headroom/plugins/opencode/src/plugin.ts
Radhakrishnan Pachyappan eeb038bc0c
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>
2026-08-17 20:21:24 -07:00

70 lines
1.9 KiB
TypeScript

import type { Plugin } from "@opencode-ai/plugin";
import { tool } from "@opencode-ai/plugin";
import { z } from "zod";
import { createHeadroomRetrieveTool, getDefaultProxyUrl } from "./retrieve.js";
import { installHeadroomTransport } from "./transport.js";
export interface HeadroomOpenCodePluginOptions {
proxyUrl?: string;
project?: string;
backend?: string;
debug?: boolean;
}
function normalizeProxyUrl(url: string): string {
return url.replace(/\/+$/, "");
}
function resolveProxyUrl(options?: HeadroomOpenCodePluginOptions): string {
return normalizeProxyUrl(
options?.proxyUrl ??
process.env.HEADROOM_PROXY_URL ??
process.env.HEADROOM_BASE_URL ??
getDefaultProxyUrl(),
);
}
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,
});
return {
dispose: async () => {
uninstallTransport();
},
tool: {
headroom_retrieve: tool({
description: retrieveTool.description,
args: {
hash: z
.string()
.regex(/^[a-f0-9]{24}$/i, "Expected 24-character hex hash"),
},
async execute(args) {
return retrieveTool.execute(args);
},
}),
},
"shell.env": async (_input, output) => {
output.env.HEADROOM_ACTIVE = "1";
output.env.HEADROOM_PROXY_URL = proxyUrl;
output.env.HEADROOM_PROJECT = project;
if (pluginOptions.backend) {
output.env.HEADROOM_BACKEND = pluginOptions.backend;
}
},
};
};
export default HeadroomPlugin;