Skip to content

Handoff: drive local agents over ACP in the termchart bridge (unlock browser/tools/approvals); logged-in browsing blocked by Chrome default-profile automation #331

Description

@ivanmkc

Handoff: drive local agents over ACP in the termchart bridge (unlock browser / tools / approvals)

Status: spike proven; blocked on logged-in browser auth by a Chrome security restriction. Another agent can pick this up cold from this issue.

Author of work: prior agent session (Ivan). Written for a fresh agent with zero prior context.


0. TL;DR

  • termchart's local bridge (packages/clitermchart bridge) drives locally-installed AI CLIs. Today it shells out one-shot (claude -p, codex exec -, gemini -p). That strips MCP servers, tools, approvals, and persistent sessions — e.g. Claude reports NO_BROWSER_TOOL, so "open Amazon and check my orders" is impossible through the bridge.
  • T3 Code (reference architecture) instead speaks each agent's native bidirectional protocol: ACP (Agent Client Protocol) for Claude, and Codex's app-server JSON-RPC. That gives full tool use, per-session MCP injection, streaming, and interactive permissions.
  • Decision (user): adopt ACP for the termchart bridge; surface permission requests to the user as approvals.
  • A standalone ACP spike (in /tmp/acp-spike, ephemeral — full code embedded below) proved ACP gives Claude a real browser: injected the Playwright MCP into session/new, Claude ran mcp__playwright__browser_navigate/find/snapshot and read live pages.
  • Amazon dry-run: isolated (logged-out) browser reached Amazon with no captcha (good feasibility signal), reported NOT_LOGGED_IN. Using the real logged-in session is the open problem (see §5).
  • Blocker: modern Chrome refuses --remote-debugging automation when --user-data-dir is the Default profile (anti-cookie-theft). So Playwright can't drive the real profile directly. Viable paths: copy the profile to a non-default dir, or Playwright MCP --extension (attach to live Chrome). Claude Desktop's "import cookies" is not reachable via ACP (different product).

1. Goal

Let the termchart bridge drive local agents with their full capabilities (tools, MCP servers, approvals, persistent sessions), so lifeboard can ask an agent to do real auth-gated web tasks (the motivating example: "open Amazon and check recent orders") without us writing any auth/cookie code — the browser owns the session.

2. Repos & where things live

  • termchartgithub.com/ivanmkc/termchart, branch lifeboard/19-review-fixes. This is where the bridge + integration live.
    • Bridge CLI command: packages/cli/src/bridge.ts (dispatched from packages/cli/src/cli.ts:366).
    • Bridge server + drivers: packages/cli/src/bridge/server.ts, drivers.ts, jobs.ts, security.ts, schedule.ts, notify.ts.
    • ⚠️ The built packages/cli/dist/cli.js is stale (missing the bridge command). Run from source: npx tsx packages/cli/src/cli.ts bridge --port 8787, or rebuild (npm run build:cli).
  • agent-bridgegithub.com/ivanmkc/agent-bridge (sibling prototype). PR feat(render): fail-fast guard for oversized graphs + edge-case verifications #3 (uca-phase1) shipped R40 = interactive stream-json (control_request → approval.requested), the proprietary analog of ACP's permission flow. Issues fix(er): render full ER relationship labels (patch + bundle beautiful-mermaid) #1/feat(cli): --rounded (rounded corners/elbows) #2 are its control-plane/board spec. Useful precedent for the approvals model.
  • T3 Code — reference architecture (pingdotgg/t3code, cloned at /tmp/t3code). Key packages: packages/effect-acp (ACP client, downloads the agentclientprotocol/agent-client-protocol schema), packages/effect-codex-app-server (Codex app-server), apps/server/src/provider/Drivers/ClaudeExecutable.ts, apps/server/src/cli/triage.ts.

3. The termchart bridge today (verified capability matrix)

HTTP API (loopback only; auth = Authorization: Bearer <token> + trusted Origin: http://127.0.0.1 for writes):

  • GET /health{ok, version, capabilities:[agents,jobs,notify]}
  • GET /agents → detected drivers + auth + models
  • POST /agents/:id/run {prompt, model?} → sync one-shot turn → {output} (prompt on stdin)
  • POST /jobs {driverId, prompt}201 {id}; GET /jobs/:id; GET /events (SSE, event: job); jobs are a bridge-side multi-step loop (≤20 steps, 5min/step) and return a canvas component ({"type":"component",...}) for lifeboard.
  • /schedule, /notify.

Live results (this machine): claude 2.1.259 (Claude Pro) — sync run ✅ and ran Bash unattended (no approval gate). codex 0.153.0 (ChatGPT Plus) — invocation OK but usage-limited (resets ~Sep 7). gemini 0.17.1 (Google AI) — broken (see §4).

4. Bugs / findings in the current bridge (worth fixing regardless of ACP)

  1. Gemini driver mis-wired (packages/cli/src/bridge/drivers.ts): runs gemini -p with the prompt on stdin, but gemini -p wants the prompt as an argument"Not enough arguments following: p". Gemini is currently undrivable. (Note: gemini --experimental-acp speaks ACP natively — could be the fix + ACP proof for gemini.)
  2. Codex error truncation (runDriver): slices errors to 300 chars = codex's startup banner, hiding the real reason (the "usage limit" message was cut off).
  3. Codex model mismatch: /agents advertises gpt-5; codex actually launches gpt-5.6-luna.
  4. No MCP/tools/approvals via claude -p: the driver passes ["-p", ...model] with no --mcp-config/permission flags, so no browser/MCP tools load and there's no approval interaction. This is the core capability gap ACP fixes.

5. What was proven with ACP (the spike)

Setup: mkdir /tmp/acp-spike && cd /tmp/acp-spike && npm init -y (set "type":"module"), then npm i @zed-industries/agent-client-protocol@0.4.5. Claude ACP agent runs via npx -y @zed-industries/claude-code-acp (v0.16.2). Browser MCP via npx -y @playwright/mcp@latest.

  • spike.mjs (example.com proof): ACP initializesession/new with Playwright MCP in mcpServerssession/prompt → Claude issued mcp__playwright__browser_navigate/find/snapshot and returned the real <h1> "Example Domain". stopReason: end_turn. ✅ ACP + injected MCP gives Claude a browser tool claude -p lacks.
  • amazon-profile.mjs (isolated variant reached Amazon logged-out with no captcha at the homepage → strong feasibility signal; profile variant hit the blocker in §6).

Gotchas discovered:

  • Env sanitization required: launching the ACP agent from inside another Claude session trips a nested-session guard. Strip CLAUDECODE* env vars from the child's env (for k: if /^CLAUDECODE$|^CLAUDE_CODE/ delete).
  • ACP schema-version mismatch (non-fatal): @zed-industries/agent-client-protocol@0.4.5 rejects some tool_call_update notifications (rawOutput field) from claude-code-acp@0.16.2 with -32602 Invalid params. Turn still completes. Fix by bumping the lib, matching versions, or a tolerant validator.
  • To allow a session/request_permission, respond { outcome: { outcome: "selected", optionId: <the allow_once option's optionId> } }.

6. The current blocker — logged-in browser auth

Motivating task needs the user's logged-in Amazon session. Findings:

  • Claude Desktop's "import cookies" is NOT reachable via ACP. ACP drives Claude Code (CLI), which has no built-in browser/cookie capability. The cookie importer belongs to the Claude Desktop app (/Applications/Claude.app, its own Electron cookie store) — different product/runtime. Dead end for the bridge.
  • Direct --user-data-dir=<Default profile> is blocked by Chrome. Playwright launches Chrome but launchPersistentContext times out on --remote-debugging-pipe: recent Chrome refuses remote-debugging/automation on the Default profile (anti-cookie-theft). Not a lock and not our bug (confirmed with playwright-core chromium.launchPersistentContext(defaultProfile, {channel:'chrome'}) → timeout after Chrome launches pid).
    • Operational notes: Chrome must be fully quit first (it locks the profile); a hard kill leaves stale ~/Library/Application Support/Google/Chrome/Singleton* symlinks that must be rm'd; failed Playwright launches can leave zombie Google Chrome processes — clean them.
    • Default profile is 3.7 GB (~1.8 GB minus caches).

Viable "browser owns cookies" options (pick one):

  • (A) Copy the profile to a non-default dir (exclude Cache, Code Cache, GPUCache, Service Worker), launch Playwright/Chrome with --user-data-dir=<copy>. Chrome's restriction targets the default path, so a copy is automatable and carries cookies (decryptable on same machine/Keychain — may trigger a one-time macOS Keychain prompt). Fully autonomous otherwise.
  • (B) Playwright MCP --extension: install the Playwright MCP browser extension in the user's Chrome once, start MCP with --extension, connect → Claude drives the live logged-in Chrome. Cleanest match to "use my logged-in browser"; requires a one-time manual extension install and reconnect.
  • (C) One-time login in the Playwright MCP's own persistent (non-isolated) profile — user logs into Amazon once; stays logged in thereafter.

7. Decisions already locked by the user

  • ✅ Use ACP for the bridge (not one-shot -p/exec).
  • ✅ Permissions: route session/request_permission to the user as approvals (not blanket auto-approve). Mirror agent-bridge PR feat(render): fail-fast guard for oversized graphs + edge-case verifications #3's R40 approval model / lifeboard's approval UX. (The spike auto-approves only because it's a controlled test.)
  • Prove the logged-in Amazon dry-run first, then integrate ACP into the bridge.
  • ⚠️ User initially chose "use my Chrome profile" — now known blocked (§6). Needs a re-pick between (A)/(B)/(C).

8. Next steps (ordered, concrete)

  1. Unblock logged-in browsing — implement option (A) profile-copy (most autonomous) or (B) extension. Re-run the Amazon dry-run (login-state only, STOP before orders — see prompt in the embedded code) to confirm LOGGED_IN. Then, with user OK, the real "recent orders" read.
  2. Fix the gemini driver and codex error truncation in packages/cli/src/bridge/drivers.ts (quick wins, independent of ACP).
  3. Integrate an ACP driver into the termchart bridge:
    • Add an acp-backed runner alongside runDriver in drivers.ts (or a new bridge/acp.ts). Spawn the ACP agent (claude-code-acp; gemini via --experimental-acp), connect with @zed-industries/agent-client-protocol ClientSideConnection + ndJsonStream over the child's stdio.
    • Route through /jobs + /events (ACP is streaming/long) — map ACP session/update (tool_call, message chunks, plan) onto job progress + lifeboard canvas components.
    • Route session/request_permission to the user (surface via /events; add a respond endpoint) per the locked decision.
    • Inject mcpServers per session (browser MCP configurable). Sanitize CLAUDECODE* env. Handle the schema-version mismatch (§5).
    • Add tests (mock ACP peer like T3's packages/effect-acp/test/fixtures/acp-mock-peer.ts).
  4. Keep it "don't roll our own auth": the browser MCP owns cookies.

9. Reference links


Appendix A — spike.mjs (example.com ACP+browser proof)

// ACP spike: drive real Claude Code over ACP with a browser MCP injected.
import { spawn } from "node:child_process";
import { Writable, Readable } from "node:stream";
import { ClientSideConnection, ndJsonStream, PROTOCOL_VERSION } from "@zed-industries/agent-client-protocol";
const log = (...a) => console.log("[spike]", ...a);
const agentText = [], toolCalls = [];
const cleanEnv = { ...process.env };
for (const k of Object.keys(cleanEnv)) if (/^CLAUDECODE$|^CLAUDE_CODE/.test(k)) delete cleanEnv[k];
const child = spawn("npx", ["-y", "@zed-industries/claude-code-acp"], { stdio: ["pipe","pipe","pipe"], env: cleanEnv });
child.stderr.on("data", (d) => process.stderr.write(`[acp-agent] ${d}`));
const client = {
  async sessionUpdate({ update }) {
    const k = update.sessionUpdate;
    if (k === "agent_message_chunk") agentText.push(update.content?.text ?? "");
    else if (k === "tool_call") { toolCalls.push(update.title || update.toolCallId); log("TOOL_CALL:", update.title || update.kind); }
  },
  async requestPermission({ options }) {
    const allow = options.find(o=>o.kind==="allow_once")||options.find(o=>o.kind==="allow_always")||options[0];
    return { outcome: { outcome: "selected", optionId: allow.optionId } };
  },
  async readTextFile({ path }) { const { readFile } = await import("node:fs/promises"); return { content: await readFile(path,"utf8") }; },
  async writeTextFile({ path, content }) { const { writeFile } = await import("node:fs/promises"); await writeFile(path, content); return {}; },
};
const conn = new ClientSideConnection(() => client, ndJsonStream(Writable.toWeb(child.stdin), Readable.toWeb(child.stdout)));
const done = (async () => {
  await conn.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: { fs: { readTextFile: true, writeTextFile: true }, terminal: false } });
  const sess = await conn.newSession({ cwd: "/tmp/acp-spike",
    mcpServers: [{ name: "playwright", command: "npx", args: ["-y","@playwright/mcp@latest","--headless","--browser","chrome"], env: [] }] });
  const res = await conn.prompt({ sessionId: sess.sessionId,
    prompt: [{ type: "text", text: "Use your browser tool (Playwright MCP) to navigate to https://example.com and tell me the EXACT text of the page's <h1>. Reply with only that heading." }] });
  log("stopReason:", res.stopReason);
})();
try { await Promise.race([done, new Promise((_,r)=>setTimeout(()=>r(new Error("timeout")),220000))]);
  log("TOOL CALLS:", toolCalls.join(" | ")); log("AGENT SAID:", agentText.join("").trim().slice(0,300));
} catch(e){ log("FAILED:", e.message);} finally { child.kill("SIGKILL"); setTimeout(()=>process.exit(0),500); }

Appendix B — amazon-profile.mjs (logged-in dry-run; uses real Chrome profile — currently blocked, see §6)

Same client/connection scaffold as Appendix A, plus: a guard that aborts if pgrep -x "Google Chrome" is non-empty; mcpServers uses the real profile:

mcpServers: [{ name: "playwright", command: "npx",
  args: ["-y","@playwright/mcp@latest","--browser","chrome","--user-data-dir", `${process.env.HOME}/Library/Application Support/Google/Chrome`], env: [] }]

and the safe dry-run prompt (login-state only):

You have a browser tool (Playwright MCP) running with the user's real Chrome profile. This is a DRY RUN —
do NOT read/list/open any orders or personal data, and do NOT type credentials.
(1) navigate to https://www.amazon.com ; (2) determine only whether the session is signed in — a greeting
like 'Hello, <FirstName>' / account menu vs a 'Sign in' link; (3) if a captcha/robot check/OTP appears, note it.
Then STOP — do NOT click Orders, do NOT reveal the account name. Reply with ONLY one token on the first line:
LOGGED_IN | NOT_LOGGED_IN | CHALLENGE — then one short sentence of evidence (no personal data).

Pre-req before running the profile variant: fully quit Chrome, then rm -f ~/Library/Application\ Support/Google/Chrome/Singleton*. (This variant is the one blocked by Chrome's default-profile automation restriction — switch to option (A)/(B)/(C) in §6.)

Appendix C — reproduce the bridge capability tests

# start bridge (dist is stale; run from source)
cd ~/code/termchart && npx tsx packages/cli/src/cli.ts bridge --port 8787   # prints token
TOK=<token>
curl -s http://127.0.0.1:8787/health
curl -s -H "Authorization: Bearer $TOK" http://127.0.0.1:8787/agents
curl -s -X POST -H "Authorization: Bearer $TOK" -H "Origin: http://127.0.0.1" -H "content-type: application/json" \
  -d '{"prompt":"Reply with one word: PONG"}' http://127.0.0.1:8787/agents/claude/run

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions