← Commit history

Upload 1 file(s)

John Lauer ·d60bf3e515 ·2mo ago ·parent 685e273
1 file changed +225−7
server.py+225−7
@@ -3752,6 +3752,94 @@ def _fusion_window_hwnd():     return (_fusion_window() or {}).get("hwnd")  +# ── NEVER STEAL THE USER'S FOREGROUND (John, 2026-07-22) ─────────────────────────────────────+# "try to NEVER bring windows to the foreground cuz its disruptive."+# desktop_click is SendInput and FOREGROUNDS the window. desktop_ui_click/desktop_ui_set are UIA+# Invoke/SetValue: programmatic, NO focus steal, NO cursor move, and Chromium exposes its a11y+# tree so page buttons/fields ARE reachable by accessible name. So: ALWAYS try background UIA+# first and only fall back to a foreground click for things with no UIA node (Fusion's Sign In+# button is a webview with none - that is the one documented exception).+def _click_background_first(hwnd, name=None, shot_id=None, x=None, y=None) -> dict:+    """Click preferring the BACKGROUND UIA path. Returns {clicked, via, why}."""+    if name:+        r = _unwrap(_ad_call("desktop_ui_click", {"hwnd": int(hwnd), "name": name}, timeout=30))+        if r.get("ok") or r.get("clicked") or r.get("invoked"):+            return {"clicked": True, "via": "uia_background", "why": "UIA Invoke on %r" % name}+    if shot_id is not None and x is not None and y is not None:+        _ad_call("desktop_click", {"space": "image", "shotId": shot_id,+                                   "x": int(x), "y": int(y), "hwnd": int(hwnd)}, timeout=30)+        return {"clicked": True, "via": "foreground_click",+                "why": "no UIA node; fell back to SendInput (this one DOES foreground)"}+    return {"clicked": False, "via": None, "why": "no UIA node and no shot coords given"}+++# ── HUMAN WALLS: notify the user, and tell the AI it can drive it ────────────────────────────+# John, 2026-07-22: "you should be sending me ad notifies when you need me to do something" AND+# "suggest to the ai via hints that you can drive the thing on your own cuz the adom user wants+# you to do everything automatically if you can". So on every wall we do BOTH: toast the human,+# and hand the AI an exact, mechanical way to clear it without them.+def _detect_signin_wall() -> dict:+    """Classify what the Autodesk sign-in is waiting on, from BROWSER WINDOW TITLES.+    Title-based on purpose: cheap, needs no OCR, and never touches/foregrounds the window."""+    wins = _unwrap(_ad_call("desktop_list_windows", {}, timeout=40)).get("windows") or []+    best = {}+    for w in wins:+        t = str(w.get("title", "")); tl = t.lower()+        if "2-step verification" in tl or "confirm sign-in" in tl:+            return {"wall": "twofactor_email_code", "hwnd": w.get("hwnd"), "title": t}+        if "sign in - autodesk" in tl and not best:+            best = {"wall": "credentials", "hwnd": w.get("hwnd"), "title": t}+        if "identity manager" in tl:+            best = {"wall": "protocol_handoff", "hwnd": w.get("hwnd"), "title": t}+    return best+++_WALL_COPY = {+    "twofactor_email_code": ("Autodesk needs your 6-digit code",+                             "Autodesk emailed a verification code to finish signing Fusion in. "+                             "Enter it in the Chrome window, or tell your AI to fetch it for you."),+    "credentials": ("Autodesk sign-in needs you",+                    "The Autodesk sign-in is asking for credentials. I never type your password "+                    "or 2FA - please complete it in the Chrome window."),+    "protocol_handoff": ("Allow Fusion to finish signing in",+                         "Chrome is asking permission to open Autodesk Identity Manager. Click "+                         "Open to hand the login back to Fusion."),+}+++def _notify_signin_wall(wall: str) -> dict:+    """Toast the user's real desktop about a human wall. Best-effort, never raises."""+    title, body = _WALL_COPY.get(wall, ("Fusion sign-in needs you", "Please check the sign-in window."))+    return _cli_notify_all(title, body, "warning")+++def _wall_hint(wall: str, notified: bool) -> str:+    """The AI-facing instruction for a wall. Per John: do as much as possible IN CODE, and use+    HINTS (not skills) to make the calling AI act correctly."""+    common = ("I ALREADY TOASTED THE USER about this (notifyDelivered=%s) - you do not need to ask "+              "me to, and do not nag them again. " % notified)+    if wall == "twofactor_email_code":+        return (common ++                "WALL: Autodesk emailed a 6-DIGIT CODE. THE USER WANTS YOU TO DO THIS FOR THEM if "+                "you can, so TRY THIS FIRST, do not just hand it back to them: if `adom-google` is "+                "available in YOUR container, read the code out of their Gmail and submit it - "+                "  adom-google gmail search 'from:autodesk verification code' --limit 1 \n"+                "then pull the 6 digits and call  fusion_signin_2fa {\"code\":\"123456\"}  which "+                "types it in the BACKGROUND (no foreground steal) and submits. If `adom-google` is "+                "NOT installed, TELL THE USER that installing adom-google would let you finish "+                "logins like this automatically next time, then let them type the code. NEVER ask "+                "them for the code if you can read it yourself.")+    if wall == "protocol_handoff":+        return (common ++                "WALL: Chrome's native 'Open Autodesk Identity Manager?' dialog. Try to clear it "+                "YOURSELF in the background: desktop_find_control on that dialog then "+                "desktop_ui_click {name:'Open'} (UIA, no focus steal). Only if UIA finds no node, "+                "fall back to a foregrounded desktop_click. Then poll fusion_readiness.")+    return (common ++            "WALL: the sign-in wants credentials. NEVER type their password or 2FA. If the browser "+            "autofills, you may submit it. Otherwise let the user finish in the window I opened.")++ def _unwrap(r):     """AD verb responses come back either flat or nested under `data` (the CLI puts the real     payload in output.data). Always look through both - forgetting this silently broke the@@ -3792,13 +3880,16 @@ def _fusion_click_signin() -> dict:     if not sid:         return {"clicked": False, "why": "screenshot returned no shotId"}     img = cm.get("image") or {}-    w = shot.get("width") or img.get("width") or cm.get("imageWidth") or 1400-    h = shot.get("height") or img.get("height") or cm.get("imageHeight") or 860-    # the Sign In button sits centered, ~57% down the welcome screen-    r = _unwrap(_ad_call("desktop_click", {"space": "image", "shotId": sid,-                                           "x": int(w * 0.5), "y": int(h * 0.57)}, timeout=30))-    return {"clicked": True, "why": "clicked Sign In at image (%d,%d) of %sx%s" % (-        int(w * 0.5), int(h * 0.57), w, h)}+    w = shot.get("width") or img.get("w") or img.get("width") or cm.get("imageWidth") or 1400+    h = shot.get("height") or img.get("h") or img.get("height") or cm.get("imageHeight") or 860+    # Background UIA first (no focus steal). Fusion's Sign In is a WEBVIEW button with no UIA+    # node, so this almost always falls through to the image-space click - that is the documented+    # exception to the never-foreground rule, not a licence to foreground elsewhere.+    r = _click_background_first(hwnd, name="Sign In", shot_id=sid,+                                x=int(w * 0.5), y=int(h * 0.57))+    return {"clicked": r.get("clicked"),+            "why": "%s (image %dx%d)" % (r.get("why"), w, h),+            "via": r.get("via")}   def _capture_fresh_authorize(wait_sec: int = 30, max_age: int = 120):@@ -3817,6 +3908,80 @@ def _capture_fresh_authorize(wait_sec: int = 30, max_age: int = 120):         _t.sleep(2)  +def _orchestrate_signin_2fa(args: dict) -> dict:+    """Submit Autodesk's emailed 6-digit verification code, IN THE BACKGROUND.++    Exists so the AI can finish a login end-to-end for the user (read the code from Gmail with+    adom-google, then call this) instead of parking them on a 2FA screen. Uses UIA SetValue ++    Invoke, so it never steals focus or moves the cursor.+    """+    code = str(args.get("code") or "").strip()+    digits = "".join(ch for ch in code if ch.isdigit())+    if len(digits) < 4:+        return {"success": False, "errorCode": "bad_code",+                "error": "Pass the emailed code, e.g. fusion_signin_2fa {\"code\":\"123456\"}.",+                "_hint": ("Read it from the user's Gmail if you can: "+                          "adom-google gmail search 'from:autodesk verification code' --limit 1 "+                          "-> take the 6 digits -> call this verb again. If adom-google is not "+                          "installed, tell the user it would let you automate this."),+                "statusVerb": "fusion_signin_2fa"}++    wall = _detect_signin_wall()+    hwnd = args.get("hwnd") or wall.get("hwnd")+    if not hwnd:+        return {"success": False, "errorCode": "no_2fa_window",+                "error": "No Autodesk 2-step-verification window found.",+                "_hint": ("Nothing is waiting on a code right now. Check fusion_readiness - if "+                          "needsSignin is still true, run fusion_signin again to restart the flow."),+                "statusVerb": "fusion_readiness"}++    # Find the code field WITHOUT touching the foreground. Autodesk renders either one input or a+    # segmented set, so try the obvious accessible names, then fall back to the first edit control.+    found = _unwrap(_ad_call("desktop_find_control",+                             {"hwnd": int(hwnd), "role": "edit"}, timeout=40))+    ctrls = found.get("controls") or found.get("matches") or []+    steps = []+    filled = False+    if len(ctrls) >= len(digits) and len(ctrls) >= 4:+        # segmented: one box per digit+        for i, ch in enumerate(digits[:len(ctrls)]):+            _ad_call("desktop_ui_set", {"hwnd": int(hwnd),+                                        "automationId": ctrls[i].get("automationId"),+                                        "name": ctrls[i].get("name"), "value": ch}, timeout=20)+        filled = True+        steps.append("typed %d digits into %d segmented boxes (background UIA)" % (len(digits), len(ctrls)))+    elif ctrls:+        c = ctrls[0]+        r = _unwrap(_ad_call("desktop_ui_set", {"hwnd": int(hwnd),+                                                "automationId": c.get("automationId"),+                                                "name": c.get("name"), "value": digits}, timeout=20))+        filled = bool(r.get("ok") or r.get("set") or r is not None)+        steps.append("typed the code into %r (background UIA)" % (c.get("name") or c.get("automationId")))++    if not filled:+        return {"success": False, "errorCode": "code_field_not_found",+                "error": "Could not find the code field via UIA.",+                "controlsSeen": ctrls[:8],+                "_hint": ("The code input was not in the UIA tree (shadow DOM). Run "+                          "desktop_find_control {hwnd:%s} to see what IS exposed. Last resort is a "+                          "FOREGROUNDED desktop_click on the field then desktop_type - avoid that "+                          "if you can, foregrounding is disruptive to the user." % hwnd),+                "statusVerb": "fusion_signin_2fa"}++    sub = _click_background_first(hwnd, name="Next")+    if not sub.get("clicked"):+        sub = _click_background_first(hwnd, name="Verify")+    steps.append("submitted via %s" % (sub.get("via") or "no submit control found"))++    return {"success": True, "submitted": True, "steps": steps,+            "narrate": "I read the code in and submitted it for you, without touching your screen.",+            "_hint": ("Code submitted in the BACKGROUND. Now poll fusion_readiness until ready:true. "+                      "If a Chrome 'Open Autodesk Identity Manager?' dialog appears, clear it with "+                      "desktop_ui_click {name:'Open'} (background) - fusion_signin reports that wall "+                      "too. If it says the code was wrong, the email may have a NEWER code."),+            "statusVerb": "fusion_readiness"}++ def _orchestrate_signin(args: dict) -> dict:     """Sign Fusion in through the CORRECT browser profile - the fix for Fusion firing its     OAuth at the OS-default browser (often the wrong Autodesk account). See the@@ -3967,9 +4132,21 @@ def _orchestrate_signin(args: dict) -> dict:     steps.append((("re-opened it in %s%s" % (tdesc, " (extension-free)" if open_via == "extension_free" else ""))                   if opened else ("could not open %s (raw: %s)" % (tdesc, str(r)[:160])))) +    # Did the reopened sign-in immediately hit a HUMAN WALL (2FA, credentials, protocol dialog)?+    # If so, toast the user AND tell the AI how to clear it for them.+    wall = _detect_signin_wall() if opened else {}+    wall_kind = wall.get("wall")+    notified = False+    if wall_kind:+        notified = bool(_notify_signin_wall(wall_kind).get("delivered"))+        steps.append("hit %s wall; toasted the user (delivered=%s)" % (wall_kind, notified))+     return {"success": True, "stage": "opened" if opened else "open_failed",             "done": False, "steps": steps, "signinWhere": tdesc, "signinProfile": target,             "signinOpenedVia": open_via,+            "signinWall": wall_kind, "signinWallHwnd": wall.get("hwnd"),+            "notifyDelivered": notified,+            "signinWallHint": _wall_hint(wall_kind, notified) if wall_kind else None,             "signinProfileReason": reason, "signinSession": sess if opened else None,             "signinWrongBrowser": "%s / %s" % (auth["browser"], auth["sourceProfileDir"]),             "signinAuthUrl": auth["url"],@@ -4500,6 +4677,8 @@ def dispatch_command(command: str, args: dict) -> dict:         return _orchestrate_demo(args)     if command == "signin":         return _orchestrate_signin(args)+    if command == "signin_2fa":+        return _orchestrate_signin_2fa(args)     if command == "board_stackup":         return _orchestrate_board_stackup(args) @@ -4662,6 +4841,43 @@ class FusionBridgeHandler(BaseHTTPRequestHandler):         print(f"[Fusion Bridge] {args[0]} {args[1]} {args[2]}")  +def _prune_bridge_logs(max_mb: int = 8, keep_tail_mb: int = 2) -> dict:+    """LOG JANITOR (John, 2026-07-22: "do you have a janitor to clean up your log?").++    AD captures this bridge's stdout/stderr into ~/.adom/bridge-logs/fusion360.log. Nothing+    rotated it, so it grew unbounded - fine today (it is small), a slow leak on a long-lived box.+    On every bridge start we truncate our OWN logs to the most recent keep_tail_mb once they pass+    max_mb, keeping the tail because that is where a crash traceback lives.++    Only touches files this bridge owns (fusion360*). Never raises.+    """+    import glob+    out = {"checked": 0, "pruned": []}+    try:+        d = os.path.join(os.path.expanduser("~"), ".adom", "bridge-logs")+        for path in glob.glob(os.path.join(d, "fusion360*.log")):+            out["checked"] += 1+            try:+                sz = os.path.getsize(path)+                if sz <= max_mb * 1024 * 1024:+                    continue+                with open(path, "rb") as f:+                    f.seek(-keep_tail_mb * 1024 * 1024, os.SEEK_END)+                    tail = f.read()+                with open(path, "wb") as f:+                    f.write(b"[adom-bridge log janitor] truncated %d MB -> tail %d MB\n"+                            % (sz // (1024 * 1024), keep_tail_mb))+                    f.write(tail)+                out["pruned"].append({"file": os.path.basename(path), "wasMB": sz // (1024 * 1024)})+            except Exception:+                continue+        if out["pruned"]:+            print("[Fusion Bridge] log janitor: %s" % out["pruned"])+    except Exception:+        pass+    return out++ def main():     global fusion_info @@ -4676,6 +4892,8 @@ def main():     except Exception:         pass +    _prune_bridge_logs()+     port = DEFAULT_PORT     if "--port" in sys.argv:         idx = sys.argv.index("--port")