From d217125a90b8b71e2abbb51d96ffb7c40becff32 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 15 Jun 2026 13:26:15 -0700 Subject: [PATCH] feat(acp): wire ACP token usage (OQ3) + opt-in headless auth (R17) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Item 2 (OQ3): capture PromptResponse.usage from conn.prompt and feed it into the bridge before finish(), so ACP-path turns report token usage/cost instead of always zero. Zero-when-absent is safe; tool-use (break-early) turns inherently report zero (the prompt result never resolves). - Item 3 (R17): opt-in headless credential delivery. When FUSION_CLAUDE_ACP_FORWARD_AUTH=1, buildBridgeEnv forwards a SINGLE Claude auth token (CLAUDE_CODE_OAUTH_TOKEN > ANTHROPIC_AUTH_TOKEN > ANTHROPIC_API_KEY) from the operator's launch env so a detached daemon (no login Keychain) can authenticate. Default OFF — the secure no-secrets posture is unchanged. acp-driver tests 9/9 (usage + the three auth-opt-in cases); typecheck clean. Remaining: item 1 (connection reuse / resume latency). Co-Authored-By: Claude Opus 4.8 --- .../src/__tests__/acp-driver.test.ts | 51 +++++++++++++++++-- packages/pi-claude-cli/src/acp-driver.ts | 46 +++++++++++++++-- 2 files changed, 91 insertions(+), 6 deletions(-) diff --git a/packages/pi-claude-cli/src/__tests__/acp-driver.test.ts b/packages/pi-claude-cli/src/__tests__/acp-driver.test.ts index 77edc653bd..a6de14a60a 100644 --- a/packages/pi-claude-cli/src/__tests__/acp-driver.test.ts +++ b/packages/pi-claude-cli/src/__tests__/acp-driver.test.ts @@ -4,6 +4,7 @@ import { PassThrough } from "node:stream"; // Synthetic ACP session/update sequence the mocked prompt() will replay. let scriptedUpdates: Array> = []; +let scriptedUsage: Record | undefined; // Driver validates the bridge path with existsSync — make the fake path "exist". // writeFileSync/unlinkSync back the R17 auth-failure signal (spied). @@ -33,7 +34,7 @@ vi.mock("@agentclientprotocol/sdk", () => ({ this.newSession = vi.fn(async () => ({ sessionId: "s1" })); this.prompt = vi.fn(async () => { for (const u of scriptedUpdates) await handler.sessionUpdate({ update: u }); - return { stopReason: "end_turn" }; + return { stopReason: "end_turn", usage: scriptedUsage }; }); }), })); @@ -53,7 +54,7 @@ vi.mock("@earendil-works/pi-ai", () => ({ calculateCost: vi.fn(), })); -import { streamViaAcp } from "../acp-driver.js"; +import { streamViaAcp, buildBridgeEnv } from "../acp-driver.js"; const MODEL = { id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5" } as never; const CTX = { messages: [{ role: "user", content: "hi" }] } as never; @@ -65,7 +66,17 @@ function eventsOf(stream: { _events: Array> }) { const flush = () => new Promise((r) => setTimeout(r, 30)); describe("streamViaAcp — ACP→pi translation (U11)", () => { - beforeEach(() => { scriptedUpdates = []; }); + beforeEach(() => { scriptedUpdates = []; scriptedUsage = undefined; }); + + it("feeds ACP token usage into the done message (item 2)", async () => { + scriptedUsage = { inputTokens: 11, outputTokens: 22 }; + scriptedUpdates = [{ sessionUpdate: "agent_message_chunk", content: { type: "text", text: "hi" } }]; + const stream = streamViaAcp(MODEL, CTX, OPTS) as unknown as { _events: Array> }; + await flush(); + const done = stream._events.find((e) => e.type === "done") as { message?: { usage?: { input?: number; output?: number } } }; + expect(done?.message?.usage?.input).toBe(11); + expect(done?.message?.usage?.output).toBe(22); + }); it("translates agent_message_chunk text into pi text events + done(stop)", async () => { scriptedUpdates = [ @@ -157,3 +168,37 @@ describe("streamViaAcp — ACP→pi translation (U11)", () => { expect(eventsOf(stream).some((e) => e.type === "done")).toBe(true); }); }); + +describe("buildBridgeEnv — R17 auth opt-in (item 3)", () => { + const saved = { flag: process.env.FUSION_CLAUDE_ACP_FORWARD_AUTH, oauth: process.env.CLAUDE_CODE_OAUTH_TOKEN, key: process.env.ANTHROPIC_API_KEY }; + afterEach(() => { + for (const [k, v] of [["FUSION_CLAUDE_ACP_FORWARD_AUTH", saved.flag], ["CLAUDE_CODE_OAUTH_TOKEN", saved.oauth], ["ANTHROPIC_API_KEY", saved.key]] as const) { + if (v === undefined) delete process.env[k]; else process.env[k] = v; + } + }); + + it("does NOT forward auth vars by default (secure default)", () => { + delete process.env.FUSION_CLAUDE_ACP_FORWARD_AUTH; + process.env.ANTHROPIC_API_KEY = "sk-secret"; + const env = buildBridgeEnv({ HOME: "/h", PATH: "/b" }); + expect(env.ANTHROPIC_API_KEY).toBeUndefined(); + expect(env.HOME).toBe("/h"); + }); + + it("forwards a single auth token when opted in", () => { + process.env.FUSION_CLAUDE_ACP_FORWARD_AUTH = "1"; + delete process.env.CLAUDE_CODE_OAUTH_TOKEN; + process.env.ANTHROPIC_API_KEY = "sk-secret"; + const env = buildBridgeEnv({ HOME: "/h", PATH: "/b" }); + expect(env.ANTHROPIC_API_KEY).toBe("sk-secret"); + }); + + it("prefers CLAUDE_CODE_OAUTH_TOKEN and forwards only one token", () => { + process.env.FUSION_CLAUDE_ACP_FORWARD_AUTH = "1"; + process.env.CLAUDE_CODE_OAUTH_TOKEN = "oauth-tok"; + process.env.ANTHROPIC_API_KEY = "sk-secret"; + const env = buildBridgeEnv({ HOME: "/h", PATH: "/b" }); + expect(env.CLAUDE_CODE_OAUTH_TOKEN).toBe("oauth-tok"); + expect(env.ANTHROPIC_API_KEY).toBeUndefined(); + }); +}); diff --git a/packages/pi-claude-cli/src/acp-driver.ts b/packages/pi-claude-cli/src/acp-driver.ts index 1566e27a87..535b4f5425 100644 --- a/packages/pi-claude-cli/src/acp-driver.ts +++ b/packages/pi-claude-cli/src/acp-driver.ts @@ -119,13 +119,35 @@ const BRIDGE_ENV_ALLOWLIST = [ "TERM", "TERMINFO", "TMPDIR", "XDG_CONFIG_HOME", "XDG_CACHE_HOME", "COLORTERM", ]; -function buildBridgeEnv(supplied?: NodeJS.ProcessEnv): NodeJS.ProcessEnv { +/** + * R17 opt-in (detached-daemon auth): a headless daemon can't reach the macOS + * login Keychain, so `claude` reports "Not logged in". When an operator sets + * `FUSION_CLAUDE_ACP_FORWARD_AUTH=1` AND provides one of these in the launch + * environment, we forward it (and ONLY it) so the bridged `claude` can + * authenticate non-interactively. Default OFF — no secret-bearing var ever + * reaches the untrusted bridge otherwise. Mirrors the native claude-code + * adapter's recognized auth vars. + */ +const BRIDGE_AUTH_ENV_KEYS = ["CLAUDE_CODE_OAUTH_TOKEN", "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_API_KEY"]; + +export 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; } + // Opt-in only: forward a single Claude auth token from the operator's launch + // env (always process.env, never the caller-supplied object). + if (process.env.FUSION_CLAUDE_ACP_FORWARD_AUTH === "1") { + for (const key of BRIDGE_AUTH_ENV_KEYS) { + const v = process.env[key]; + if (typeof v === "string" && v.length > 0) { + env[key] = v; + break; // forward only the highest-preference token that's present + } + } + } return env; } @@ -350,8 +372,26 @@ export function streamViaAcp( ]; // ACP ContentBlock[] — text/image shapes match; cast through unknown. - await conn.prompt({ sessionId: opened.sessionId, prompt: blocks as unknown as Parameters[0]["prompt"] }); - if (!sawToolCall) finish("stop"); + const res = await conn.prompt({ sessionId: opened.sessionId, prompt: blocks as unknown as Parameters[0]["prompt"] }); + // Feed token usage (experimental ACP field) into the bridge BEFORE finish() + // so it lands in the `done` message. Tool-use turns break early and never + // resolve here, so they inherently report zero usage. Zero-when-absent safe. + if (!sawToolCall) { + const u = (res as { usage?: { inputTokens?: number; outputTokens?: number; cachedReadTokens?: number; cachedWriteTokens?: number } }).usage; + if (u) { + bridge.handleEvent({ + type: "message_delta", + delta: {}, + usage: { + input_tokens: u.inputTokens, + output_tokens: u.outputTokens, + cache_read_input_tokens: u.cachedReadTokens ?? undefined, + cache_creation_input_tokens: u.cachedWriteTokens ?? undefined, + }, + } as ClaudeApiEvent); + } + finish("stop"); + } } catch (err) { failWith(err instanceof Error ? err.message : String(err)); }