diff --git a/docs/solutions/architecture-patterns/acp-persistent-jsonrpc-agent-runtime-integration.md b/docs/solutions/architecture-patterns/acp-persistent-jsonrpc-agent-runtime-integration.md index 4a12845a97..55d167b2b1 100644 --- a/docs/solutions/architecture-patterns/acp-persistent-jsonrpc-agent-runtime-integration.md +++ b/docs/solutions/architecture-patterns/acp-persistent-jsonrpc-agent-runtime-integration.md @@ -42,7 +42,7 @@ related_components: - **Per-category permission gating, never per-preset.** The shipped default policy preset is `unrestricted` (every category → allow). A preset-level shortcut auto-approves everything the moment the runtime is selected. Classify each call's kind into a category and read `permissionPolicy.rules[category]`; add an explicit acknowledgement setting before honoring blanket allows on sensitive categories. - Select `allow_once` only — never `allow_always`/`reject_always` (a persisted grant inside untrusted code loses per-call interception). Unmappable/missing kinds, missing gate/policy, and HITL-without-a-readable-decision all default-deny. Require **both** `pauseForApproval` AND `findApprovalByDedupeKey` before creating an approval request — otherwise a human approval is silently discarded and a pending record is orphaned. - **Filesystem jail = realpath, not string checks.** `project-root-guard.ts` is a suffix check, not a jail. Use realpath-within-realpath(cwd), `lstat` the final component for new files, `O_NOFOLLOW` open, and **truncate only after post-open re-validation** (passing `O_TRUNC` into open() truncates an escaped target before validation — write-path TOCTOU). Deny-list secrets and `.git/**` by basename regardless of cwd membership. Stat-gate reads (a full `readFile` before a byte ceiling is an OOM vector). -- **Bound everything the agent emits**, including the channels that don't look like output: per-turn + per-chunk caps on text/thinking, ANSI/control stripping, bounded identifier lengths and correlation maps, and **plan/structured events** (entry size was bounded but entry *count* wasn't — 1,000 × 64KB entries bypassed the per-turn budget). Redact stderr across chunk boundaries, not per-chunk (secrets split across `data` events evade per-chunk regexes). Build the subprocess env from an allow-list, never inherited `process.env`. +- **Bound everything the agent emits**, including the channels that don't look like output: per-turn + per-chunk caps on text/thinking, ANSI/control stripping, bounded identifier lengths and correlation maps, and **plan/structured events** (entry size was bounded but entry *count* wasn't — 1,000 × 64KB entries bypassed the per-turn budget). Redact stderr across chunk boundaries, not per-chunk (secrets split across `data` events evade per-chunk regexes). Build the subprocess env from an allow-list, never inherited `process.env` — but make the list **complete**: a thin `{HOME,PATH}` starves agent CLIs of the vars they use to find auth (`XDG_CONFIG_HOME`/`XDG_CACHE_HOME`/`USER`/`SHELL`/`LANG`), and even a correct env can't beat macOS login-Keychain session isolation for detached daemons. See `integration-issues/acp-bridge-not-logged-in-thin-env-keychain-isolation.md`. **4. Per-turn bridge state must actually reset per turn.** Anything accumulated per "turn" (output budgets, cap-flag latches, tool-call correlation maps) needs an explicit `reset()` invoked at the top of each prompt — a latch that never resets silently suppresses all output for the rest of the session after one flood. Write a two-turns-through-the-same-handler test; single-turn tests cannot catch it. diff --git a/docs/solutions/integration-issues/acp-bridge-not-logged-in-thin-env-keychain-isolation.md b/docs/solutions/integration-issues/acp-bridge-not-logged-in-thin-env-keychain-isolation.md new file mode 100644 index 0000000000..475ccc457c --- /dev/null +++ b/docs/solutions/integration-issues/acp-bridge-not-logged-in-thin-env-keychain-isolation.md @@ -0,0 +1,88 @@ +--- +title: "ACP bridge returns 'Not logged in' despite a working claude -p: thin spawn env + Keychain session isolation" +date: 2026-06-15 +category: integration-issues +module: pi-claude-cli +problem_type: integration_issue +component: tooling +symptoms: + - "ACP-bridged turns return the literal assistant text 'Not logged in · Please run /login' instead of real answers" + - "claude -p \"say hi\" works in the same shell while the bridge fails" + - "A verification harness forwarding only HOME and PATH fails even inside an authenticated terminal" + - "Reproducible under detached/headless runners (launchd daemon, autonomous task runner) but not interactively" +root_cause: incomplete_setup +resolution_type: code_fix +severity: high +tags: [acp, claude-code, keychain, spawn-env, authentication, macos, pi-claude-cli] +related_components: [authentication, tooling] +--- + +# ACP bridge returns 'Not logged in' despite a working claude -p: thin spawn env + Keychain session isolation + +## Problem + +The `claude-code-cli-acp` ACP bridge — driven by Fusion's `pi-claude-cli` provider to replace `claude -p` — returned the assistant text **"Not logged in · Please run /login"** instead of real answers, even though `claude -p "say hi"` succeeded in the same shell. The cause was environmental, not an upstream bridge limitation: a thin spawn env starved `claude` of the variables it needs to locate its auth, and macOS Keychain session isolation blocked headless processes from reading the login Keychain at all. + +## Symptoms + +- ACP-bridged turns return the literal text `Not logged in · Please run /login` (no tool calls, no real content), while `claude -p "say hi"` works in the same interactive shell. +- A verification harness that forwarded only `{HOME, PATH}` to the bridge failed **even inside an authenticated terminal**, falsely implying the auth itself was broken. +- The failure is reproducible in detached/headless contexts (launchd daemon, autonomous task-runner subprocess) but not in interactive ones — "works when I run it, fails when the daemon runs it." +- `~/.claude/.credentials.json` exists but is an empty **directory**, making file-based credential debugging a dead end. + +## What Didn't Work + +- **Six autonomous headless task attempts** (FN-6466/6467/6473/6476) re-ran the bridge spike, each hit "Not logged in," concluded **NOT-GO**, and even filed upstream issue `moabualruz/claude-code-cli-acp#2` — misattributing an environmental problem to an upstream bridge gap. +- **A `{HOME, PATH}`-only verification harness** kept failing in an authenticated terminal. Because it failed where auth was known-good, it masked that the *env*, not the *auth state*, was wrong — and reinforced the wrong conclusion across every retry. +- **Re-running `claude` / `claude --print` to "re-auth"** — print mode is non-interactive and cannot perform interactive OAuth login, so this could never repair the session. + +## Solution + +Two changes, one per root cause. + +**1. Forward the full env allow-list when spawning the bridge.** Build the bridge subprocess env from an explicit allow-list (never inherited `process.env`, never API keys), and make that list *complete* — not just `{HOME, PATH}`. + +`packages/pi-claude-cli/src/acp-driver.ts`: + +```ts +const BRIDGE_ENV_ALLOWLIST = [ + "HOME", "PATH", "USER", "LOGNAME", "SHELL", "LANG", "LC_ALL", "LC_CTYPE", + "TERM", "TERMINFO", "TMPDIR", "XDG_CONFIG_HOME", "XDG_CACHE_HOME", "COLORTERM", +]; + +function buildBridgeEnv(supplied?: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + const source = supplied ?? process.env; + const env: NodeJS.ProcessEnv = {}; + for (const key of BRIDGE_ENV_ALLOWLIST) { + const v = source[key]; + if (typeof v === "string") env[key] = v; + } + return env; +} +// spawn(options.bridgePath, [], { ..., env: buildBridgeEnv(options.bridgeEnv) }) +``` + +The critical additions over a naive `{HOME, PATH}` env are **`XDG_CONFIG_HOME`, `XDG_CACHE_HOME`, `USER`, `SHELL`, `LANG`**. With the full list, auth succeeds immediately. + +**2. The Keychain finding (gate R17).** Claude Code stores its OAuth credentials in the macOS **login Keychain** as a generic-password item (service `"Claude Code-credentials"`), *not* a file (`~/.claude/.credentials.json` is an empty directory). A detached/headless process runs in a **different security session** and cannot read the login Keychain, so it fails regardless of env; a login-session process (interactive terminal, or an `fn` daemon launched from a login shell) can. This is codified as gate **R17**: the provider's runtime must have login-Keychain access. The driver also detects a not-logged-in turn and writes a best-effort cross-process signal (`fusion-acp-bridge-auth.json`) that `GET /providers/claude-cli/status` reads, so the dashboard can raise an auth-failure banner with a "Use `claude -p`" fallback. + +## Why This Works + +Two independent environmental causes were compounding, which is why the failure looked like a flaky upstream bug: + +1. **Thin spawn env (the silent one).** `claude` resolves config/auth through more than `{HOME, PATH}` — it reads `XDG_CONFIG_HOME`/`XDG_CACHE_HOME` for config locations and relies on `USER`/`SHELL`/`LANG` for session and locale context. Spawned with only `{HOME, PATH}` it can't locate its auth context and reports "Not logged in." The `{HOME,PATH}`-only harness reproduced this *even in an authenticated terminal*, which is exactly why it misdirected six investigations: it "proved" the bridge couldn't auth using a starved env. + +2. **macOS Keychain session isolation.** Even with a perfect env, the login Keychain is bound to the login security session. Interactive terminals (and daemons started from a login shell) share that session and can read the `"Claude Code-credentials"` item; detached launchd daemons and autonomous subprocesses run in a separate session and cannot. Same machine, same credentials, different security session — the precise reason `claude -p` worked interactively while the headless tasks failed. + +## Prevention + +- **When spawning an agent CLI as a subprocess, forward the full env allow-list, not a thin `{HOME, PATH}`.** Agent CLIs resolve auth/config through `XDG_CONFIG_HOME`, `XDG_CACHE_HOME`, `USER`, `SHELL`, and locale vars. Keep the allow-list explicit (no inherited `process.env`, no API keys) but make it *complete*. +- **Never trust a verification harness that uses a thinner env than the real spawn path.** A harness that forwards fewer vars than production manufactures failures and masks the real cause. Match the production allow-list exactly, or the harness lies. +- **Treat "works interactively but fails headless" as a session/Keychain problem first.** On macOS, OAuth/login credentials live in the session-bound login Keychain. A detached daemon or autonomous task-runner is in a different security session and cannot read them — no amount of env or file fiddling fixes that. Ask "is this process in the login session?" before assuming the tool is broken. +- **Headless daemons need an explicit credential-delivery story.** Don't assume a daemon inherits interactive credentials. Either launch it from a login shell/session or provide credentials through a session-independent channel, and encode it as a runtime gate (here, R17) so it's checked rather than rediscovered. +- **Don't let autonomous/headless task-runners conclude "impossible" or file upstream issues from a single un-isolated failure.** Six runs reached NOT-GO and an upstream issue from one un-diagnosed environmental cause. Require an environmental-isolation step (interactive vs. headless, full vs. thin env) before declaring an integration unworkable. + +## Related Issues + +- `docs/solutions/architecture-patterns/acp-persistent-jsonrpc-agent-runtime-integration.md` — the ACP runtime integration pattern. Its §3 rule "build the subprocess env from an allow-list, never inherited `process.env`" is the principle this doc operationalizes; this doc is its concrete failure mode (allow-list too thin → "Not logged in") plus the Keychain-isolation dimension that pattern doc does not cover. +- Upstream `moabualruz/claude-code-cli-acp#2` — filed during the failed investigation; the issue is environmental (this doc), not an upstream bridge gap.