fix(opencode): send x-headroom-project header on all proxied requests (#2868)

## Description

The OpenCode transport plugin set `HEADROOM_PROJECT` as a shell env var
for child processes but never forwarded it as `x-headroom-project` on
the actual proxied HTTP requests. The proxy's `classify_project` only
attributes traffic via `x-headroom-project` header or `/p/<name>` URL
prefix — without the header, every OpenCode request was unattributed and
the Per-Project Savings dashboard showed `0 project(s)` permanently.

Fixes #2847.

## Root cause

`installHeadroomTransport` was called with only `{ proxyUrl, debug }`.
The `project` value was computed and used only in the `shell.env` hook
(for subprocess env injection), never threaded through to
`mergeFetchHeaders` or `headersForNodeRequest`.

## Changes Made

1. Add `project?: string` to `InstallOptions` and `TransportState`.
2. Resolve the project value once at plugin init (`pluginOptions.project
→ input.project.id → input.directory`) and pass it to
`installHeadroomTransport`.
3. Both header-building seams now set `x-headroom-project` when a
project is present:
   - `mergeFetchHeaders` (wrapped `fetch` path)
- `headersForNodeRequest` (wrapped `http.request` / `https.request`
path)
4. Reuse the resolved `project` in the `shell.env` hook (removes the
duplicate resolution that was there before).

## Changes

- `plugins/opencode/src/transport.ts` — `InstallOptions.project`,
`TransportState.project`; `mergeFetchHeaders`, `headersForNodeRequest`,
`routedNodeOptions`, `withRoutedFetchInput`, `installHeadroomTransport`
updated
- `plugins/opencode/src/plugin.ts` — resolve `project` once, pass it to
transport; reuse in `shell.env`
- `plugins/opencode/src/transport.test.ts` — 3 new tests: project header
on fetch, project header on https.request, no header when project unset
- `headroom/providers/opencode/_dist/entry.opencode.js` — rebuilt with
`npm run build:standalone` to match source

## Testing

- [x] Unit tests pass
- [x] TypeScript typecheck passes
- [x] New regression tests added

### Test Output

```
cd plugins/opencode && npm test
# 17 passed (14 existing + 3 new)
```

TypeScript build also passes: `npm run typecheck` (no errors).

## Type of Change

- [x] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature
- [ ] Breaking change
- [ ] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring

## Real Behavior Proof

- Environment: OpenCode transport plugin test environment on the current
PR head.
- Exact command / steps: ran the plugin test suite and TypeScript
typecheck after rebuilding the standalone bundle.
- Observed result: all 17 tests passed, including project-header
coverage for fetch and Node HTTPS paths plus the unset-project control;
typechecking passed.
- Not tested: a live OpenCode session against a deployed Headroom proxy.

## Review Readiness

- [x] I have performed a self-review
- [x] This PR is ready for human review

---------

Signed-off-by: Radhakrishnan P <gingeekrishna@gmail.com>
Signed-off-by: Radhakrishnan Pachyappan <gingeekrishna@gmail.com>
This commit is contained in:
Radhakrishnan Pachyappan
2026-08-18 08:51:24 +05:30
committed by GitHub
parent 2cae0f8eaf
commit eeb038bc0c
5 changed files with 114 additions and 27 deletions
@@ -12487,6 +12487,7 @@ var childProcess = nodeRequire("node:child_process");
var fs = nodeRequire("node:fs");
var BASE_URL_HEADER = "x-headroom-base-url";
var ORIGINAL_PATH_HEADER = "x-headroom-original-path";
var PROJECT_HEADER = "x-headroom-project";
var PROXY_ENV = "HEADROOM_OPENCODE_TRANSPORT_PROXY_URL";
var STATE_KEY = /* @__PURE__ */ Symbol.for("headroom.opencode.transport");
function getState() {
@@ -12635,7 +12636,7 @@ function requestUrl(input) {
}
return new URL(String(input));
}
function mergeFetchHeaders(input, init, upstream, originalPath = void 0) {
function mergeFetchHeaders(input, init, upstream, originalPath = void 0, project = void 0) {
const headers = new Headers(input instanceof Request ? input.headers : void 0);
if (init?.headers) {
new Headers(init.headers).forEach((value, key) => headers.set(key, value));
@@ -12647,9 +12648,12 @@ function mergeFetchHeaders(input, init, upstream, originalPath = void 0) {
if (originalPath) {
headers.set(ORIGINAL_PATH_HEADER, originalPath);
}
if (project) {
headers.set(PROJECT_HEADER, project);
}
return headers;
}
function withRoutedFetchInput(input, init, proxy) {
function withRoutedFetchInput(input, init, proxy, project) {
const upstream = requestUrl(input);
if (!shouldRoute(upstream, proxy)) {
return [input, init];
@@ -12657,7 +12661,7 @@ function withRoutedFetchInput(input, init, proxy) {
const { url: nextUrl, originalPath } = routedUrlForOpenCode(upstream, proxy);
const nextInit = {
...init,
headers: mergeFetchHeaders(input, init, upstream, originalPath)
headers: mergeFetchHeaders(input, init, upstream, originalPath, project)
};
if (input instanceof Request) {
return [new Request(nextUrl, input), nextInit];
@@ -12703,12 +12707,15 @@ function urlFromRequestOptions(options) {
return void 0;
}
}
function headersForNodeRequest(options, upstream, originalPath) {
function headersForNodeRequest(options, upstream, originalPath, project) {
const headers = new Headers(options.headers);
headers.set(BASE_URL_HEADER, upstream.origin);
if (originalPath) {
headers.set(ORIGINAL_PATH_HEADER, originalPath);
}
if (project) {
headers.set(PROJECT_HEADER, project);
}
headers.delete("host");
const result = {};
headers.forEach((value, key) => {
@@ -12716,7 +12723,7 @@ function headersForNodeRequest(options, upstream, originalPath) {
});
return result;
}
function routedNodeOptions(parts, proxy) {
function routedNodeOptions(parts, proxy, project) {
if (!parts.url || !shouldRoute(parts.url, proxy)) {
return void 0;
}
@@ -12747,7 +12754,7 @@ function routedNodeOptions(parts, proxy) {
hostname: nextUrl.hostname,
port: nextUrl.port || void 0,
path: `${nextUrl.pathname}${nextUrl.search}`,
headers: headersForNodeRequest(parts.options, parts.url, originalPath)
headers: headersForNodeRequest(parts.options, parts.url, originalPath, project)
};
}
function wrapRequest(originalHttpRequest, originalHttpsRequest, originalRequest) {
@@ -12758,7 +12765,7 @@ function wrapRequest(originalHttpRequest, originalHttpsRequest, originalRequest)
}
const proxy = normalizeProxyUrl(state.proxyUrl);
const parts = splitNodeArgs(args);
const nextOptions = routedNodeOptions(parts, proxy);
const nextOptions = routedNodeOptions(parts, proxy, state.project);
if (!nextOptions) {
return Reflect.apply(originalRequest, this, args);
}
@@ -12794,6 +12801,7 @@ function installHeadroomTransport(options) {
if (existing) {
existing.refs += 1;
existing.proxyUrl = options.proxyUrl;
existing.project = options.project;
existing.debug = Boolean(options.debug);
installProcessEnv(options.proxyUrl);
return () => uninstallHeadroomTransport();
@@ -12801,6 +12809,7 @@ function installHeadroomTransport(options) {
const state = {
refs: 1,
proxyUrl: options.proxyUrl,
project: options.project,
debug: Boolean(options.debug),
originalFetch: globalThis.fetch,
originalHttpRequest: http.request,
@@ -12821,7 +12830,7 @@ function installHeadroomTransport(options) {
return state.originalFetch(...args);
}
const proxy = normalizeProxyUrl(current.proxyUrl);
const [nextInput, nextInit] = withRoutedFetchInput(args[0], args[1], proxy);
const [nextInput, nextInit] = withRoutedFetchInput(args[0], args[1], proxy, current.project);
return state.originalFetch(nextInput, nextInit);
};
http.request = wrapRequest(state.originalHttpRequest, state.originalHttpsRequest, state.originalHttpRequest);
@@ -12871,9 +12880,11 @@ function resolveProxyUrl(options) {
var HeadroomPlugin = async (input, options = {}) => {
const pluginOptions = options;
const proxyUrl = resolveProxyUrl(pluginOptions);
const project = pluginOptions.project ?? input.project?.id ?? input.directory;
const retrieveTool = createHeadroomRetrieveTool({ proxyBaseUrl: proxyUrl });
const uninstallTransport = installHeadroomTransport({
proxyUrl,
project,
debug: pluginOptions.debug
});
return {
@@ -12894,7 +12905,7 @@ var HeadroomPlugin = async (input, options = {}) => {
"shell.env": async (_input, output) => {
output.env.HEADROOM_ACTIVE = "1";
output.env.HEADROOM_PROXY_URL = proxyUrl;
output.env.HEADROOM_PROJECT = pluginOptions.project ?? input.project.id ?? input.directory;
output.env.HEADROOM_PROJECT = project;
if (pluginOptions.backend) {
output.env.HEADROOM_BACKEND = pluginOptions.backend;
}
@@ -8,6 +8,7 @@ var childProcess = nodeRequire("node:child_process");
var fs = nodeRequire("node:fs");
var BASE_URL_HEADER = "x-headroom-base-url";
var ORIGINAL_PATH_HEADER = "x-headroom-original-path";
var PROJECT_HEADER = "x-headroom-project";
var PROXY_ENV = "HEADROOM_OPENCODE_TRANSPORT_PROXY_URL";
var STATE_KEY = /* @__PURE__ */ Symbol.for("headroom.opencode.transport");
function getState() {
@@ -156,7 +157,7 @@ function requestUrl(input) {
}
return new URL(String(input));
}
function mergeFetchHeaders(input, init, upstream, originalPath = void 0) {
function mergeFetchHeaders(input, init, upstream, originalPath = void 0, project = void 0) {
const headers = new Headers(input instanceof Request ? input.headers : void 0);
if (init?.headers) {
new Headers(init.headers).forEach((value, key) => headers.set(key, value));
@@ -168,9 +169,12 @@ function mergeFetchHeaders(input, init, upstream, originalPath = void 0) {
if (originalPath) {
headers.set(ORIGINAL_PATH_HEADER, originalPath);
}
if (project) {
headers.set(PROJECT_HEADER, project);
}
return headers;
}
function withRoutedFetchInput(input, init, proxy) {
function withRoutedFetchInput(input, init, proxy, project) {
const upstream = requestUrl(input);
if (!shouldRoute(upstream, proxy)) {
return [input, init];
@@ -178,7 +182,7 @@ function withRoutedFetchInput(input, init, proxy) {
const { url: nextUrl, originalPath } = routedUrlForOpenCode(upstream, proxy);
const nextInit = {
...init,
headers: mergeFetchHeaders(input, init, upstream, originalPath)
headers: mergeFetchHeaders(input, init, upstream, originalPath, project)
};
if (input instanceof Request) {
return [new Request(nextUrl, input), nextInit];
@@ -224,12 +228,15 @@ function urlFromRequestOptions(options) {
return void 0;
}
}
function headersForNodeRequest(options, upstream, originalPath) {
function headersForNodeRequest(options, upstream, originalPath, project) {
const headers = new Headers(options.headers);
headers.set(BASE_URL_HEADER, upstream.origin);
if (originalPath) {
headers.set(ORIGINAL_PATH_HEADER, originalPath);
}
if (project) {
headers.set(PROJECT_HEADER, project);
}
headers.delete("host");
const result = {};
headers.forEach((value, key) => {
@@ -237,7 +244,7 @@ function headersForNodeRequest(options, upstream, originalPath) {
});
return result;
}
function routedNodeOptions(parts, proxy) {
function routedNodeOptions(parts, proxy, project) {
if (!parts.url || !shouldRoute(parts.url, proxy)) {
return void 0;
}
@@ -268,7 +275,7 @@ function routedNodeOptions(parts, proxy) {
hostname: nextUrl.hostname,
port: nextUrl.port || void 0,
path: `${nextUrl.pathname}${nextUrl.search}`,
headers: headersForNodeRequest(parts.options, parts.url, originalPath)
headers: headersForNodeRequest(parts.options, parts.url, originalPath, project)
};
}
function wrapRequest(originalHttpRequest, originalHttpsRequest, originalRequest) {
@@ -279,7 +286,7 @@ function wrapRequest(originalHttpRequest, originalHttpsRequest, originalRequest)
}
const proxy = normalizeProxyUrl(state.proxyUrl);
const parts = splitNodeArgs(args);
const nextOptions = routedNodeOptions(parts, proxy);
const nextOptions = routedNodeOptions(parts, proxy, state.project);
if (!nextOptions) {
return Reflect.apply(originalRequest, this, args);
}
@@ -315,6 +322,7 @@ function installHeadroomTransport(options) {
if (existing) {
existing.refs += 1;
existing.proxyUrl = options.proxyUrl;
existing.project = options.project;
existing.debug = Boolean(options.debug);
installProcessEnv(options.proxyUrl);
return () => uninstallHeadroomTransport();
@@ -322,6 +330,7 @@ function installHeadroomTransport(options) {
const state = {
refs: 1,
proxyUrl: options.proxyUrl,
project: options.project,
debug: Boolean(options.debug),
originalFetch: globalThis.fetch,
originalHttpRequest: http.request,
@@ -342,7 +351,7 @@ function installHeadroomTransport(options) {
return state.originalFetch(...args);
}
const proxy = normalizeProxyUrl(current.proxyUrl);
const [nextInput, nextInit] = withRoutedFetchInput(args[0], args[1], proxy);
const [nextInput, nextInit] = withRoutedFetchInput(args[0], args[1], proxy, current.project);
return state.originalFetch(nextInput, nextInit);
};
http.request = wrapRequest(state.originalHttpRequest, state.originalHttpsRequest, state.originalHttpRequest);
+6 -4
View File
@@ -28,9 +28,14 @@ function resolveProxyUrl(options?: HeadroomOpenCodePluginOptions): string {
export const HeadroomPlugin: Plugin = async (input, options = {}) => {
const pluginOptions = options as HeadroomOpenCodePluginOptions;
const proxyUrl = resolveProxyUrl(pluginOptions);
const project =
pluginOptions.project ??
(input.project as { id?: string } | undefined)?.id ??
input.directory;
const retrieveTool = createHeadroomRetrieveTool({ proxyBaseUrl: proxyUrl });
const uninstallTransport = installHeadroomTransport({
proxyUrl,
project,
debug: pluginOptions.debug,
});
@@ -54,10 +59,7 @@ export const HeadroomPlugin: Plugin = async (input, options = {}) => {
"shell.env": async (_input, output) => {
output.env.HEADROOM_ACTIVE = "1";
output.env.HEADROOM_PROXY_URL = proxyUrl;
output.env.HEADROOM_PROJECT =
pluginOptions.project ??
(input.project as { id?: string }).id ??
input.directory;
output.env.HEADROOM_PROJECT = project;
if (pluginOptions.backend) {
output.env.HEADROOM_BACKEND = pluginOptions.backend;
}
+52
View File
@@ -386,6 +386,58 @@ describe("Headroom OpenCode transport", () => {
}
});
it("sends x-headroom-project header on routed fetch calls when project is set", async () => {
const originalFetch = globalThis.fetch;
const fetchMock = vi.fn(async (..._args: FetchCall) => new Response("ok"));
globalThis.fetch = fetchMock as unknown as typeof fetch;
installHeadroomTransport({ proxyUrl: "http://127.0.0.1:8787/v1", project: "my-project" });
await fetch("https://api.anthropic.com/v1/messages", { method: "POST" });
const headers = new Headers(fetchMock.mock.calls[0][1]?.headers);
expect(headers.get("x-headroom-project")).toBe("my-project");
globalThis.fetch = originalFetch;
});
it("omits x-headroom-project header when project is not set", async () => {
const originalFetch = globalThis.fetch;
const fetchMock = vi.fn(async (..._args: FetchCall) => new Response("ok"));
globalThis.fetch = fetchMock as unknown as typeof fetch;
installHeadroomTransport({ proxyUrl: "http://127.0.0.1:8787/v1" });
await fetch("https://api.anthropic.com/v1/messages", { method: "POST" });
const headers = new Headers(fetchMock.mock.calls[0][1]?.headers);
expect(headers.get("x-headroom-project")).toBeNull();
globalThis.fetch = originalFetch;
});
it("sends x-headroom-project header on routed Node https.request calls when project is set", async () => {
const proxy = await proxyServer();
installHeadroomTransport({ proxyUrl: proxy.url, project: "my-project" });
await new Promise<void>((resolve, reject) => {
const req = https.request(
"https://api.anthropic.com/v1/messages",
{ method: "POST" },
(res) => {
res.resume();
res.on("end", resolve);
},
);
req.on("error", reject);
req.end("{}");
});
expect(proxy.seen[0].headers["x-headroom-project"]).toBe("my-project");
await proxy.close();
});
it("restores patched transports only after the final disposer", () => {
const originalFetch = globalThis.fetch;
const originalHttpRequest = http.request;
+19 -6
View File
@@ -9,6 +9,7 @@ const fs = nodeRequire("node:fs") as typeof import("node:fs");
const BASE_URL_HEADER = "x-headroom-base-url";
const ORIGINAL_PATH_HEADER = "x-headroom-original-path";
const PROJECT_HEADER = "x-headroom-project";
const PROXY_ENV = "HEADROOM_OPENCODE_TRANSPORT_PROXY_URL";
const STATE_KEY = Symbol.for("headroom.opencode.transport");
@@ -25,12 +26,14 @@ type ChildFork = typeof childProcess.fork;
interface InstallOptions {
proxyUrl: string;
project?: string;
debug?: boolean;
}
interface TransportState {
refs: number;
proxyUrl: string;
project: string | undefined;
debug: boolean;
originalFetch: typeof fetch;
originalHttpRequest: HttpRequest;
@@ -233,6 +236,7 @@ function mergeFetchHeaders(
init: RequestInit | undefined,
upstream: URL | undefined,
originalPath: string | undefined = undefined,
project: string | undefined = undefined,
): Headers {
const headers = new Headers(input instanceof Request ? input.headers : undefined);
if (init?.headers) {
@@ -245,10 +249,13 @@ function mergeFetchHeaders(
if (originalPath) {
headers.set(ORIGINAL_PATH_HEADER, originalPath);
}
if (project) {
headers.set(PROJECT_HEADER, project);
}
return headers;
}
function withRoutedFetchInput(input: RequestInfo | URL, init: RequestInit | undefined, proxy: URL): FetchArgs {
function withRoutedFetchInput(input: RequestInfo | URL, init: RequestInit | undefined, proxy: URL, project: string | undefined): FetchArgs {
const upstream = requestUrl(input);
if (!shouldRoute(upstream, proxy)) {
return [input, init];
@@ -257,7 +264,7 @@ function withRoutedFetchInput(input: RequestInfo | URL, init: RequestInit | unde
const { url: nextUrl, originalPath } = routedUrlForOpenCode(upstream, proxy);
const nextInit = {
...init,
headers: mergeFetchHeaders(input, init, upstream, originalPath),
headers: mergeFetchHeaders(input, init, upstream, originalPath, project),
};
if (input instanceof Request) {
@@ -314,12 +321,16 @@ function headersForNodeRequest(
options: Record<string, unknown>,
upstream: URL,
originalPath: string | undefined,
project: string | undefined,
): Record<string, string> {
const headers = new Headers(options.headers as HeadersInit | undefined);
headers.set(BASE_URL_HEADER, upstream.origin);
if (originalPath) {
headers.set(ORIGINAL_PATH_HEADER, originalPath);
}
if (project) {
headers.set(PROJECT_HEADER, project);
}
headers.delete("host");
const result: Record<string, string> = {};
@@ -329,7 +340,7 @@ function headersForNodeRequest(
return result;
}
function routedNodeOptions(parts: NodeRequestParts, proxy: URL): Record<string, unknown> | undefined {
function routedNodeOptions(parts: NodeRequestParts, proxy: URL, project: string | undefined): Record<string, unknown> | undefined {
if (!parts.url || !shouldRoute(parts.url, proxy)) {
return undefined;
}
@@ -362,7 +373,7 @@ function routedNodeOptions(parts: NodeRequestParts, proxy: URL): Record<string,
hostname: nextUrl.hostname,
port: nextUrl.port || undefined,
path: `${nextUrl.pathname}${nextUrl.search}`,
headers: headersForNodeRequest(parts.options, parts.url, originalPath),
headers: headersForNodeRequest(parts.options, parts.url, originalPath, project),
};
}
@@ -379,7 +390,7 @@ function wrapRequest(
const proxy = normalizeProxyUrl(state.proxyUrl);
const parts = splitNodeArgs(args);
const nextOptions = routedNodeOptions(parts, proxy);
const nextOptions = routedNodeOptions(parts, proxy, state.project);
if (!nextOptions) {
return Reflect.apply(originalRequest, this, args);
}
@@ -420,6 +431,7 @@ export function installHeadroomTransport(options: InstallOptions): () => void {
if (existing) {
existing.refs += 1;
existing.proxyUrl = options.proxyUrl;
existing.project = options.project;
existing.debug = Boolean(options.debug);
installProcessEnv(options.proxyUrl);
return () => uninstallHeadroomTransport();
@@ -428,6 +440,7 @@ export function installHeadroomTransport(options: InstallOptions): () => void {
const state: TransportState = {
refs: 1,
proxyUrl: options.proxyUrl,
project: options.project,
debug: Boolean(options.debug),
originalFetch: globalThis.fetch,
originalHttpRequest: http.request,
@@ -449,7 +462,7 @@ export function installHeadroomTransport(options: InstallOptions): () => void {
return state.originalFetch(...args);
}
const proxy = normalizeProxyUrl(current.proxyUrl);
const [nextInput, nextInit] = withRoutedFetchInput(args[0], args[1], proxy);
const [nextInput, nextInit] = withRoutedFetchInput(args[0], args[1], proxy, current.project);
return state.originalFetch(nextInput, nextInit);
};