From f13fd1157bbe0c75bfb8910fa6e9a606b6332fbe Mon Sep 17 00:00:00 2001 From: Zen Process Date: Fri, 19 Jun 2026 19:56:28 +0300 Subject: [PATCH] feat(adapters): stamp agent id via X-Anthropic-Agent-Id for claude_local (#8322) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Local agents run through the `claude_local` adapter, which spawns Claude Code and routes its Anthropic API traffic through a shared proxy (e.g. better-ccflare). > - That proxy's request log feeds observability dashboards that break down cost and token usage. > - But requests from local agents arrive without an agent identifier, so all traffic collapses into a single unattributed bucket and per-agent telemetry is impossible. > - Anthropic's CLI forwards `ANTHROPIC_CUSTOM_HEADERS` onto each API request, and better-ccflare now reads an `X-Anthropic-Agent-Id` header to attribute requests per agent. > - This pull request stamps the agent's id into `ANTHROPIC_CUSTOM_HEADERS` as `X-Anthropic-Agent-Id`, injected into the env the adapter actually forwards to the spawned process. > - The benefit is per-agent cost/token attribution in proxy-backed dashboards, with zero impact on agents that don't run behind such a proxy. ## Linked Issues or Issue Description No public GitHub issue exists; describing inline per CONTRIBUTING.md (feature template). **Problem or motivation** When several `claude_local` agents share a proxy (better-ccflare) in front of the Anthropic API, the proxy cannot tell which agent issued a given request. Per-agent cost and token dashboards therefore can't be built — every request is attributed to one undifferentiated bucket. **Proposed solution** Have the `claude_local` adapter stamp each spawned Claude Code process with `ANTHROPIC_CUSTOM_HEADERS: X-Anthropic-Agent-Id: `. The Anthropic CLI forwards that header on every API call, so the proxy can attribute requests to the specific agent. **Alternatives considered** Parsing per-agent identity from workspace paths or session ids in the proxy — brittle and proxy-specific. A first-class request header is the stable contract; it pairs with better-ccflare's `X-Anthropic-Agent-Id` support ([tombii/better-ccflare#260](https://github.com/tombii/better-ccflare/pull/260)) and dashboards like [danieltamas/axon](https://github.com/danieltamas/axon). **Roadmap alignment** Additive observability glue for an existing adapter; no overlap with planned core work. ## What Changed - Added `server/src/adapters/claude-agent-id-header.ts` exporting `stampClaudeAgentIdHeader`, which wraps the `claude_local` execute and merges `X-Anthropic-Agent-Id: ` into the run's `config.env.ANTHROPIC_CUSTOM_HEADERS`. - `server/src/adapters/registry.ts`: the `claude_local` adapter now uses the wrapped execute (`stampClaudeAgentIdHeader(claudeExecute)`). - The header is merged into **`config.env`** — the env the Claude adapter actually forwards into the spawned process — rather than `agent.adapterConfig.env`, which is resolved upstream before `execute` runs and is never read by the Claude adapter. - `agentId` is sanitized (CR/LF stripped, capped at 256 chars) before interpolation to prevent HTTP header injection. - Passthrough when no agent id is available; a pre-existing `X-Anthropic-Agent-Id` (manual override) is respected and not duplicated. - Added `server/src/adapters/claude-agent-id-header.test.ts` covering injection into `config.env`, append-to-existing `ANTHROPIC_CUSTOM_HEADERS`, CR/LF sanitization, length bounding, no-agent-id passthrough, and the duplicate-header guard. ## Verification - Unit tests: `cd server && npx vitest run src/adapters/claude-agent-id-header.test.ts` → 6 passing. - CI: typecheck, build, server suites, and Greptile gates run on this PR. - Manual: configure a `claude_local` agent behind better-ccflare, run a heartbeat, and confirm the proxy log shows `X-Anthropic-Agent-Id: ` on that agent's requests. An agent that already sets `X-Anthropic-Agent-Id` in its adapter config keeps its override (no duplicate line). ## Risks Low risk. The change is additive and non-breaking: - When no agent id is present, or when an `X-Anthropic-Agent-Id` is already configured, execution is byte-for-byte unchanged (the original `ctx` is passed straight through). - The header only has an effect for deployments whose proxy reads it; agents without such a proxy are unaffected. - The agent id is sanitized (CR/LF removed, length-bounded) before it is interpolated into a header value, preventing header injection. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. This is additive observability glue for an existing adapter and does not overlap planned core work. ## Model Used Claude (Anthropic) — Claude Opus 4.8 (`claude-opus-4-8`), via the Claude Code CLI with extended reasoning and tool use. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [ ] If this change affects the UI, I have included before/after screenshots (N/A — no UI changes) - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: zenprocess Co-authored-by: Devin Foley Co-authored-by: Paperclip --- .../adapters/claude-agent-id-header.test.ts | 107 ++++++++++++++++++ server/src/adapters/claude-agent-id-header.ts | 39 +++++++ server/src/adapters/registry.ts | 3 +- 3 files changed, 148 insertions(+), 1 deletion(-) create mode 100644 server/src/adapters/claude-agent-id-header.test.ts create mode 100644 server/src/adapters/claude-agent-id-header.ts 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,