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:
Zen Process
2026-06-19 19:56:28 +03:00
committed by GitHub
parent a0c7e38ccd
commit f13fd1157b
3 changed files with 148 additions and 1 deletions
@@ -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]);
};
}
+2 -1
View File
@@ -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,