From 8a9759789fad0b3c44291a32bc680fd4d2b5b56f Mon Sep 17 00:00:00 2001 From: Mike Swanson Date: Sat, 6 Jun 2026 18:50:05 -0700 Subject: [PATCH] feat(scripts): add Firefox driver (ff.py) via Playwright; disable claude-in-chrome Add .claude/scripts/ff.py, a Firefox browser driver built on Playwright and the Firefox sibling of the existing cdp.py Chrome driver. It runs a small background daemon holding one Playwright Firefox page on a persistent profile, controlled over localhost:9333, with subcommands launch/status/nav/shot/click/ type/eval/console/network/stop. Verified end-to-end (real screenshot, network and console capture). This is now the preferred browser-automation path because Mike dislikes Chrome and the claude-in-chrome extension (that connector was disabled in ~/.claude.json this session - not a repo change). Add memory reference_ff_firefox_driver.md documenting the driver and an index line in MEMORY.md. The MEMORY.md change also unavoidably includes a pre-existing adjacent index line for reference_antigravity_agy_not_headless.md, so that memory file is bundled in to keep the index consistent. Co-Authored-By: Claude Opus 4.8 (1M context) --- .claude/memory/MEMORY.md | 2 + .../reference_antigravity_agy_not_headless.md | 12 + .claude/memory/reference_ff_firefox_driver.md | 37 +++ .claude/scripts/ff.py | 279 ++++++++++++++++++ 4 files changed, 330 insertions(+) create mode 100644 .claude/memory/reference_antigravity_agy_not_headless.md create mode 100644 .claude/memory/reference_ff_firefox_driver.md create mode 100644 .claude/scripts/ff.py diff --git a/.claude/memory/MEMORY.md b/.claude/memory/MEMORY.md index d2b0763..16757ee 100644 --- a/.claude/memory/MEMORY.md +++ b/.claude/memory/MEMORY.md @@ -8,6 +8,7 @@ - [Syncro API — Invoice Verification Pattern](syncro_invoice_verification_pattern.md) — /invoices?customer_id=X returns no ticket linkage; query /invoices/{number} for ticket_id. Compare by ticket ID, not number. - [Approval Workflow: Tools vs Projects](approval-workflow-tools-vs-projects.md) — Tools (remediation, scripts): Howard/Claude with approval. Projects (GuruRMM): Mike approval; features→roadmap, bugs→bug list. - [CDP Chrome driver](reference_cdp_chrome_driver.md) — Drive Chrome via DevTools Protocol (.claude/scripts/cdp.py): visible window + screenshots-to-disk so Gemini/Grok can SEE the live site. Use localhost not 127.0.0.1; dedicated profile. Antigravity-style. +- [Firefox driver (ff.py)](reference_ff_firefox_driver.md) — PREFERRED browser driver. Drive Firefox via Playwright (.claude/scripts/ff.py): daemon on :9333, persistent profile, nav/shot/click/type/eval/console/network. Mike dislikes Chrome; claude-in-chrome connector disabled 2026-06-06. - [Community Forum (Flarum)](reference_community_forum.md) — Flarum forum at community.azcomputerguru.com, API access, database, posting workflow. - [Radio Show Website](reference_radio_website.md) — Astro static site at radio.azcomputerguru.com on IX server. - [IX Server Access](reference_ix_server_access.md) — `ix.azcomputerguru.com` / 172.16.3.10. Reachable when Tailscale is on (no VPN). SSH currently uses sshpass with root password; key auth from GURU-5070 not configured yet (was CachyOS, now Win11 — verify). @@ -44,6 +45,7 @@ - [/tmp path mismatch on Windows](feedback_tmp_path_windows.md) — Write tool and Git Bash resolve `/tmp` to DIFFERENT real dirs. Use heredoc or workspace path for JSON payloads handed to curl. - [Windows bash command mapping](feedback_windows_bash_mapping.md) — `bash` often resolves to WSL stub instead of Git/MSYS bash required by the harness. Fix by prepending `C:\Program Files\Git\bin` (and usr\bin) to PATH, or source `.claude/scripts/ensure-git-bash.ps1`. Profile has the logic; use plain `bash .claude/scripts/...` after remap. See the helper and this memory file for details. - [Git must authenticate non-interactively](feedback_git_noninteractive_auth.md) — Mike's gripe with Git for Windows is the constant password prompts (GCM) that hang automation, NOT the tool itself. D:\ClaudeTools is set to `credential.helper=store` primed with the azcomputerguru Gitea API token (host 172.16.3.20:3000); always set `GIT_TERMINAL_PROMPT=0`. Any never-prompts solution is acceptable. +- [Antigravity agy.exe is not a headless CLI](reference_antigravity_agy_not_headless.md) — the `agy` skill's real backend is `@google/gemini-cli`, not the Antigravity `agy.exe` (IDE agent, no stdout, hangs). Don't reinstall agy.exe expecting headless output. Mike has a paid Gemini account, so stay on gemini-cli past the June 18 free-tier sunset (prefer `GEMINI_API_KEY`). - [SQL instance role — verify by connections, not name](feedback_sql_instance_role_by_connection.md) — Standard installed under default `SQLEXPRESS` instance name is real. Prove role with `sys.dm_exec_sessions` + `Get-NetTCPConnection -OwningProcess` before recommending stop/uninstall. - [Clear-RecycleBin fails silently as SYSTEM](feedback_clear_recyclebin_system_context.md) — RMM-dispatched cleanup scripts cannot use `Clear-RecycleBin -Force`; the cmdlet uses Shell COM and silently no-ops without an interactive desktop. Enumerate `C:\$Recycle.Bin\\*` directly. - [Graph CA policy reads are eventually consistent](feedback_graph_ca_policy_eventual_consistency.md) — After PATCHing a CA policy (204), wait ~5s before GET-verifying; immediate reads can be stale. diff --git a/.claude/memory/reference_antigravity_agy_not_headless.md b/.claude/memory/reference_antigravity_agy_not_headless.md new file mode 100644 index 0000000..699ccf2 --- /dev/null +++ b/.claude/memory/reference_antigravity_agy_not_headless.md @@ -0,0 +1,12 @@ +--- +name: reference_antigravity_agy_not_headless +description: Antigravity CLI agy.exe is the IDE embedded agent (no stdout, SQLite store) — NOT a headless CLI. The agy skill uses @google/gemini-cli, not agy.exe. Don't reinstall agy.exe expecting a headless tool. +metadata: + type: reference +--- + +The `agy.exe` installed by Google's Antigravity CLI (`%LOCALAPPDATA%\agy\bin\agy.exe`, installer `https://antigravity.google/cli/install.ps1`) is the IDE's embedded agent, **NOT a usable headless CLI** on this fleet. Even v1.0.6's advertised `-p/--print` produces ZERO stdout and hangs when invoked non-interactively from the Bash/PowerShell tool harness — it writes only to a SQLite conversation store. First found 2026-06-05 (`session-logs/2026-06-05-mike-gururmm-platform-day.md` line 35); **re-confirmed 2026-06-06** after the GURU-5070 reinstall (reinstalled agy.exe and walked straight back into the same no-output/hang symptom). + +The `agy` SKILL (despite the name) routes to the official **`@google/gemini-cli`** (`gemini`, npm global) — that IS the real headless second-opinion tool (Google OAuth, no API key), resolved via `identity.json .gemini.binary`. Grok (`ask-grok.sh`) is the other working second model. Both were verified returning `OK` on 2026-06-06. + +**June 18 sunset — likely a non-issue for ACG.** Google is sunsetting gemini-cli's free/unpaid OAuth quota on **2026-06-18**, but Mike has a **paid Gemini account**, so the plan is to **stay on gemini-cli** (do NOT migrate to Antigravity). The bulletproof form is to auth gemini-cli with a paid **Gemini API key** (`GEMINI_API_KEY`) rather than the free OAuth quota — that path is unaffected by the OAuth-CLI sunset regardless of how the consumer tiers shake out, and is more stable for headless use. (Sources disagree on whether paid Pro/Ultra OAuth is also cut, so the API-key path is the safe bet.) **Do NOT reinstall agy.exe expecting it to work headless.** Related: [[feedback_agy_review_not_readonly]]. diff --git a/.claude/memory/reference_ff_firefox_driver.md b/.claude/memory/reference_ff_firefox_driver.md new file mode 100644 index 0000000..9080f19 --- /dev/null +++ b/.claude/memory/reference_ff_firefox_driver.md @@ -0,0 +1,37 @@ +--- +name: reference_ff_firefox_driver +description: Drive Firefox via Playwright (.claude/scripts/ff.py) — Mike's preferred browser; replaces the disliked claude-in-chrome extension +metadata: + type: reference +--- + +`.claude/scripts/ff.py` drives **Firefox** over Playwright — the Firefox sibling of +[[reference_cdp_chrome_driver]]. Mike dislikes Chrome and the `claude-in-chrome` MCP +extension, so when he asks to "look at a website / interact / collect the logs", use this, +not Chrome. (The Chrome connector was disabled 2026-06-06: keys `claudeInChromeDefaultEnabled`, +`cachedChromeExtensionInstalled` set false and `chromeExtension` pairing removed in +`~/.claude.json`; backup at `~/.claude.json.bak-prechrome`. Re-toggle in the connectors UI if it +reappears.) + +**Why a daemon, not stateless like cdp.py:** Firefox dropped most CDP support, so cdp.py's +"new WS per command" trick doesn't port. `ff.py launch` spawns a background daemon holding ONE +Playwright Firefox page on a **persistent profile** (`~/.claude/ff-profile`, logins survive); +every other subcommand is a thin HTTP client to it on `localhost:9333` (env `FF_PORT`). The page +persists between calls (nav now, shot later) and the daemon accumulates console + network logs. + +**Commands:** `launch [url] [--headless]` · `status` · `nav ` · `shot ` (real PNG to +disk → feed to `agy image-analyze`/Grok) · `click ` · `type ` · `key ` · +`eval ` · `console [--clear]` · `network [--clear]` · `stop`. Default headed (visible) so Mike +can log into authenticated apps once; Claude still must NOT type passwords. + +**Gotchas (both bit during build, 2026-06-06):** +- **`py` honors a script's shebang.** ff.py's `#!/usr/bin/env python` makes `py ff.py` resolve + `python` via PATH → **Python 3.12**, while bare `py -c` uses the default **3.14**. Playwright is + installed in BOTH now (`\python.exe -m pip install playwright` + `... -m playwright install + firefox`), so it's interpreter-agnostic. If `ModuleNotFoundError: playwright` recurs after a + Python upgrade, install playwright into whatever `py .claude/scripts/ff.py status` actually runs. +- The detached daemon's stdio is redirected to `~/.claude/ff-daemon.log` (NOT inherited) — otherwise + `launch` never returns control and startup crashes are invisible. Check that log if `launch` hangs. + +Verified end-to-end 2026-06-06: launch→status→eval→shot (26KB real render of example.com)→network +(200 captured)→console (caught an injected log). See [[reference_cdp_chrome_driver]]. diff --git a/.claude/scripts/ff.py b/.claude/scripts/ff.py new file mode 100644 index 0000000..7e7f2bc --- /dev/null +++ b/.claude/scripts/ff.py @@ -0,0 +1,279 @@ +#!/usr/bin/env python +""" +ff.py - drive Firefox over Playwright, the Firefox sibling of cdp.py. + +Firefox dropped most of its CDP support, so the stateless "new connection per +command" trick cdp.py uses against Chrome's debug port doesn't port cleanly. +Instead `launch` spawns a small background daemon that holds ONE Playwright +Firefox page (on a persistent profile, so logins survive); every other +subcommand is a thin HTTP client to that daemon. The page persists between +calls (nav now, shot later) and the daemon accumulates console + network logs +for retrieval -- the "collect the logs" use case. + +Usage: + py ff.py launch [url] [--headless] # start the background Firefox daemon + py ff.py status # daemon health + current url/title + py ff.py nav # navigate the page + py ff.py shot # screenshot the page to a PNG file + py ff.py click # left-click at viewport coords + py ff.py type # insert text into the focused element + py ff.py key # press a key (Enter/Tab/Escape/...) + py ff.py eval # page.evaluate(js), prints JSON result + py ff.py console [--clear] # dump collected console messages (JSON) + py ff.py network [--clear] # dump collected network requests (JSON) + py ff.py stop # shut the daemon down + +Env: FF_PORT (control port, default 9333) + FF_PROFILE (default %USERPROFILE%\\.claude\\ff-profile) +""" +import sys, os, json, time, subprocess, urllib.request, urllib.error + +PORT = int(os.environ.get("FF_PORT", "9333")) +BASE = f"http://localhost:{PORT}" +PROFILE = os.environ.get("FF_PROFILE", os.path.join(os.path.expanduser("~"), ".claude", "ff-profile")) + + +# --------------------------------------------------------------------------- # +# client side (the CLI you actually type) +# --------------------------------------------------------------------------- # +def _req(path, method="GET", body=None, timeout=30): + data = json.dumps(body).encode() if body is not None else None + r = urllib.request.Request(BASE + path, data=data, method=method, + headers={"Content-Type": "application/json"}) + with urllib.request.urlopen(r, timeout=timeout) as resp: + raw = resp.read().decode() + return json.loads(raw) if raw else {} + + +def _alive(): + try: + _req("/status", timeout=2) + return True + except Exception: + return False + + +def cmd_launch(args): + headless = "--headless" in args + url = next((a for a in args if not a.startswith("--")), None) + if _alive(): + print(f"[ff] daemon already running on {BASE}") + if url: + _req("/nav", "POST", {"url": _fix(url)}) + print(f"[ff] navigated -> {_fix(url)}") + return + os.makedirs(PROFILE, exist_ok=True) + flags = subprocess.CREATE_NEW_PROCESS_GROUP | 0x00000008 # DETACHED_PROCESS + env = dict(os.environ, FF_DAEMON="1", FF_HEADLESS="1" if headless else "0", + FF_START_URL=_fix(url) if url else "about:blank") + # Redirect the detached child's stdio to a logfile -- otherwise it inherits + # the parent's stdout pipe (caller never gets control back) and any startup + # crash is invisible. + log = open(os.path.join(os.path.dirname(PROFILE), "ff-daemon.log"), "w") + subprocess.Popen([sys.executable, os.path.abspath(__file__), "_serve"], + env=env, creationflags=flags, close_fds=True, + stdin=subprocess.DEVNULL, stdout=log, stderr=log) + for _ in range(60): + if _alive(): + print(f"[ff] daemon up on {BASE} (headless={headless}) profile={PROFILE}") + if url: + print(f"[ff] start url -> {_fix(url)}") + return + time.sleep(0.5) + raise SystemExit("[ff] daemon failed to start (check that 'py -m playwright install firefox' ran)") + + +def _fix(url): + if url and "://" not in url and url != "about:blank": + return "https://" + url + return url + + +def _need(args, n, what): + if len(args) < n: + raise SystemExit(f"[ff] {what}") + + +def cmd_status(a): + print(json.dumps(_req("/status"), indent=2)) + + +def cmd_nav(a): + _need(a, 1, "usage: ff.py nav ") + _req("/nav", "POST", {"url": _fix(a[0])}) + print(f"[ff] navigated -> {_fix(a[0])}") + + +def cmd_shot(a): + _need(a, 1, "usage: ff.py shot ") + out = os.path.abspath(a[0]) + _req("/shot", "POST", {"path": out}) + print(f"[ff] screenshot -> {out} ({os.path.getsize(out)} bytes)") + + +def cmd_click(a): + _need(a, 2, "usage: ff.py click ") + _req("/click", "POST", {"x": float(a[0]), "y": float(a[1])}) + print(f"[ff] click ({a[0]},{a[1]})") + + +def cmd_type(a): + _need(a, 1, "usage: ff.py type ") + _req("/type", "POST", {"text": a[0]}) + print(f"[ff] typed {len(a[0])} chars") + + +def cmd_key(a): + _need(a, 1, "usage: ff.py key ") + _req("/key", "POST", {"key": a[0]}) + print(f"[ff] key {a[0]}") + + +def cmd_eval(a): + _need(a, 1, "usage: ff.py eval ") + print(json.dumps(_req("/eval", "POST", {"js": a[0]}).get("value"), indent=2, default=str)) + + +def cmd_console(a): + res = _req("/console" + ("?clear=1" if "--clear" in a else "")) + print(json.dumps(res.get("messages", []), indent=2, default=str)) + + +def cmd_network(a): + res = _req("/network" + ("?clear=1" if "--clear" in a else "")) + print(json.dumps(res.get("requests", []), indent=2, default=str)) + + +def cmd_stop(a): + if not _alive(): + print("[ff] daemon not running") + return + try: + _req("/stop", "POST", {}, timeout=5) + except Exception: + pass + print("[ff] daemon stopped") + + +# --------------------------------------------------------------------------- # +# daemon side (py ff.py _serve) -- holds the live Firefox page +# --------------------------------------------------------------------------- # +def serve(): + from http.server import BaseHTTPRequestHandler, HTTPServer + from urllib.parse import urlparse, parse_qs + import threading + from playwright.sync_api import sync_playwright + + headless = os.environ.get("FF_HEADLESS") == "1" + start_url = os.environ.get("FF_START_URL", "about:blank") + + pw = sync_playwright().start() + ctx = pw.firefox.launch_persistent_context(PROFILE, headless=headless, + viewport={"width": 1280, "height": 800}) + page = ctx.pages[0] if ctx.pages else ctx.new_page() + + console_log, network_log = [], [] + page.on("console", lambda m: console_log.append( + {"type": m.type, "text": m.text, "location": m.location})) + page.on("response", lambda r: network_log.append( + {"status": r.status, "method": r.request.method, "url": r.url, + "type": r.request.resource_type})) + page.on("pageerror", lambda e: console_log.append( + {"type": "pageerror", "text": str(e), "location": {}})) + if start_url and start_url != "about:blank": + try: + page.goto(start_url, wait_until="load", timeout=30000) + except Exception: + pass + + class H(BaseHTTPRequestHandler): + def log_message(self, *a): # silence + pass + + def _reply(self, obj, code=200): + b = json.dumps(obj, default=str).encode() + self.send_response(code) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(b))) + self.end_headers() + self.wfile.write(b) + + def _body(self): + n = int(self.headers.get("Content-Length", 0)) + return json.loads(self.rfile.read(n)) if n else {} + + def do_GET(self): + u = urlparse(self.path) + q = parse_qs(u.query) + try: + if u.path == "/status": + self._reply({"ok": True, "url": page.url, "title": page.title(), + "headless": headless, "console": len(console_log), + "network": len(network_log)}) + elif u.path == "/console": + msgs = list(console_log) + if q.get("clear"): + console_log.clear() + self._reply({"messages": msgs}) + elif u.path == "/network": + reqs = list(network_log) + if q.get("clear"): + network_log.clear() + self._reply({"requests": reqs}) + else: + self._reply({"error": "not found"}, 404) + except Exception as e: + self._reply({"error": str(e)}, 500) + + def do_POST(self): + u = urlparse(self.path) + try: + b = self._body() + if u.path == "/nav": + page.goto(b["url"], wait_until="load", timeout=30000) + self._reply({"ok": True, "url": page.url}) + elif u.path == "/shot": + page.screenshot(path=b["path"], full_page=b.get("full", False)) + self._reply({"ok": True}) + elif u.path == "/click": + page.mouse.click(b["x"], b["y"]) + self._reply({"ok": True}) + elif u.path == "/type": + page.keyboard.insert_text(b["text"]) + self._reply({"ok": True}) + elif u.path == "/key": + page.keyboard.press(b["key"]) + self._reply({"ok": True}) + elif u.path == "/eval": + self._reply({"value": page.evaluate(b["js"])}) + elif u.path == "/stop": + self._reply({"ok": True}) + threading.Thread(target=httpd.shutdown, daemon=True).start() + else: + self._reply({"error": "not found"}, 404) + except Exception as e: + self._reply({"error": str(e)}, 500) + + httpd = HTTPServer(("127.0.0.1", PORT), H) + try: + httpd.serve_forever() + finally: + try: + ctx.close() + except Exception: + pass + pw.stop() + + +CMDS = {"launch": cmd_launch, "status": cmd_status, "nav": cmd_nav, "shot": cmd_shot, + "click": cmd_click, "type": cmd_type, "key": cmd_key, "eval": cmd_eval, + "console": cmd_console, "network": cmd_network, "stop": cmd_stop} + +if __name__ == "__main__": + if len(sys.argv) >= 2 and sys.argv[1] == "_serve": + serve() + elif len(sys.argv) < 2 or sys.argv[1] not in CMDS: + print(__doc__) + raise SystemExit(1) + else: + CMDS[sys.argv[1]](sys.argv[2:])