feat(acp): wire ACP token usage (OQ3) + opt-in headless auth (R17)

- 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 <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-06-15 13:26:15 -07:00
parent b83210b471
commit d217125a90
2 changed files with 91 additions and 6 deletions

View File

@@ -4,6 +4,7 @@ import { PassThrough } from "node:stream";
// Synthetic ACP session/update sequence the mocked prompt() will replay.
let scriptedUpdates: Array<Record<string, unknown>> = [];
let scriptedUsage: Record<string, number> | 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<Record<string, unknown>> }) {
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<Record<string, unknown>> };
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();
});
});

View File

@@ -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<typeof conn.prompt>[0]["prompt"] });
if (!sawToolCall) finish("stop");
const res = await conn.prompt({ sessionId: opened.sessionId, prompt: blocks as unknown as Parameters<typeof conn.prompt>[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));
}