mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
feat: headroom wrap opencode / unwrap opencode CLI (#1105)
## Summary This PR implements transparent `headroom wrap opencode` support without asking users to edit OpenCode provider URLs, choose an extra CLI flag, or maintain a static provider list. The wrapper now lives at the runtime transport boundary: OpenCode keeps its user/provider config, while Headroom intercepts outbound provider traffic in-process and routes it through the local Headroom proxy. ## What changed ### Transparent OpenCode wrapping - `headroom wrap opencode` injects the `headroom-opencode` plugin through `OPENCODE_CONFIG_CONTENT`. - Existing OpenCode provider URLs are preserved. We do not rewrite user config URLs to point at Headroom. - Existing `OPENAI_BASE_URL` and `ANTHROPIC_BASE_URL` env vars are preserved. - Local OpenCode traffic, localhost traffic, and Headroom proxy traffic bypass the shim to avoid loops. ### Runtime transport interception - Added an OpenCode plugin transport shim that wraps: - `globalThis.fetch` - `http.request` / `http.get` - `https.request` / `https.get` - External provider calls are routed to the local Headroom proxy. - The original upstream origin is passed through `x-headroom-base-url`, so the proxy can forward to the real provider without changing OpenCode config. - External `http2.connect` is blocked loudly instead of allowing direct provider traffic to leak outside Headroom. ### Live provider additions Provider coverage is no longer based on a static config scan. Because routing happens at outbound request time, providers added mid-session are routed through Headroom automatically as long as they use the covered Node transport paths. ### Subagent and child-process coverage - The parent OpenCode plugin sets a packaged Node preload shim through `NODE_OPTIONS=--import=.../hook-shim/handler.js`. - The transport shim patches `child_process.spawn`, `exec`, `execFile`, and `fork` so child Node processes receive the Headroom preload even when OpenCode passes a custom `env`. - The child-process shim fails closed if it loads without `HEADROOM_OPENCODE_TRANSPORT_PROXY_URL`. - This closes the subagent leak path where a child Node process could otherwise start without Headroom transport interception. ## Why this goes beyond PR #1089 PR #1089 improves OpenCode provider registration, but it still focuses on provider config shape. This PR moves the enforcement boundary to runtime transport interception. This PR goes further because: - No provider URL rewriting is required. - New providers added mid-session are covered automatically. - Subagents and child Node processes inherit the Headroom transport shim. - Direct external HTTP/2 paths fail loudly instead of leaking. - The wrap remains transparent to the user's OpenCode provider config. - The wrapper is fail-closed for unsupported child-process preload state. ## Additional robustness fixes While validating the change in Docker, the full Python suite exposed unrelated Linux/container robustness issues. These are fixed in this PR so the suite is green: - Binary cache handling now treats cache paths under a non-writable existing parent as unavailable, including when tests run as root in Docker. - `release_version.py` honors `MANUAL_VER` before git calls so direct script execution works outside a `.git` checkout. - Test logger isolation now resets relevant Headroom child loggers so proxy logging setup cannot poison later `caplog` tests. - The scanner missing-path test now uses a guaranteed missing `tmp_path` child instead of relying on `/nonexistent/path`. ## Validation All implementation validation was run inside Docker. - Full Python suite from a fresh Docker copy: `6605 passed, 523 skipped`. - Ruff on changed Python/OpenCode paths: passed. - OpenCode plugin typecheck: passed. - OpenCode plugin tests: `9 passed`. - OpenCode plugin build: passed. - Hook shim preload smoke test: passed. ## Notes This PR intentionally does not add a CLI option. `headroom wrap opencode` means full wrap. Either Headroom wraps OpenCode transparently, or the path fails loudly instead of silently leaking provider traffic. --------- Co-authored-by: Rudimar Ronsoni <6081613+rudironsoni@users.noreply.github.com>
This commit is contained in:
parent
95b2333ee5
commit
b4571cc346
57 changed files with 4655 additions and 62 deletions
3
plugins/opencode/.gitignore
vendored
Normal file
3
plugins/opencode/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
node_modules
|
||||
dist
|
||||
*.log
|
||||
127
plugins/opencode/README.md
Normal file
127
plugins/opencode/README.md
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
# headroom-opencode
|
||||
|
||||
Headroom proxy integration for [OpenCode](https://opencode.ai). Routes LLM traffic through the Headroom proxy for token compression, provides CCR retrieval, and handles provider configuration.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
npm install headroom-opencode
|
||||
```
|
||||
|
||||
## Quick start
|
||||
|
||||
### Create a Headroom provider for opencode.json
|
||||
|
||||
```ts
|
||||
import { createHeadroomProvider } from "headroom-opencode";
|
||||
|
||||
const provider = createHeadroomProvider({
|
||||
proxyPort: 8787,
|
||||
});
|
||||
|
||||
// Write to opencode.json:
|
||||
// {
|
||||
// "provider": { "headroom": provider },
|
||||
// "model": "headroom/claude-sonnet-4-6"
|
||||
// }
|
||||
```
|
||||
|
||||
### Build OPENCODE_CONFIG_CONTENT
|
||||
|
||||
```ts
|
||||
import { buildOpencodeConfigContentJson } from "headroom-opencode";
|
||||
|
||||
const json = buildOpencodeConfigContentJson({
|
||||
proxyPort: 8787,
|
||||
defaultModel: "claude-sonnet-4-6",
|
||||
});
|
||||
|
||||
// Set as env var: process.env.OPENCODE_CONFIG_CONTENT = json;
|
||||
```
|
||||
|
||||
### Compress messages through the proxy
|
||||
|
||||
```ts
|
||||
import { compressWithHeadroom } from "headroom-opencode";
|
||||
|
||||
const result = await compressWithHeadroom(messages, {
|
||||
model: "gpt-4o",
|
||||
proxyUrl: "http://localhost:8787",
|
||||
});
|
||||
|
||||
console.log(`Saved ${result.tokensSaved} tokens`);
|
||||
```
|
||||
|
||||
### CCR retrieve tool
|
||||
|
||||
```ts
|
||||
import { createHeadroomRetrieveTool } from "headroom-opencode";
|
||||
|
||||
const retrieveTool = createHeadroomRetrieveTool({
|
||||
proxyBaseUrl: "http://localhost:8787",
|
||||
});
|
||||
|
||||
// Register in OpenCode's MCP config under mcp.headroom_retrieve
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
### `createHeadroomProvider(options?)`
|
||||
|
||||
Creates a provider object compatible with OpenCode's `@ai-sdk/openai-compatible` format.
|
||||
|
||||
| Option | Default | Description |
|
||||
|---|---|---|
|
||||
| `proxyBaseUrl` | `http://127.0.0.1:8787` | Full proxy base URL |
|
||||
| `proxyPort` | `8787` | Proxy port (ignored if proxyBaseUrl is set) |
|
||||
| `models` | See below | Custom model mappings |
|
||||
| `defaultModel` | `claude-sonnet-4-6` | Default model ID |
|
||||
|
||||
### `buildOpencodeConfigContent(options?)`
|
||||
|
||||
Returns a full `OPENCODE_CONFIG_CONTENT` JSON object with provider and model.
|
||||
|
||||
### `buildOpencodeConfigContentJson(options?)`
|
||||
|
||||
Same as above but returns a JSON string ready for the `OPENCODE_CONFIG_CONTENT` env var.
|
||||
|
||||
### `compressWithHeadroom(messages, options?)`
|
||||
|
||||
Compresses an array of messages through the Headroom proxy. Returns compression stats and compressed messages.
|
||||
|
||||
### `createHeadroomRetrieveTool(config)`
|
||||
|
||||
Creates a CCR retrieve tool for OpenCode's MCP system.
|
||||
|
||||
### `setDefaultProxyUrl(url)` / `getDefaultProxyUrl()`
|
||||
|
||||
Set or get the default proxy URL for all operations. Defaults to `HEADROOM_BASE_URL` env var or `http://localhost:8787`.
|
||||
|
||||
## Default models
|
||||
|
||||
| Model ID | Context | Output |
|
||||
|---|---|---|
|
||||
| `claude-sonnet-4-6` | 200K | 16K |
|
||||
| `claude-opus-4-6` | 200K | 16K |
|
||||
| `claude-haiku-4-5-20251001` | 200K | 8K |
|
||||
| `gpt-4o` | 128K | 16K |
|
||||
| `gpt-4.1` | 1M | 32K |
|
||||
|
||||
## OpenCode plugin
|
||||
|
||||
The package default export is an OpenCode plugin. It adds a `headroom_retrieve`
|
||||
tool and Headroom metadata for shell commands:
|
||||
|
||||
```json
|
||||
{
|
||||
"plugin": [["headroom-opencode", { "proxyUrl": "http://127.0.0.1:8787" }]]
|
||||
}
|
||||
```
|
||||
|
||||
The plugin does not set `OPENAI_BASE_URL` or `ANTHROPIC_BASE_URL`. Model traffic
|
||||
is routed by the `headroom` provider config generated by
|
||||
`buildOpencodeConfigContent` or `headroom wrap opencode`.
|
||||
|
||||
## License
|
||||
|
||||
Apache-2.0
|
||||
8
plugins/opencode/hook-shim/handler.js
Normal file
8
plugins/opencode/hook-shim/handler.js
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { installHeadroomTransport } from "../dist/index.js";
|
||||
|
||||
const proxyUrl = process.env.HEADROOM_OPENCODE_TRANSPORT_PROXY_URL;
|
||||
if (!proxyUrl) {
|
||||
throw new Error("Headroom OpenCode transport shim loaded without HEADROOM_OPENCODE_TRANSPORT_PROXY_URL");
|
||||
}
|
||||
|
||||
installHeadroomTransport({ proxyUrl });
|
||||
46
plugins/opencode/package.json
Normal file
46
plugins/opencode/package.json
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
{
|
||||
"name": "headroom-opencode",
|
||||
"version": "0.1.0",
|
||||
"description": "Headroom proxy integration plugin for OpenCode - routes LLM traffic through the Headroom proxy for token compression",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"files": [
|
||||
"dist",
|
||||
"hook-shim",
|
||||
"README.md"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@opencode-ai/plugin": "^1.17.8",
|
||||
"headroom-ai": "^0.22.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@ai-sdk/openai-compatible": "*",
|
||||
"@ai-sdk/provider": "*",
|
||||
"ai": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@ai-sdk/openai-compatible": {
|
||||
"optional": true
|
||||
},
|
||||
"@ai-sdk/provider": {
|
||||
"optional": true
|
||||
},
|
||||
"ai": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.10.0",
|
||||
"tsup": "^8.0.0",
|
||||
"typescript": "^5.5.0",
|
||||
"vitest": "^4.1.5"
|
||||
},
|
||||
"license": "Apache-2.0"
|
||||
}
|
||||
23
plugins/opencode/src/index.ts
Normal file
23
plugins/opencode/src/index.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
export {
|
||||
DEFAULT_MODEL,
|
||||
DEFAULT_MODELS,
|
||||
buildOpencodeConfigContent,
|
||||
buildOpencodeConfigContentJson,
|
||||
createHeadroomProvider,
|
||||
} from "./provider.js";
|
||||
export type {
|
||||
HeadroomModelMapping,
|
||||
HeadroomProvider,
|
||||
HeadroomProviderOptions,
|
||||
} from "./provider.js";
|
||||
export {
|
||||
compressWithHeadroom,
|
||||
createHeadroomRetrieveTool,
|
||||
getDefaultProxyUrl,
|
||||
setDefaultProxyUrl,
|
||||
} from "./retrieve.js";
|
||||
export type { RetrieveToolConfig } from "./retrieve.js";
|
||||
export { HeadroomPlugin, default } from "./plugin.js";
|
||||
export type { HeadroomOpenCodePluginOptions } from "./plugin.js";
|
||||
|
||||
export { installHeadroomTransport } from "./transport.js";
|
||||
68
plugins/opencode/src/plugin.test.ts
Normal file
68
plugins/opencode/src/plugin.test.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { HeadroomPlugin } from "./plugin.js";
|
||||
|
||||
function pluginInput() {
|
||||
return {
|
||||
client: {},
|
||||
project: { id: "project-1" },
|
||||
directory: "/repo",
|
||||
worktree: "/repo",
|
||||
experimental_workspace: {
|
||||
register: vi.fn(),
|
||||
},
|
||||
$: {},
|
||||
} as never;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("HeadroomPlugin", () => {
|
||||
it("adds only Headroom metadata to shell env", async () => {
|
||||
const plugin = await HeadroomPlugin(pluginInput(), {
|
||||
proxyUrl: "http://127.0.0.1:8787/",
|
||||
backend: "litellm",
|
||||
});
|
||||
const output = {
|
||||
env: {
|
||||
OPENAI_BASE_URL: "https://deepseek.example/v1",
|
||||
ANTHROPIC_BASE_URL: "https://anthropic.example",
|
||||
},
|
||||
};
|
||||
|
||||
await plugin["shell.env"]?.({ cwd: "/repo" }, output);
|
||||
|
||||
expect(output.env).toMatchObject({
|
||||
HEADROOM_ACTIVE: "1",
|
||||
HEADROOM_PROXY_URL: "http://127.0.0.1:8787",
|
||||
HEADROOM_PROJECT: "project-1",
|
||||
HEADROOM_BACKEND: "litellm",
|
||||
OPENAI_BASE_URL: "https://deepseek.example/v1",
|
||||
ANTHROPIC_BASE_URL: "https://anthropic.example",
|
||||
});
|
||||
});
|
||||
|
||||
it("exposes a headroom_retrieve tool backed by the proxy", async () => {
|
||||
const fetchMock = vi.fn(async () => ({
|
||||
ok: true,
|
||||
json: async () => "original content",
|
||||
}));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const plugin = await HeadroomPlugin(pluginInput(), {
|
||||
proxyUrl: "http://127.0.0.1:8787",
|
||||
});
|
||||
const result = await plugin.tool?.headroom_retrieve.execute(
|
||||
{ hash: "0123456789abcdef01234567", query: "needle" },
|
||||
{} as never,
|
||||
);
|
||||
|
||||
expect(result).toBe("original content");
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"http://127.0.0.1:8787/v1/retrieve/0123456789abcdef01234567?query=needle",
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
});
|
||||
69
plugins/opencode/src/plugin.ts
Normal file
69
plugins/opencode/src/plugin.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
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 retrieveTool = createHeadroomRetrieveTool({ proxyBaseUrl: proxyUrl });
|
||||
const uninstallTransport = installHeadroomTransport({
|
||||
proxyUrl,
|
||||
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"),
|
||||
query: z.string().optional(),
|
||||
},
|
||||
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 =
|
||||
pluginOptions.project ??
|
||||
(input.project as { id?: string }).id ??
|
||||
input.directory;
|
||||
if (pluginOptions.backend) {
|
||||
output.env.HEADROOM_BACKEND = pluginOptions.backend;
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export default HeadroomPlugin;
|
||||
92
plugins/opencode/src/provider.ts
Normal file
92
plugins/opencode/src/provider.ts
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
export interface HeadroomModelMapping {
|
||||
name: string;
|
||||
limit: {
|
||||
context: number;
|
||||
output: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface HeadroomProviderOptions {
|
||||
proxyBaseUrl?: string;
|
||||
proxyPort?: number;
|
||||
defaultModel?: string;
|
||||
models?: Record<string, HeadroomModelMapping>;
|
||||
}
|
||||
|
||||
export const DEFAULT_MODELS: Record<string, HeadroomModelMapping> = {
|
||||
"claude-sonnet-4-6": {
|
||||
name: "Claude Sonnet 4.6",
|
||||
limit: { context: 200000, output: 16384 },
|
||||
},
|
||||
"claude-opus-4-6": {
|
||||
name: "Claude Opus 4.6",
|
||||
limit: { context: 200000, output: 16384 },
|
||||
},
|
||||
"claude-haiku-4-5-20251001": {
|
||||
name: "Claude Haiku 4.5",
|
||||
limit: { context: 200000, output: 8192 },
|
||||
},
|
||||
"gpt-4o": {
|
||||
name: "GPT-4o",
|
||||
limit: { context: 128000, output: 16384 },
|
||||
},
|
||||
"gpt-4.1": {
|
||||
name: "GPT-4.1",
|
||||
limit: { context: 1048576, output: 32768 },
|
||||
},
|
||||
};
|
||||
|
||||
export const DEFAULT_MODEL = "claude-sonnet-4-6";
|
||||
|
||||
function resolveBaseUrl(options: HeadroomProviderOptions): string {
|
||||
if (options.proxyBaseUrl) return options.proxyBaseUrl.replace(/\/+$/, "");
|
||||
const port = options.proxyPort ?? 8787;
|
||||
return `http://127.0.0.1:${port}`;
|
||||
}
|
||||
|
||||
export interface HeadroomProvider {
|
||||
npm: string;
|
||||
name: string;
|
||||
options: {
|
||||
baseURL: string;
|
||||
apiKey?: string;
|
||||
};
|
||||
models: Record<string, HeadroomModelMapping>;
|
||||
}
|
||||
|
||||
export function createHeadroomProvider(
|
||||
options: HeadroomProviderOptions = {},
|
||||
): HeadroomProvider {
|
||||
const baseUrl = resolveBaseUrl(options);
|
||||
const models = options.models ?? DEFAULT_MODELS;
|
||||
|
||||
return {
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
name: "Headroom Proxy",
|
||||
options: { baseURL: `${baseUrl}/v1` },
|
||||
models: Object.fromEntries(
|
||||
Object.entries(models).map(([id, mapping]) => [
|
||||
`headroom/${id}`,
|
||||
mapping,
|
||||
]),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildOpencodeConfigContent(
|
||||
options: HeadroomProviderOptions = {},
|
||||
): Record<string, unknown> {
|
||||
const defaultModel = options.defaultModel ?? DEFAULT_MODEL;
|
||||
const provider = createHeadroomProvider(options);
|
||||
|
||||
return {
|
||||
provider: { headroom: provider },
|
||||
model: `headroom/${defaultModel}`,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildOpencodeConfigContentJson(
|
||||
options: HeadroomProviderOptions = {},
|
||||
): string {
|
||||
return JSON.stringify(buildOpencodeConfigContent(options));
|
||||
}
|
||||
94
plugins/opencode/src/retrieve.ts
Normal file
94
plugins/opencode/src/retrieve.ts
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
import type { CompressResult } from "headroom-ai";
|
||||
import { compress } from "headroom-ai";
|
||||
|
||||
let _proxyUrlCache: string | null = null;
|
||||
|
||||
export function setDefaultProxyUrl(url: string): void {
|
||||
_proxyUrlCache = url;
|
||||
}
|
||||
|
||||
export function getDefaultProxyUrl(): string {
|
||||
return _proxyUrlCache ?? process.env.HEADROOM_BASE_URL ?? "http://localhost:8787";
|
||||
}
|
||||
|
||||
export interface RetrieveToolConfig {
|
||||
proxyBaseUrl: string;
|
||||
}
|
||||
|
||||
export function createHeadroomRetrieveTool(config: RetrieveToolConfig) {
|
||||
const origin = config.proxyBaseUrl.replace(/\/+$/, "");
|
||||
|
||||
return {
|
||||
name: "headroom_retrieve",
|
||||
description:
|
||||
"Retrieve original uncompressed content from Headroom's compression store. " +
|
||||
"Use when compressed context mentions a hash and you need the full details. " +
|
||||
"Pass the hash from the compression marker (24 hex characters). " +
|
||||
"Optionally pass a query to search within the original content.",
|
||||
parameters: {
|
||||
type: "object" as const,
|
||||
properties: {
|
||||
hash: {
|
||||
type: "string",
|
||||
description: "The 24-character hex hash from the compression marker",
|
||||
},
|
||||
query: {
|
||||
type: "string",
|
||||
description: "Optional search query to filter results within the original content",
|
||||
},
|
||||
},
|
||||
required: ["hash"],
|
||||
},
|
||||
execute: async (args: { hash: string; query?: string }): Promise<string> => {
|
||||
const { hash, query } = args;
|
||||
|
||||
if (!/^[a-f0-9]{24}$/i.test(hash)) {
|
||||
return JSON.stringify({
|
||||
error: "Invalid hash format. Expected 24 hex characters.",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const url = query
|
||||
? `${origin}/v1/retrieve/${hash}?query=${encodeURIComponent(query)}`
|
||||
: `${origin}/v1/retrieve/${hash}`;
|
||||
|
||||
const resp = await fetch(url, {
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
const body = await resp.text().catch(() => "");
|
||||
return JSON.stringify({
|
||||
error: `Retrieval failed: HTTP ${resp.status}`,
|
||||
details: body,
|
||||
});
|
||||
}
|
||||
|
||||
const data = await resp.json();
|
||||
return typeof data === "string" ? data : JSON.stringify(data);
|
||||
} catch (error) {
|
||||
return JSON.stringify({
|
||||
error: `Retrieval failed: ${error}`,
|
||||
hint: "The compressed content may have expired (default TTL: 5 minutes)",
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function compressWithHeadroom(
|
||||
messages: unknown[],
|
||||
options: {
|
||||
model?: string;
|
||||
tokenBudget?: number;
|
||||
proxyUrl?: string;
|
||||
} = {},
|
||||
): Promise<CompressResult> {
|
||||
return compress(messages, {
|
||||
baseUrl: options.proxyUrl ?? getDefaultProxyUrl(),
|
||||
model: options.model ?? "gpt-4o",
|
||||
tokenBudget: options.tokenBudget,
|
||||
stack: "opencode",
|
||||
});
|
||||
}
|
||||
215
plugins/opencode/src/transport.test.ts
Normal file
215
plugins/opencode/src/transport.test.ts
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
import childProcess from "node:child_process";
|
||||
import http from "node:http";
|
||||
import http2 from "node:http2";
|
||||
import https from "node:https";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { installHeadroomTransport, uninstallHeadroomTransport } from "./transport.js";
|
||||
|
||||
afterEach(() => {
|
||||
uninstallHeadroomTransport();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
type FetchCall = [RequestInfo | URL, RequestInit?];
|
||||
|
||||
type SeenRequest = {
|
||||
method: string | undefined;
|
||||
url: string | undefined;
|
||||
headers: http.IncomingHttpHeaders;
|
||||
body: string;
|
||||
};
|
||||
|
||||
function proxyServer(): Promise<{ url: string; seen: SeenRequest[]; close: () => Promise<void> }> {
|
||||
const seen: SeenRequest[] = [];
|
||||
const server = http.createServer((req, res) => {
|
||||
let body = "";
|
||||
req.setEncoding("utf8");
|
||||
req.on("data", (chunk) => {
|
||||
body += chunk;
|
||||
});
|
||||
req.on("end", () => {
|
||||
seen.push({ method: req.method, url: req.url, headers: req.headers, body });
|
||||
res.writeHead(200, { "content-type": "application/json" });
|
||||
res.end("{\"ok\":true}");
|
||||
});
|
||||
});
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
reject(new Error("Expected TCP server address"));
|
||||
return;
|
||||
}
|
||||
resolve({
|
||||
url: `http://127.0.0.1:${address.port}/v1`,
|
||||
seen,
|
||||
close: () => new Promise((done) => server.close(() => done())),
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
describe("Headroom OpenCode transport", () => {
|
||||
it("routes external fetch calls through the proxy without pre-registering providers", 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.deepseek.com/v1/chat/completions?x=1", {
|
||||
method: "POST",
|
||||
headers: { authorization: "Bearer test" },
|
||||
});
|
||||
await fetch("https://new-provider.example/base/v1/messages", { method: "POST" });
|
||||
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
new URL("http://127.0.0.1:8787/v1/chat/completions?x=1"),
|
||||
expect.objectContaining({ method: "POST" }),
|
||||
);
|
||||
expect(new Headers(fetchMock.mock.calls[0][1]?.headers).get("x-headroom-base-url")).toBe(
|
||||
"https://api.deepseek.com",
|
||||
);
|
||||
expect(fetchMock.mock.calls[1][0]).toEqual(new URL("http://127.0.0.1:8787/base/v1/messages"));
|
||||
expect(new Headers(fetchMock.mock.calls[1][1]?.headers).get("x-headroom-base-url")).toBe(
|
||||
"https://new-provider.example",
|
||||
);
|
||||
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
it("bypasses local, OpenCode, and Headroom proxy fetch URLs", 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("http://127.0.0.1:8787/v1/retrieve");
|
||||
await fetch("http://localhost:4096/config");
|
||||
|
||||
expect(fetchMock.mock.calls[0][0]).toBe("http://127.0.0.1:8787/v1/retrieve");
|
||||
expect(fetchMock.mock.calls[1][0]).toBe("http://localhost:4096/config");
|
||||
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
it("routes external https.request calls through the proxy", async () => {
|
||||
const proxy = await proxyServer();
|
||||
installHeadroomTransport({ proxyUrl: proxy.url });
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const req = https.request(
|
||||
"https://api.anthropic.com/v1/messages?beta=1",
|
||||
{ method: "POST", headers: { authorization: "Bearer test" } },
|
||||
(res) => {
|
||||
res.resume();
|
||||
res.on("end", resolve);
|
||||
},
|
||||
);
|
||||
req.on("error", reject);
|
||||
req.end("{\"model\":\"claude\"}");
|
||||
});
|
||||
|
||||
expect(proxy.seen).toHaveLength(1);
|
||||
expect(proxy.seen[0]).toMatchObject({ method: "POST", url: "/v1/messages?beta=1" });
|
||||
expect(proxy.seen[0].headers["x-headroom-base-url"]).toBe("https://api.anthropic.com");
|
||||
expect(proxy.seen[0].headers.host).toMatch(/^127\.0\.0\.1:/);
|
||||
expect(proxy.seen[0].body).toBe("{\"model\":\"claude\"}");
|
||||
|
||||
await proxy.close();
|
||||
});
|
||||
|
||||
it("blocks external http2 connections instead of leaking them", () => {
|
||||
installHeadroomTransport({ proxyUrl: "http://127.0.0.1:8787/v1" });
|
||||
|
||||
expect(() => http2.connect("https://api.openai.com")).toThrow(
|
||||
/blocked direct HTTP\/2 connection to https:\/\/api\.openai\.com/,
|
||||
);
|
||||
});
|
||||
|
||||
it("preloads the Headroom shim into child Node processes", () => {
|
||||
const originalNodeOptions = process.env.NODE_OPTIONS;
|
||||
const originalProxyUrl = process.env.HEADROOM_OPENCODE_TRANSPORT_PROXY_URL;
|
||||
|
||||
try {
|
||||
process.env.NODE_OPTIONS = "--trace-warnings";
|
||||
delete process.env.HEADROOM_OPENCODE_TRANSPORT_PROXY_URL;
|
||||
|
||||
installHeadroomTransport({ proxyUrl: "http://127.0.0.1:8787/v1" });
|
||||
|
||||
expect(process.env.HEADROOM_OPENCODE_TRANSPORT_PROXY_URL).toBe("http://127.0.0.1:8787/v1");
|
||||
expect(process.env.NODE_OPTIONS).toContain("--trace-warnings");
|
||||
expect(process.env.NODE_OPTIONS).toContain("--import=file:");
|
||||
expect(process.env.NODE_OPTIONS).toContain("/hook-shim/handler.js");
|
||||
|
||||
installHeadroomTransport({ proxyUrl: "http://127.0.0.1:8787/v1" });
|
||||
expect(process.env.NODE_OPTIONS?.match(/hook-shim\/handler\.js/g)).toHaveLength(1);
|
||||
} finally {
|
||||
if (originalNodeOptions === undefined) {
|
||||
delete process.env.NODE_OPTIONS;
|
||||
} else {
|
||||
process.env.NODE_OPTIONS = originalNodeOptions;
|
||||
}
|
||||
if (originalProxyUrl === undefined) {
|
||||
delete process.env.HEADROOM_OPENCODE_TRANSPORT_PROXY_URL;
|
||||
} else {
|
||||
process.env.HEADROOM_OPENCODE_TRANSPORT_PROXY_URL = originalProxyUrl;
|
||||
}
|
||||
uninstallHeadroomTransport();
|
||||
}
|
||||
});
|
||||
|
||||
it("injects the Headroom shim into child processes with custom env", () => {
|
||||
const originalSpawn = childProcess.spawn;
|
||||
const spawnMock = vi.fn(() => ({
|
||||
on: vi.fn(),
|
||||
once: vi.fn(),
|
||||
emit: vi.fn(),
|
||||
kill: vi.fn(),
|
||||
killed: false,
|
||||
pid: 123,
|
||||
}));
|
||||
childProcess.spawn = spawnMock as unknown as typeof childProcess.spawn;
|
||||
|
||||
try {
|
||||
installHeadroomTransport({ proxyUrl: "http://127.0.0.1:8787/v1" });
|
||||
childProcess.spawn("node", ["agent.js"], { env: { PATH: "/bin", NODE_OPTIONS: "--trace-warnings" } });
|
||||
|
||||
const options = (spawnMock.mock.calls[0] as unknown[])[2] as { env: NodeJS.ProcessEnv };
|
||||
expect(options.env.PATH).toBe("/bin");
|
||||
expect(options.env.HEADROOM_OPENCODE_TRANSPORT_PROXY_URL).toBe("http://127.0.0.1:8787/v1");
|
||||
expect(options.env.NODE_OPTIONS).toContain("--trace-warnings");
|
||||
expect(options.env.NODE_OPTIONS).toContain("--import=file:");
|
||||
expect(options.env.NODE_OPTIONS).toContain("/hook-shim/handler.js");
|
||||
} finally {
|
||||
uninstallHeadroomTransport();
|
||||
childProcess.spawn = originalSpawn;
|
||||
}
|
||||
});
|
||||
|
||||
it("restores patched transports only after the final disposer", () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const originalHttpRequest = http.request;
|
||||
const originalHttpsRequest = https.request;
|
||||
const firstDispose = installHeadroomTransport({ proxyUrl: "http://127.0.0.1:8787/v1" });
|
||||
const secondDispose = installHeadroomTransport({ proxyUrl: "http://127.0.0.1:8788/v1" });
|
||||
|
||||
expect(globalThis.fetch).not.toBe(originalFetch);
|
||||
expect(http.request).not.toBe(originalHttpRequest);
|
||||
expect(https.request).not.toBe(originalHttpsRequest);
|
||||
|
||||
firstDispose();
|
||||
expect(globalThis.fetch).not.toBe(originalFetch);
|
||||
expect(http.request).not.toBe(originalHttpRequest);
|
||||
|
||||
secondDispose();
|
||||
expect(globalThis.fetch).toBe(originalFetch);
|
||||
expect(http.request).toBe(originalHttpRequest);
|
||||
expect(https.request).toBe(originalHttpsRequest);
|
||||
});
|
||||
});
|
||||
438
plugins/opencode/src/transport.ts
Normal file
438
plugins/opencode/src/transport.ts
Normal file
|
|
@ -0,0 +1,438 @@
|
|||
import { createRequire, syncBuiltinESMExports } from "node:module";
|
||||
|
||||
const nodeRequire = createRequire(import.meta.url);
|
||||
const http = nodeRequire("node:http") as typeof import("node:http");
|
||||
const https = nodeRequire("node:https") as typeof import("node:https");
|
||||
const http2 = nodeRequire("node:http2") as typeof import("node:http2");
|
||||
const childProcess = nodeRequire("node:child_process") as typeof import("node:child_process");
|
||||
|
||||
const BASE_URL_HEADER = "x-headroom-base-url";
|
||||
const PROXY_ENV = "HEADROOM_OPENCODE_TRANSPORT_PROXY_URL";
|
||||
const STATE_KEY = Symbol.for("headroom.opencode.transport");
|
||||
|
||||
type FetchArgs = Parameters<typeof fetch>;
|
||||
type HttpRequest = typeof http.request;
|
||||
type HttpGet = typeof http.get;
|
||||
type HttpsRequest = typeof https.request;
|
||||
type HttpsGet = typeof https.get;
|
||||
type Http2Connect = typeof http2.connect;
|
||||
type ChildSpawn = typeof childProcess.spawn;
|
||||
type ChildExec = typeof childProcess.exec;
|
||||
type ChildExecFile = typeof childProcess.execFile;
|
||||
type ChildFork = typeof childProcess.fork;
|
||||
|
||||
interface InstallOptions {
|
||||
proxyUrl: string;
|
||||
debug?: boolean;
|
||||
}
|
||||
|
||||
interface TransportState {
|
||||
refs: number;
|
||||
proxyUrl: string;
|
||||
debug: boolean;
|
||||
originalFetch: typeof fetch;
|
||||
originalHttpRequest: HttpRequest;
|
||||
originalHttpGet: HttpGet;
|
||||
originalHttpsRequest: HttpsRequest;
|
||||
originalHttpsGet: HttpsGet;
|
||||
originalHttp2Connect: Http2Connect;
|
||||
originalChildSpawn: ChildSpawn;
|
||||
originalChildExec: ChildExec;
|
||||
originalChildExecFile: ChildExecFile;
|
||||
originalChildFork: ChildFork;
|
||||
}
|
||||
|
||||
interface GlobalWithHeadroomTransport {
|
||||
[STATE_KEY]?: TransportState;
|
||||
}
|
||||
|
||||
interface NodeRequestParts {
|
||||
url?: URL;
|
||||
options: Record<string, unknown>;
|
||||
callback?: (...args: unknown[]) => unknown;
|
||||
}
|
||||
|
||||
function getState(): TransportState | undefined {
|
||||
return (globalThis as GlobalWithHeadroomTransport)[STATE_KEY];
|
||||
}
|
||||
|
||||
function setState(state: TransportState | undefined): void {
|
||||
(globalThis as GlobalWithHeadroomTransport)[STATE_KEY] = state;
|
||||
}
|
||||
|
||||
function shimImportSpecifier(): string {
|
||||
return new URL("../hook-shim/handler.js", import.meta.url).href;
|
||||
}
|
||||
|
||||
function withNodeImportOption(existing: string | undefined, shim: string): string {
|
||||
const parts = existing?.trim() ? existing.trim().split(/\s+/) : [];
|
||||
const alreadyPresent = parts.some((part, index) => {
|
||||
return part === `--import=${shim}` || (part === "--import" && parts[index + 1] === shim);
|
||||
});
|
||||
if (!alreadyPresent) {
|
||||
parts.push(`--import=${shim}`);
|
||||
}
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
function withShimEnv(env: NodeJS.ProcessEnv | Record<string, unknown> | undefined, proxyUrl: string): NodeJS.ProcessEnv {
|
||||
const nextEnv = { ...(env ?? process.env) } as NodeJS.ProcessEnv;
|
||||
nextEnv[PROXY_ENV] = proxyUrl;
|
||||
nextEnv.NODE_OPTIONS = withNodeImportOption(nextEnv.NODE_OPTIONS, shimImportSpecifier());
|
||||
return nextEnv;
|
||||
}
|
||||
|
||||
function installProcessEnv(proxyUrl: string): void {
|
||||
process.env[PROXY_ENV] = proxyUrl;
|
||||
process.env.NODE_OPTIONS = withNodeImportOption(process.env.NODE_OPTIONS, shimImportSpecifier());
|
||||
}
|
||||
|
||||
function isOptions(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value) && !(value instanceof URL);
|
||||
}
|
||||
|
||||
function injectOptionsEnv(args: unknown[], optionIndex: number, proxyUrl: string): unknown[] {
|
||||
const nextArgs = [...args];
|
||||
const callback = typeof nextArgs.at(-1) === "function" ? nextArgs.pop() : undefined;
|
||||
const existing = isOptions(nextArgs[optionIndex]) ? { ...(nextArgs[optionIndex] as Record<string, unknown>) } : {};
|
||||
existing.env = withShimEnv(existing.env as NodeJS.ProcessEnv | undefined, proxyUrl);
|
||||
|
||||
if (isOptions(nextArgs[optionIndex])) {
|
||||
nextArgs[optionIndex] = existing;
|
||||
} else {
|
||||
nextArgs.splice(optionIndex, 0, existing);
|
||||
}
|
||||
|
||||
if (callback) {
|
||||
nextArgs.push(callback);
|
||||
}
|
||||
return nextArgs;
|
||||
}
|
||||
|
||||
function wrapSpawn(originalSpawn: ChildSpawn): ChildSpawn {
|
||||
return function headroomSpawn(this: unknown, ...args: unknown[]) {
|
||||
const state = getState();
|
||||
if (!state) {
|
||||
return Reflect.apply(originalSpawn, this, args);
|
||||
}
|
||||
const optionIndex = Array.isArray(args[1]) ? 2 : 1;
|
||||
return Reflect.apply(originalSpawn, this, injectOptionsEnv(args, optionIndex, state.proxyUrl));
|
||||
} as ChildSpawn;
|
||||
}
|
||||
|
||||
function wrapExec(originalExec: ChildExec): ChildExec {
|
||||
return function headroomExec(this: unknown, ...args: unknown[]) {
|
||||
const state = getState();
|
||||
if (!state) {
|
||||
return Reflect.apply(originalExec, this, args);
|
||||
}
|
||||
return Reflect.apply(originalExec, this, injectOptionsEnv(args, 1, state.proxyUrl));
|
||||
} as ChildExec;
|
||||
}
|
||||
|
||||
function wrapExecFile(originalExecFile: ChildExecFile): ChildExecFile {
|
||||
return function headroomExecFile(this: unknown, ...args: unknown[]) {
|
||||
const state = getState();
|
||||
if (!state) {
|
||||
return Reflect.apply(originalExecFile, this, args);
|
||||
}
|
||||
const optionIndex = Array.isArray(args[1]) ? 2 : 1;
|
||||
return Reflect.apply(originalExecFile, this, injectOptionsEnv(args, optionIndex, state.proxyUrl));
|
||||
} as ChildExecFile;
|
||||
}
|
||||
|
||||
function wrapFork(originalFork: ChildFork): ChildFork {
|
||||
return function headroomFork(this: unknown, ...args: unknown[]) {
|
||||
const state = getState();
|
||||
if (!state) {
|
||||
return Reflect.apply(originalFork, this, args);
|
||||
}
|
||||
const optionIndex = Array.isArray(args[1]) ? 2 : 1;
|
||||
return Reflect.apply(originalFork, this, injectOptionsEnv(args, optionIndex, state.proxyUrl));
|
||||
} as ChildFork;
|
||||
}
|
||||
|
||||
function normalizeProxyUrl(proxyUrl: string): URL {
|
||||
return new URL(proxyUrl);
|
||||
}
|
||||
|
||||
function isLoopback(hostname: string): boolean {
|
||||
const normalized = hostname.toLowerCase().replace(/^\[|\]$/g, "");
|
||||
return normalized === "localhost" || normalized === "127.0.0.1" || normalized === "::1";
|
||||
}
|
||||
|
||||
function shouldRoute(url: URL, proxy: URL): boolean {
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
||||
return false;
|
||||
}
|
||||
if (isLoopback(url.hostname)) {
|
||||
return false;
|
||||
}
|
||||
if (url.origin === proxy.origin) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function routedUrl(upstream: URL, proxy: URL): URL {
|
||||
return new URL(`${upstream.pathname}${upstream.search}`, proxy.origin);
|
||||
}
|
||||
|
||||
function requestUrl(input: RequestInfo | URL): URL {
|
||||
if (input instanceof Request) {
|
||||
return new URL(input.url);
|
||||
}
|
||||
if (input instanceof URL) {
|
||||
return input;
|
||||
}
|
||||
return new URL(String(input));
|
||||
}
|
||||
|
||||
function mergeFetchHeaders(input: RequestInfo | URL, init?: RequestInit, upstream?: URL): Headers {
|
||||
const headers = new Headers(input instanceof Request ? input.headers : undefined);
|
||||
if (init?.headers) {
|
||||
new Headers(init.headers).forEach((value, key) => headers.set(key, value));
|
||||
}
|
||||
if (upstream) {
|
||||
headers.set(BASE_URL_HEADER, upstream.origin);
|
||||
headers.delete("host");
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
function withRoutedFetchInput(input: RequestInfo | URL, init: RequestInit | undefined, proxy: URL): FetchArgs {
|
||||
const upstream = requestUrl(input);
|
||||
if (!shouldRoute(upstream, proxy)) {
|
||||
return [input, init];
|
||||
}
|
||||
|
||||
const nextInit = {
|
||||
...init,
|
||||
headers: mergeFetchHeaders(input, init, upstream),
|
||||
};
|
||||
const nextUrl = routedUrl(upstream, proxy);
|
||||
|
||||
if (input instanceof Request) {
|
||||
return [new Request(nextUrl, input), nextInit];
|
||||
}
|
||||
return [nextUrl, nextInit];
|
||||
}
|
||||
|
||||
function splitNodeArgs(args: unknown[]): NodeRequestParts {
|
||||
const callback = typeof args.at(-1) === "function" ? (args.at(-1) as (...args: unknown[]) => unknown) : undefined;
|
||||
const withoutCallback = callback ? args.slice(0, -1) : args;
|
||||
const [first, second] = withoutCallback;
|
||||
const options = typeof second === "object" && second !== null ? { ...(second as Record<string, unknown>) } : {};
|
||||
|
||||
if (first instanceof URL) {
|
||||
return { url: first, options, callback };
|
||||
}
|
||||
if (typeof first === "string") {
|
||||
try {
|
||||
return { url: new URL(first), options, callback };
|
||||
} catch {
|
||||
return { options, callback };
|
||||
}
|
||||
}
|
||||
if (typeof first === "object" && first !== null) {
|
||||
const requestOptions = { ...(first as Record<string, unknown>), ...options };
|
||||
return { url: urlFromRequestOptions(requestOptions), options: requestOptions, callback };
|
||||
}
|
||||
return { options, callback };
|
||||
}
|
||||
|
||||
function urlFromRequestOptions(options: Record<string, unknown>): URL | undefined {
|
||||
const protocol = String(options.protocol ?? "http:");
|
||||
if (protocol !== "http:" && protocol !== "https:") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const hostValue = options.hostname ?? options.host;
|
||||
if (!hostValue) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const hostname = String(hostValue).replace(/:\d+$/, "");
|
||||
const port = options.port ? `:${String(options.port)}` : "";
|
||||
const path = String(options.path ?? "/");
|
||||
try {
|
||||
return new URL(`${protocol}//${hostname}${port}${path}`);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function headersForNodeRequest(options: Record<string, unknown>, upstream: URL): Record<string, string> {
|
||||
const headers = new Headers(options.headers as HeadersInit | undefined);
|
||||
headers.set(BASE_URL_HEADER, upstream.origin);
|
||||
headers.delete("host");
|
||||
|
||||
const result: Record<string, string> = {};
|
||||
headers.forEach((value, key) => {
|
||||
result[key] = value;
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
function routedNodeOptions(parts: NodeRequestParts, proxy: URL): Record<string, unknown> | undefined {
|
||||
if (!parts.url || !shouldRoute(parts.url, proxy)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const nextUrl = routedUrl(parts.url, proxy);
|
||||
const {
|
||||
agent: _agent,
|
||||
auth: _auth,
|
||||
createConnection: _createConnection,
|
||||
defaultPort: _defaultPort,
|
||||
family: _family,
|
||||
headers: _headers,
|
||||
host: _host,
|
||||
hostname: _hostname,
|
||||
href: _href,
|
||||
lookup: _lookup,
|
||||
path: _path,
|
||||
pathname: _pathname,
|
||||
port: _port,
|
||||
protocol: _protocol,
|
||||
search: _search,
|
||||
servername: _servername,
|
||||
setHost: _setHost,
|
||||
...rest
|
||||
} = parts.options;
|
||||
|
||||
return {
|
||||
...rest,
|
||||
protocol: nextUrl.protocol,
|
||||
hostname: nextUrl.hostname,
|
||||
port: nextUrl.port || undefined,
|
||||
path: `${nextUrl.pathname}${nextUrl.search}`,
|
||||
headers: headersForNodeRequest(parts.options, parts.url),
|
||||
};
|
||||
}
|
||||
|
||||
function wrapRequest(
|
||||
originalHttpRequest: HttpRequest,
|
||||
originalHttpsRequest: HttpsRequest,
|
||||
originalRequest: HttpRequest | HttpsRequest,
|
||||
): HttpRequest | HttpsRequest {
|
||||
return function headroomRequest(this: unknown, ...args: unknown[]) {
|
||||
const state = getState();
|
||||
if (!state) {
|
||||
return Reflect.apply(originalRequest, this, args);
|
||||
}
|
||||
|
||||
const proxy = normalizeProxyUrl(state.proxyUrl);
|
||||
const parts = splitNodeArgs(args);
|
||||
const nextOptions = routedNodeOptions(parts, proxy);
|
||||
if (!nextOptions) {
|
||||
return Reflect.apply(originalRequest, this, args);
|
||||
}
|
||||
|
||||
const targetRequest = proxy.protocol === "https:" ? originalHttpsRequest : originalHttpRequest;
|
||||
const nextArgs = parts.callback ? [nextOptions, parts.callback] : [nextOptions];
|
||||
return Reflect.apply(targetRequest, this, nextArgs);
|
||||
} as HttpRequest | HttpsRequest;
|
||||
}
|
||||
|
||||
function wrapGet(request: HttpRequest | HttpsRequest): HttpGet | HttpsGet {
|
||||
return function headroomGet(this: unknown, ...args: unknown[]) {
|
||||
const req = Reflect.apply(request, this, args);
|
||||
req.end();
|
||||
return req;
|
||||
} as HttpGet | HttpsGet;
|
||||
}
|
||||
|
||||
function wrapHttp2Connect(originalConnect: Http2Connect): Http2Connect {
|
||||
return function headroomHttp2Connect(this: unknown, authority: string | URL, ...args: unknown[]) {
|
||||
const state = getState();
|
||||
if (state) {
|
||||
const proxy = normalizeProxyUrl(state.proxyUrl);
|
||||
const upstream = authority instanceof URL ? authority : new URL(String(authority));
|
||||
if (shouldRoute(upstream, proxy)) {
|
||||
throw new Error(
|
||||
`Headroom OpenCode wrap blocked direct HTTP/2 connection to ${upstream.origin}. ` +
|
||||
"Use fetch, http, or https so traffic can be routed through Headroom.",
|
||||
);
|
||||
}
|
||||
}
|
||||
return Reflect.apply(originalConnect, this, [authority, ...args]);
|
||||
} as Http2Connect;
|
||||
}
|
||||
|
||||
export function installHeadroomTransport(options: InstallOptions): () => void {
|
||||
const existing = getState();
|
||||
if (existing) {
|
||||
existing.refs += 1;
|
||||
existing.proxyUrl = options.proxyUrl;
|
||||
existing.debug = Boolean(options.debug);
|
||||
installProcessEnv(options.proxyUrl);
|
||||
return () => uninstallHeadroomTransport();
|
||||
}
|
||||
|
||||
const state: TransportState = {
|
||||
refs: 1,
|
||||
proxyUrl: options.proxyUrl,
|
||||
debug: Boolean(options.debug),
|
||||
originalFetch: globalThis.fetch,
|
||||
originalHttpRequest: http.request,
|
||||
originalHttpGet: http.get,
|
||||
originalHttpsRequest: https.request,
|
||||
originalHttpsGet: https.get,
|
||||
originalHttp2Connect: http2.connect,
|
||||
originalChildSpawn: childProcess.spawn,
|
||||
originalChildExec: childProcess.exec,
|
||||
originalChildExecFile: childProcess.execFile,
|
||||
originalChildFork: childProcess.fork,
|
||||
};
|
||||
|
||||
setState(state);
|
||||
installProcessEnv(options.proxyUrl);
|
||||
globalThis.fetch = async (...args: FetchArgs) => {
|
||||
const current = getState();
|
||||
if (!current) {
|
||||
return state.originalFetch(...args);
|
||||
}
|
||||
const proxy = normalizeProxyUrl(current.proxyUrl);
|
||||
const [nextInput, nextInit] = withRoutedFetchInput(args[0], args[1], proxy);
|
||||
return state.originalFetch(nextInput, nextInit);
|
||||
};
|
||||
|
||||
http.request = wrapRequest(state.originalHttpRequest, state.originalHttpsRequest, state.originalHttpRequest) as HttpRequest;
|
||||
https.request = wrapRequest(state.originalHttpRequest, state.originalHttpsRequest, state.originalHttpsRequest) as HttpsRequest;
|
||||
http.get = wrapGet(http.request) as HttpGet;
|
||||
https.get = wrapGet(https.request) as HttpsGet;
|
||||
http2.connect = wrapHttp2Connect(state.originalHttp2Connect);
|
||||
childProcess.spawn = wrapSpawn(state.originalChildSpawn);
|
||||
childProcess.exec = wrapExec(state.originalChildExec);
|
||||
childProcess.execFile = wrapExecFile(state.originalChildExecFile);
|
||||
childProcess.fork = wrapFork(state.originalChildFork);
|
||||
syncBuiltinESMExports();
|
||||
|
||||
return () => uninstallHeadroomTransport();
|
||||
}
|
||||
|
||||
export function uninstallHeadroomTransport(): void {
|
||||
const state = getState();
|
||||
if (!state) {
|
||||
return;
|
||||
}
|
||||
|
||||
state.refs -= 1;
|
||||
if (state.refs > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
globalThis.fetch = state.originalFetch;
|
||||
http.request = state.originalHttpRequest;
|
||||
http.get = state.originalHttpGet;
|
||||
https.request = state.originalHttpsRequest;
|
||||
https.get = state.originalHttpsGet;
|
||||
http2.connect = state.originalHttp2Connect;
|
||||
childProcess.spawn = state.originalChildSpawn;
|
||||
childProcess.exec = state.originalChildExec;
|
||||
childProcess.execFile = state.originalChildExecFile;
|
||||
childProcess.fork = state.originalChildFork;
|
||||
syncBuiltinESMExports();
|
||||
setState(undefined);
|
||||
}
|
||||
19
plugins/opencode/tsconfig.json
Normal file
19
plugins/opencode/tsconfig.json
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"types": ["node"],
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"declaration": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"sourceMap": true,
|
||||
"isolatedModules": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "dist", "test"]
|
||||
}
|
||||
10
plugins/opencode/tsup.config.ts
Normal file
10
plugins/opencode/tsup.config.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { defineConfig } from "tsup";
|
||||
|
||||
export default defineConfig({
|
||||
entry: { index: "src/index.ts" },
|
||||
format: ["esm"],
|
||||
dts: true,
|
||||
sourcemap: true,
|
||||
clean: true,
|
||||
external: ["headroom-ai"],
|
||||
});
|
||||
8
plugins/opencode/vitest.config.ts
Normal file
8
plugins/opencode/vitest.config.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "node",
|
||||
globals: true,
|
||||
},
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue