From eeb038bc0c28fc8078986db0849bfcff6743c158 Mon Sep 17 00:00:00 2001 From: Radhakrishnan Pachyappan Date: Tue, 18 Aug 2026 08:51:24 +0530 Subject: [PATCH] fix(opencode): send x-headroom-project header on all proxied requests (#2868) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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/` 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 Signed-off-by: Radhakrishnan Pachyappan --- .../opencode/_dist/entry.opencode.js | 29 +++++++---- .../providers/opencode/hook-shim/handler.js | 25 ++++++--- plugins/opencode/src/plugin.ts | 10 ++-- plugins/opencode/src/transport.test.ts | 52 +++++++++++++++++++ plugins/opencode/src/transport.ts | 25 ++++++--- 5 files changed, 114 insertions(+), 27 deletions(-) diff --git a/headroom/providers/opencode/_dist/entry.opencode.js b/headroom/providers/opencode/_dist/entry.opencode.js index f2fb1fcbb..fcc5943bb 100644 --- a/headroom/providers/opencode/_dist/entry.opencode.js +++ b/headroom/providers/opencode/_dist/entry.opencode.js @@ -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; } diff --git a/headroom/providers/opencode/hook-shim/handler.js b/headroom/providers/opencode/hook-shim/handler.js index 1dbfba992..e4e0d7d12 100644 --- a/headroom/providers/opencode/hook-shim/handler.js +++ b/headroom/providers/opencode/hook-shim/handler.js @@ -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); diff --git a/plugins/opencode/src/plugin.ts b/plugins/opencode/src/plugin.ts index cd3813614..e9d62fa3f 100644 --- a/plugins/opencode/src/plugin.ts +++ b/plugins/opencode/src/plugin.ts @@ -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; } diff --git a/plugins/opencode/src/transport.test.ts b/plugins/opencode/src/transport.test.ts index 39edd98de..f6b2cfff4 100644 --- a/plugins/opencode/src/transport.test.ts +++ b/plugins/opencode/src/transport.test.ts @@ -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((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; diff --git a/plugins/opencode/src/transport.ts b/plugins/opencode/src/transport.ts index b15d5f67d..b4f768eec 100644 --- a/plugins/opencode/src/transport.ts +++ b/plugins/opencode/src/transport.ts @@ -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, upstream: URL, originalPath: string | undefined, + project: string | undefined, ): Record { 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 = {}; @@ -329,7 +340,7 @@ function headersForNodeRequest( return result; } -function routedNodeOptions(parts: NodeRequestParts, proxy: URL): Record | undefined { +function routedNodeOptions(parts: NodeRequestParts, proxy: URL, project: string | undefined): Record | undefined { if (!parts.url || !shouldRoute(parts.url, proxy)) { return undefined; } @@ -362,7 +373,7 @@ function routedNodeOptions(parts: NodeRequestParts, proxy: URL): Record 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); };