diff --git a/server/src/adapters/claude-agent-id-header.test.ts b/server/src/adapters/claude-agent-id-header.test.ts new file mode 100644 index 00000000..a7371e08 --- /dev/null +++ b/server/src/adapters/claude-agent-id-header.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it, vi } from "vitest"; +import { stampClaudeAgentIdHeader } from "./claude-agent-id-header.js"; + +type ExecuteCtx = { + agent?: { id?: string }; + config?: { env?: Record; [key: string]: unknown }; + [key: string]: unknown; +}; + +function recordingInner() { + const sentinel = Symbol("inner-result"); + const inner = vi.fn((ctx: ExecuteCtx) => Promise.resolve(sentinel)); + return { inner, sentinel }; +} + +function customHeaders(ctx: ExecuteCtx): string | undefined { + const value = ctx.config?.env?.ANTHROPIC_CUSTOM_HEADERS; + return typeof value === "string" ? value : undefined; +} + +describe("stampClaudeAgentIdHeader", () => { + it("merges X-Anthropic-Agent-Id into config.env (the env the adapter forwards)", async () => { + const { inner, sentinel } = recordingInner(); + const wrapped = stampClaudeAgentIdHeader(inner as never); + + const result = await wrapped({ + agent: { id: "agent-123" }, + config: { cwd: "/work", env: { EXISTING: "keep" } }, + } as never); + + expect(result).toBe(sentinel); + const forwarded = inner.mock.calls[0]?.[0] as ExecuteCtx; + expect(customHeaders(forwarded)).toBe("X-Anthropic-Agent-Id: agent-123"); + // Unrelated config + env entries are preserved. + expect(forwarded.config?.cwd).toBe("/work"); + expect(forwarded.config?.env?.EXISTING).toBe("keep"); + }); + + it("appends to a pre-existing ANTHROPIC_CUSTOM_HEADERS value", async () => { + const { inner } = recordingInner(); + const wrapped = stampClaudeAgentIdHeader(inner as never); + + await wrapped({ + agent: { id: "agent-9" }, + config: { env: { ANTHROPIC_CUSTOM_HEADERS: "X-Trace: abc" } }, + } as never); + + const forwarded = inner.mock.calls[0]?.[0] as ExecuteCtx; + expect(customHeaders(forwarded)).toBe("X-Trace: abc\nX-Anthropic-Agent-Id: agent-9"); + }); + + it("strips CR/LF and bounds length to prevent header injection", async () => { + const { inner } = recordingInner(); + const wrapped = stampClaudeAgentIdHeader(inner as never); + + await wrapped({ + agent: { id: "evil\r\nX-Injected: 1" }, + config: { env: {} }, + } as never); + + const forwarded = inner.mock.calls[0]?.[0] as ExecuteCtx; + expect(customHeaders(forwarded)).toBe("X-Anthropic-Agent-Id: evilX-Injected: 1"); + expect(customHeaders(forwarded)).not.toContain("\n"); + }); + + it("bounds an oversized agent id to 256 characters", async () => { + const { inner } = recordingInner(); + const wrapped = stampClaudeAgentIdHeader(inner as never); + + await wrapped({ + agent: { id: "a".repeat(500) }, + config: { env: {} }, + } as never); + + const forwarded = inner.mock.calls[0]?.[0] as ExecuteCtx; + expect(customHeaders(forwarded)).toBe(`X-Anthropic-Agent-Id: ${"a".repeat(256)}`); + }); + + it("passes through unchanged when no agent id is available", async () => { + const { inner } = recordingInner(); + const wrapped = stampClaudeAgentIdHeader(inner as never); + + const ctx = { agent: {}, config: { env: { EXISTING: "keep" } } } as never; + await wrapped(ctx); + + // Same ctx object forwarded; no header added. + expect(inner.mock.calls[0]?.[0]).toBe(ctx); + expect(customHeaders(inner.mock.calls[0]?.[0] as ExecuteCtx)).toBeUndefined(); + }); + + it("does not append a duplicate when X-Anthropic-Agent-Id is already present", async () => { + const { inner } = recordingInner(); + const wrapped = stampClaudeAgentIdHeader(inner as never); + + const ctx = { + agent: { id: "agent-7" }, + config: { env: { ANTHROPIC_CUSTOM_HEADERS: "X-Anthropic-Agent-Id: manual-override" } }, + } as never; + await wrapped(ctx); + + // Manual override is respected — ctx forwarded untouched. + expect(inner.mock.calls[0]?.[0]).toBe(ctx); + expect(customHeaders(inner.mock.calls[0]?.[0] as ExecuteCtx)).toBe( + "X-Anthropic-Agent-Id: manual-override", + ); + }); +}); diff --git a/server/src/adapters/claude-agent-id-header.ts b/server/src/adapters/claude-agent-id-header.ts new file mode 100644 index 00000000..dfd89eff --- /dev/null +++ b/server/src/adapters/claude-agent-id-header.ts @@ -0,0 +1,39 @@ +import type { ServerAdapterModule } from "./types.js"; + +type ClaudeExecute = ServerAdapterModule["execute"]; + +const AGENT_ID_HEADER = "X-Anthropic-Agent-Id"; + +/** + * Wraps the claude_local execute so every request the spawned Claude Code makes + * carries `X-Anthropic-Agent-Id: ` via ANTHROPIC_CUSTOM_HEADERS. A proxy + * such as better-ccflare reads that header to attribute requests per agent for + * downstream cost/token telemetry. + * + * The header is merged into `config.env` — the env the adapter actually forwards + * into the spawned process — not `agent.adapterConfig.env`, which is resolved + * upstream before execute runs and is never read by the Claude adapter. + */ +export function stampClaudeAgentIdHeader(inner: ClaudeExecute): ClaudeExecute { + return (ctx) => { + const agent = (ctx as { agent?: { id?: string } }).agent; + // Strip CR/LF and bound length to prevent HTTP header injection — agentId is + // interpolated into ANTHROPIC_CUSTOM_HEADERS, which is forwarded as a header. + const agentId = agent?.id + ? String(agent.id).replace(/[\r\n]/g, "").trim().slice(0, 256) + : undefined; + if (!agentId) return inner(ctx); + const config = ((ctx as { config?: unknown }).config ?? {}) as Record; + const env: Record = { + ...((config.env as Record | undefined) ?? {}), + }; + const existing = + typeof env.ANTHROPIC_CUSTOM_HEADERS === "string" ? env.ANTHROPIC_CUSTOM_HEADERS : ""; + // Respect an X-Anthropic-Agent-Id already set via manual override — appending a + // second line would send a duplicate header to the proxy. + if (/(^|\n)\s*X-Anthropic-Agent-Id\s*:/i.test(existing)) return inner(ctx); + const header = `${AGENT_ID_HEADER}: ${agentId}`; + env.ANTHROPIC_CUSTOM_HEADERS = existing ? `${existing}\n${header}` : header; + return inner({ ...ctx, config: { ...config, env } } as Parameters[0]); + }; +} diff --git a/server/src/adapters/registry.ts b/server/src/adapters/registry.ts index 09f6b2a2..1c232d7d 100644 --- a/server/src/adapters/registry.ts +++ b/server/src/adapters/registry.ts @@ -5,6 +5,7 @@ import type { ServerAdapterModule, } from "./types.js"; import { parseAdapterModelsEnv } from "../services/adapter-models-env.js"; +import { stampClaudeAgentIdHeader } from "./claude-agent-id-header.js"; import { buildSandboxNpmInstallCommand, getAdapterSessionManagement, @@ -266,7 +267,7 @@ async function listAcpxModels(): Promise { const claudeLocalAdapter: ServerAdapterModule = { type: "claude_local", - execute: claudeExecute, + execute: stampClaudeAgentIdHeader(claudeExecute), testEnvironment: claudeTestEnvironment, listSkills: listClaudeSkills, syncSkills: syncClaudeSkills,