feat(adapters): stamp agent id via X-Anthropic-Agent-Id for claude_local (#8322)
## 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: <agentId>`. 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: <agentId>` 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: <agentId>` 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 <zenprocess@users.noreply.github.com> Co-authored-by: Devin Foley <devin@paperclip.ing> Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -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<string, unknown>; [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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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: <agentId>` 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<string, unknown>;
|
||||
const env: Record<string, unknown> = {
|
||||
...((config.env as Record<string, unknown> | 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<ClaudeExecute>[0]);
|
||||
};
|
||||
}
|
||||
@@ -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<AdapterModel[]> {
|
||||
|
||||
const claudeLocalAdapter: ServerAdapterModule = {
|
||||
type: "claude_local",
|
||||
execute: claudeExecute,
|
||||
execute: stampClaudeAgentIdHeader(claudeExecute),
|
||||
testEnvironment: claudeTestEnvironment,
|
||||
listSkills: listClaudeSkills,
|
||||
syncSkills: syncClaudeSkills,
|
||||
|
||||
Reference in New Issue
Block a user