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) <noreply@anthropic.com>
This commit is contained in:
2026-06-06 18:50:05 -07:00
parent 5a9fe1bc6c
commit 8a9759789f
4 changed files with 330 additions and 0 deletions

View File

@@ -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\<SID>\*` 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.

View File

@@ -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]].

View File

@@ -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 <url>` · `shot <out.png>` (real PNG to
disk → feed to `agy image-analyze`/Grok) · `click <x> <y>` · `type <text>` · `key <Key>` ·
`eval <js>` · `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 (`<py312>\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]].

279
.claude/scripts/ff.py Normal file
View File

@@ -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 <url> # navigate the page
py ff.py shot <out.png> # screenshot the page to a PNG file
py ff.py click <x> <y> # left-click at viewport coords
py ff.py type <text> # insert text into the focused element
py ff.py key <Key> # press a key (Enter/Tab/Escape/...)
py ff.py eval <js> # 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 <url>")
_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.png>")
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 <x> <y>")
_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 <text>")
_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 <Key>")
_req("/key", "POST", {"key": a[0]})
print(f"[ff] key {a[0]}")
def cmd_eval(a):
_need(a, 1, "usage: ff.py eval <js>")
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:])