Harden OpenClaw plugin proxy routing (#1074)

## Description

Hardens the bundled OpenClaw plugin so configured proxy routing is
fail-closed and `autoStart` is opt-in.

Closes: N/A

This follow-up is intentionally separate from the ContentRouter cache
fix because it changes plugin/gateway behavior rather than core
compression routing.

The plugin should not mutate upstream provider routing unless a
configured proxy URL is reachable and looks like Headroom. It should
also avoid unhandled startup promise rejections when proxy startup is
fire-and-forget.

Why this shape:

- `autoStart: false` by default matches deployments where Headroom is
supervised externally, for example by systemd. The plugin should not
silently start or assume ownership of a proxy unless the operator opted
in.
- Provider routing is fail-closed: a configured URL must first respond
like Headroom, not merely expose a generic liveness endpoint. This
prevents accidentally routing model traffic through the wrong local
service.
- `/readyz` is treated as liveness, not identity. Identity comes from
Headroom-shaped stats endpoints (`/v1/retrieve/stats` or `/stats`)
because those are harder for unrelated services to satisfy by accident.
- Startup remains asynchronous, but errors are captured and exposed
instead of becoming unhandled promise rejections.
- This is a separate PR because the core cache fix is about compression
correctness, while this patch is about integration safety around
OpenClaw gateway routing.

## Type of Change

- [x] Bug fix (non-breaking change fixes issue)
- [ ] New feature (non-breaking change adds functionality)
- [ ] Breaking change (fix or feature would cause existing functionality
change)
- [ ] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)

## Changes Made

- Make proxy `autoStart` opt-in (`default: false`).
- Probe configured `proxyUrl` before applying provider routing.
- Treat `/readyz` as liveness only; require Headroom-shaped
`/v1/retrieve/stats` or `/stats` for identity.
- Observe fire-and-forget startup promise rejection and expose startup
error for callers.
- Isolate proxy-ready listener failures.
- Keep provider routing deferred when no active/probed Headroom proxy
exists.
- Register retrieve tool with explicit `headroom_retrieve` name.
- Extend plugin/unit tests for configured proxy failures, generic
non-Headroom endpoints, path collisions, and routing behavior.

Changed files:

- `plugins/openclaw/README.md`
- `plugins/openclaw/openclaw.plugin.json`
- `plugins/openclaw/src/engine.ts`
- `plugins/openclaw/src/plugin/index.ts`
- `plugins/openclaw/src/proxy-manager.ts`
- `plugins/openclaw/test/engine.test.ts`
- `plugins/openclaw/test/gateway-config.test.ts`
- `plugins/openclaw/test/plugin-runtime-routing.test.ts`
- `plugins/openclaw/test/proxy-manager.test.ts`

## Testing

- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`)
- [x] Type checking passes (`mypy headroom`)
- [x] New tests added new functionality
- [x] Manual testing performed

### Test Output

```text
$ npm test

Test Files  6 passed (6)
Tests  74 passed (74)

$ npm run typecheck
tsc --noEmit

$ npm run build
tsup && node prepare-dist.mjs
Build success
```

## Real Behavior Proof

- Environment: local OpenClaw plugin package in the Headroom repo.
- Exact command / steps:
  - Run plugin test suite.
  - Run TypeScript typecheck.
  - Run plugin build.
- Observed result:
  - Tests passed: `74/74`.
  - Typecheck passed.
  - Build passed.
- Not tested:
- Full OpenClaw Gateway integration as part of this standalone PR prep.

## Review Readiness

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

## Checklist

- [x] My code follows project's style guidelines
- [x] I performed self-review my code
- [ ] I commented my code, particularly in hard-to-understand areas
- [x] I made corresponding changes documentation
- [x] My changes generate no new warnings
- [x] I added tests prove fix is effective or feature works
- [x] New and existing unit tests pass locally my changes
- [ ] I updated CHANGELOG.md if applicable

## Screenshots (if applicable)

N/A.

## Additional Notes

Checklist items left unchecked intentionally:

- No CHANGELOG update included.
- No extra comments were needed beyond existing code structure.

Co-authored-by: Björn-Christian Bönkost <bjoern@v2202603344248440850.hotsrv.de>
This commit is contained in:
felixboenkost-droid 2026-06-23 05:52:59 +02:00 committed by GitHub
parent 723b80c091
commit 6d116b15f1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 690 additions and 64 deletions

View file

@ -77,11 +77,12 @@ Install automatically selects the `contextEngine` slot for `headroom` on current
- `http://127.0.0.1:<proxyPort>`
- `http://localhost:<proxyPort>`
Default `proxyPort` is `8787`.
Default `proxyPort` is `8787`. Auto-start is opt-in; in production, prefer an externally
managed proxy such as systemd with `proxyUrl` set and `autoStart: false`.
### Upstream gateway routing
By default, the plugin also rewrites the built-in `openai-codex` provider base URL to the active Headroom proxy at runtime. That means Codex provider traffic flows through Headroom, so `/stats` can observe real upstream request and cache activity instead of only local context compression.
By default, the plugin also rewrites the built-in `openai-codex` provider base URL to a verified active Headroom proxy at runtime. That means Codex provider traffic flows through Headroom, so `/stats` can observe real upstream request and cache activity instead of only local context compression.
This does not replace Headroom's existing Codex routing rules. The proxy already decides between `api.openai.com` and `chatgpt.com/backend-api/codex/responses` based on ChatGPT auth. The plugin change only points OpenClaw's provider config at the active proxy in memory and preserves the rest of the provider config.
@ -199,10 +200,10 @@ Compression is lossless via CCR (Compress-Cache-Retrieve): originals are stored
| Option | Default | Description |
|--------|---------|-------------|
| `proxyUrl` | auto-detected | Optional URL of a Headroom proxy. Local addresses (`http://127.0.0.1:<port>`, `http://localhost:<port>`) enable auto-start; remote URLs (`https://headroom.example.com`) are connect-only. |
| `proxyPort` | `8787` | Port used for default auto-detect/auto-start when `proxyUrl` is not set. |
| `proxyUrl` | auto-detected | Optional URL of a Headroom proxy. Configured URLs are probe-gated before provider routing. Remote URLs (`https://headroom.example.com`) are connect-only. |
| `proxyPort` | `8787` | Port used for default auto-detect and optional local auto-start when `proxyUrl` is not set. |
| `pythonPath` | auto-detected | Optional Python executable override for Python fallback launcher. |
| `autoStart` | `true` | Auto-start a local `headroom proxy` if not already running (local URLs only; ignored for remote proxies) |
| `autoStart` | `false` | Opt-in auto-start for a local `headroom proxy` if not already running (local URLs only; ignored for remote proxies). Keep `false` when systemd owns the proxy. |
| `startupTimeoutMs` | `20000` | Time to wait for auto-started proxy to become healthy |
| `routeCodexViaProxy` | `true` | Rewrite OpenClaw's built-in `openai-codex` provider to use the active Headroom proxy in memory so upstream Codex requests pass through Headroom. |
| `gatewayProviderIds` | `[]` | Optional explicit list of OpenClaw provider ids to route through the active Headroom proxy in memory. Friendly aliases `codex`, `claude`, `copilot`, and `gemini` are also accepted. When set, this overrides the default `openai-codex` routing list. |

View file

@ -4,7 +4,7 @@
"uiHints": {
"proxyUrl": {
"label": "Proxy URL",
"help": "Optional. URL of a Headroom proxy (example: http://127.0.0.1:8787 or https://headroom.example.com). If omitted, plugin auto-detects on localhost. Auto-start only works for local addresses."
"help": "Optional. URL Headroom proxy (example: http://127.0.0.1:8787 or https://headroom.example.com). Configured URLs probe-gated before provider routing. Auto-start opt-in only works local addresses."
},
"proxyPort": {
"label": "Proxy Port",
@ -53,7 +53,7 @@
},
"autoStart": {
"type": "boolean",
"default": true
"default": false
},
"startupTimeoutMs": {
"type": "integer",

View file

@ -29,6 +29,7 @@ export class HeadroomContextEngine {
private logger: ProxyManagerLogger;
private proxyReadyListeners = new Set<(proxyUrl: string) => void | Promise<void>>();
private proxyStartupPromise: Promise<string> | null = null;
private proxyStartupError: unknown = null;
private stats = {
totalCompressions: 0,
totalTokensSaved: 0,
@ -235,26 +236,38 @@ export class HeadroomContextEngine {
return this.proxyUrl;
}
getProxyStartupError(): unknown {
return this.proxyStartupError;
}
ensureProxyStarted(): void {
if (this.config.enabled === false || this.proxyUrl || this.proxyStartupPromise) {
return;
}
this.proxyStartupError = null;
this.proxyStartupPromise = this.proxyManager
.start()
.then(async (proxyUrl) => {
this.proxyUrl = proxyUrl;
this.proxyStartupError = null;
await this.notifyProxyReady(proxyUrl);
this.logger.info(`Headroom proxy ready at ${proxyUrl}`);
return proxyUrl;
})
.catch((error) => {
this.proxyStartupError = error;
this.logger.warn(`Headroom proxy unavailable: ${error}`);
throw error;
})
.finally(() => {
this.proxyStartupPromise = null;
});
// Fire-and-forget lifecycle callers intentionally do not await this promise.
// Keep the promise rejectable for ensureProxyUrl(), but mark it observed so
// a missing proxy cannot become a process-level unhandled rejection.
void this.proxyStartupPromise.catch(() => {});
}
onProxyReady(listener: (proxyUrl: string) => void | Promise<void>): () => void {
@ -278,7 +291,11 @@ export class HeadroomContextEngine {
private async notifyProxyReady(proxyUrl: string): Promise<void> {
for (const listener of this.proxyReadyListeners) {
await listener(proxyUrl);
try {
await listener(proxyUrl);
} catch (error) {
this.logger.warn(`Headroom proxy ready listener failed: ${error}`);
}
}
}
}

View file

@ -20,7 +20,7 @@ import {
applyGatewayProviderBaseUrlsInPlace,
resolveGatewayProviderIds,
} from "../gateway-config.js";
import { normalizeAndValidateProxyUrl } from "../proxy-manager.js";
import { normalizeAndValidateProxyUrl, probeHeadroomProxy } from "../proxy-manager.js";
import { createHeadroomRetrieveTool } from "../tools/headroom-retrieve.js";
/**
@ -48,6 +48,8 @@ function headroomPlugin(api: any) {
debug: (m: string) => logger.debug?.(m),
});
const gatewayProviderIds = resolveGatewayProviderIds(config);
let validatedConfiguredProxyUrl: string | null = null;
let configuredProxyProbePromise: Promise<string | null> | null = null;
const applyGatewayRouting = async (activeProxyUrl: string) => {
if (gatewayProviderIds.length === 0) {
@ -71,11 +73,46 @@ function headroomPlugin(api: any) {
}
};
const getConfiguredRoutingProxyUrl = async (): Promise<string | null> => {
if (!proxyUrl) {
return null;
}
if (validatedConfiguredProxyUrl === proxyUrl) {
return validatedConfiguredProxyUrl;
}
if (!configuredProxyProbePromise) {
configuredProxyProbePromise = probeHeadroomProxy(proxyUrl)
.then((probe) => {
if (probe.reachable && probe.isHeadroom) {
validatedConfiguredProxyUrl = proxyUrl;
return proxyUrl;
}
logger.warn(
`[headroom] Skipping upstream gateway routing: configured proxyUrl is not a ready Headroom proxy at ${proxyUrl}` +
(probe.reason ? ` (${probe.reason})` : ""),
);
return null;
})
.catch((error) => {
logger.warn(
`[headroom] Skipping upstream gateway routing: failed to probe configured proxyUrl ${proxyUrl}: ${error}`,
);
return null;
})
.finally(() => {
configuredProxyProbePromise = null;
});
}
return configuredProxyProbePromise;
};
const ensureGatewayRouting = async () => {
const activeProxyUrl = engine.getProxyUrl();
if (gatewayProviderIds.length === 0) {
return;
}
const activeProxyUrl = engine.getProxyUrl() ?? (await getConfiguredRoutingProxyUrl());
if (!activeProxyUrl) {
logger.debug?.("[headroom] Deferring upstream gateway routing until proxy is available");
engine.ensureProxyStarted();
return;
}
await applyGatewayRouting(activeProxyUrl);
@ -93,7 +130,7 @@ function headroomPlugin(api: any) {
const activeProxyUrl = engine.getProxyUrl() ?? proxyUrl;
if (!activeProxyUrl) return null;
return createHeadroomRetrieveTool({ proxyUrl: activeProxyUrl });
});
}, { names: ["headroom_retrieve"] });
api.on("gateway_start", async () => {
await ensureGatewayRouting();

View file

@ -110,7 +110,7 @@ export class ProxyManager {
}
// Auto-start is only available for local proxies
if (this.config.autoStart !== false) {
if (this.config.autoStart === true) {
const startupUrl = explicitUrl ?? defaultCandidates[0];
const startupProbe = probeByUrl.get(startupUrl);
if (startupProbe?.reachable && !startupProbe.isHeadroom) {
@ -431,36 +431,71 @@ function withDefaultPort(proxyUrl: string, defaultPort: number): string {
*/
export async function probeHeadroomProxy(proxyUrl: string): Promise<ProxyProbeResult> {
const origin = normalizeAndValidateProxyUrl(proxyUrl);
try {
const health = await fetch(`${origin}/health`, {
signal: AbortSignal.timeout(3_000),
});
if (!health.ok) {
return { reachable: false, isHeadroom: false, reason: `health HTTP ${health.status}` };
const probeEndpoint = async (
path: string,
options: { readBody?: boolean } = {},
): Promise<{ reachable: boolean; ok: boolean; status?: number; body?: string }> => {
try {
const response = await fetch(`${origin}${path}`, {
signal: AbortSignal.timeout(3_000),
});
const body =
response.ok && options.readBody
? await response.text().catch(() => undefined)
: undefined;
return { reachable: true, ok: response.ok, status: response.status, body };
} catch {
return { reachable: false, ok: false };
}
} catch {
return { reachable: false, isHeadroom: false, reason: "health check failed" };
};
const ready = await probeEndpoint("/readyz");
const retrieveStats = await probeEndpoint("/v1/retrieve/stats", { readBody: true });
if (retrieveStats.ok && hasHeadroomStatsShape(retrieveStats.body)) {
return { reachable: true, isHeadroom: true };
}
const stats = await probeEndpoint("/stats", { readBody: true });
if (stats.ok && hasHeadroomStatsShape(stats.body)) {
return { reachable: true, isHeadroom: true };
}
const health = await probeEndpoint("/health");
const anyReachable = ready.reachable || retrieveStats.reachable || stats.reachable || health.reachable;
if (!anyReachable) {
return { reachable: false, isHeadroom: false, reason: "proxy probe failed" };
}
const reasons = [
ready.reachable ? `readyz HTTP ${ready.status}` : "readyz unavailable",
retrieveStats.reachable
? `retrieve stats HTTP ${retrieveStats.status}`
: "retrieve stats endpoint unavailable",
stats.reachable ? `stats HTTP ${stats.status}` : "stats endpoint unavailable",
health.reachable ? `health HTTP ${health.status}` : "health check failed",
];
return { reachable: true, isHeadroom: false, reason: reasons.join("; ") };
}
function hasHeadroomStatsShape(body: string | undefined): boolean {
if (!body) {
return false;
}
try {
const retrieveStats = await fetch(`${origin}/v1/retrieve/stats`, {
signal: AbortSignal.timeout(3_000),
});
if (retrieveStats.ok) {
return { reachable: true, isHeadroom: true };
}
return {
reachable: true,
isHeadroom: false,
reason: `retrieve stats HTTP ${retrieveStats.status}`,
};
const parsed = JSON.parse(body) as Record<string, unknown>;
return (
parsed !== null &&
typeof parsed === "object" &&
(Object.hasOwn(parsed, "proxy_inbound") ||
Object.hasOwn(parsed, "api_requests") ||
Object.hasOwn(parsed, "provider_tokens") ||
Object.hasOwn(parsed, "proxy_compression_saved") ||
Object.hasOwn(parsed, "store") ||
Object.hasOwn(parsed, "recent_retrievals"))
);
} catch {
return {
reachable: true,
isHeadroom: false,
reason: "retrieve stats endpoint unavailable",
};
return false;
}
}

View file

@ -83,6 +83,104 @@ describe("HeadroomContextEngine proxy startup helpers", () => {
expect(mocked.start).not.toHaveBeenCalled();
});
it("does not emit an unhandledRejection when fire-and-forget startup fails", async () => {
mocked.start.mockReset();
mocked.start.mockRejectedValue(new Error("proxy boom"));
const engine = new HeadroomContextEngine();
const unhandled: unknown[] = [];
const onUnhandled = (reason: unknown) => unhandled.push(reason);
process.on("unhandledRejection", onUnhandled);
try {
// Fire-and-forget: caller intentionally does not await.
engine.ensureProxyStarted();
// Let the startup promise settle and any microtasks/macrotasks flush.
await new Promise((resolve) => setTimeout(resolve, 0));
expect(unhandled).toEqual([]);
expect(mocked.logger.warn).toHaveBeenCalledWith(
expect.stringContaining("Headroom proxy unavailable"),
);
} finally {
process.off("unhandledRejection", onUnhandled);
}
});
it("stores the startup failure in getProxyStartupError()", async () => {
const failure = new Error("proxy boom");
mocked.start.mockReset();
mocked.start.mockRejectedValue(failure);
const engine = new HeadroomContextEngine();
expect(engine.getProxyStartupError()).toBeNull();
engine.ensureProxyStarted();
await new Promise((resolve) => setTimeout(resolve, 0));
expect(engine.getProxyStartupError()).toBe(failure);
});
it("allows retrying startup after a failure", async () => {
mocked.start.mockReset();
mocked.start
.mockRejectedValueOnce(new Error("proxy boom"))
.mockResolvedValueOnce("http://127.0.0.1:8787");
const engine = new HeadroomContextEngine();
engine.ensureProxyStarted();
await new Promise((resolve) => setTimeout(resolve, 0));
expect(engine.getProxyStartupError()).toBeInstanceOf(Error);
// A second attempt is possible once the failed promise has cleared.
const url = await engine.ensureProxyUrl();
expect(url).toBe("http://127.0.0.1:8787");
expect(engine.getProxyStartupError()).toBeNull();
expect(mocked.start).toHaveBeenCalledTimes(2);
});
it("ensureProxyUrl rejects cleanly on startup failure without unhandledRejection", async () => {
const failure = new Error("proxy boom");
mocked.start.mockReset();
mocked.start.mockRejectedValue(failure);
const engine = new HeadroomContextEngine();
const unhandled: unknown[] = [];
const onUnhandled = (reason: unknown) => unhandled.push(reason);
process.on("unhandledRejection", onUnhandled);
try {
await expect(engine.ensureProxyUrl()).rejects.toBe(failure);
await new Promise((resolve) => setTimeout(resolve, 0));
expect(unhandled).toEqual([]);
} finally {
process.off("unhandledRejection", onUnhandled);
}
});
it("isolates and logs proxy-ready listener rejections", async () => {
const engine = new HeadroomContextEngine();
const failing = vi.fn(async () => {
throw new Error("listener boom");
});
const healthy = vi.fn();
engine.onProxyReady(failing);
engine.onProxyReady(healthy);
engine.ensureProxyStarted();
// ensureProxyUrl must still resolve despite the listener throwing.
await expect(engine.ensureProxyUrl()).resolves.toBe("http://127.0.0.1:8787");
expect(failing).toHaveBeenCalled();
expect(healthy).toHaveBeenCalledWith("http://127.0.0.1:8787");
expect(mocked.logger.warn).toHaveBeenCalledWith(
expect.stringContaining("Headroom proxy ready listener failed"),
);
expect(engine.getProxyStartupError()).toBeNull();
});
it("schedules startup and returns original messages when assembling before proxy readiness", async () => {
const engine = new HeadroomContextEngine();
const messages = [{ role: "user", content: "hello" }];

View file

@ -228,6 +228,65 @@ describe("applyGatewayProviderBaseUrls", () => {
expect(result.changed).toBe(false);
expect((result.config as any).models?.providers?.["github-copilot"]).toBeUndefined();
});
it("documents the Gate-D risk: anthropic without an explicit baseUrl routes to the bare proxy origin", () => {
const result = applyGatewayProviderBaseUrls({}, "http://127.0.0.1:8787", ["anthropic"]);
expect(result.changed).toBe(true);
expect((result.config as any).models.providers.anthropic).toEqual({
baseUrl: "http://127.0.0.1:8787",
models: [],
});
});
it("documents the multi-provider risk: providers sharing /v1 collapse to the same proxy path", () => {
const result = applyGatewayProviderBaseUrls(
{
models: {
providers: {
openai: {
baseUrl: "https://api.openai.com/v1",
},
"github-copilot": {
baseUrl: "https://api.githubcopilot.com/v1",
},
},
},
},
"http://127.0.0.1:8787",
["openai", "github-copilot"],
);
expect(result.changed).toBe(true);
expect((result.config as any).models.providers.openai.baseUrl).toBe(
"http://127.0.0.1:8787/v1",
);
expect((result.config as any).models.providers["github-copilot"].baseUrl).toBe(
"http://127.0.0.1:8787/v1",
);
});
it("re-points an already routed provider to a new proxy origin without duplicating paths", () => {
const result = applyGatewayProviderBaseUrls(
{
models: {
providers: {
"openai-codex": {
baseUrl: "http://127.0.0.1:8787/backend-api",
},
},
},
},
"http://localhost:8787",
["openai-codex"],
);
expect(result.changed).toBe(true);
expect((result.config as any).models.providers["openai-codex"]).toEqual({
baseUrl: "http://localhost:8787/backend-api",
models: [],
});
});
});
describe("applyGatewayProviderBaseUrlsInPlace", () => {

View file

@ -28,14 +28,48 @@ vi.mock("../src/tools/headroom-retrieve.js", () => ({
import headroomPlugin from "../src/plugin/index.js";
afterEach(() => {
vi.restoreAllMocks();
mocked.ensureProxyUrl.mockClear();
mocked.ensureProxyStarted.mockClear();
mocked.getProxyUrl.mockClear();
mocked.getProxyUrl.mockReset();
mocked.getProxyUrl.mockReturnValue(null);
mocked.createHeadroomRetrieveTool.mockClear();
proxyReadyListeners.length = 0;
});
describe("headroomPlugin runtime routing", () => {
function stubConfiguredProxyProbe(response: "headroom" | "non-headroom" | "down") {
if (response === "down") {
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("ECONNREFUSED")));
return;
}
vi.stubGlobal(
"fetch",
vi.fn((url: string) => {
if (url.endsWith("/readyz")) {
return Promise.resolve({ ok: true, status: 200, text: () => Promise.resolve("") });
}
if (url.endsWith("/v1/retrieve/stats")) {
return Promise.resolve({
ok: response === "headroom",
status: response === "headroom" ? 200 : 404,
text: () => Promise.resolve(""),
});
}
if (url.endsWith("/stats")) {
return Promise.resolve({
ok: response === "headroom",
status: response === "headroom" ? 200 : 200,
text: () =>
Promise.resolve(response === "headroom" ? JSON.stringify({ proxy_inbound: { total: 1 } }) : "{}"),
});
}
return Promise.resolve({ ok: false, status: 404, text: () => Promise.resolve("") });
}),
);
}
it("routes configured providers in memory once the proxy becomes available", async () => {
const gatewayHandlers = new Map<string, () => Promise<void>>();
const writeConfigFile = vi.fn();
@ -100,8 +134,10 @@ describe("headroomPlugin runtime routing", () => {
headroomPlugin(api);
await Promise.resolve();
// With no active or configured proxy URL, initial routing defers without
// auto-starting the proxy or mutating providers.
expect(mocked.ensureProxyUrl).not.toHaveBeenCalled();
expect(mocked.ensureProxyStarted).toHaveBeenCalledTimes(1);
expect(mocked.ensureProxyStarted).not.toHaveBeenCalled();
expect(writeConfigFile).not.toHaveBeenCalled();
expect(loadConfig).not.toHaveBeenCalled();
expect(api.config.models.providers["openai-codex"]).toBeUndefined();
@ -132,10 +168,218 @@ describe("headroomPlugin runtime routing", () => {
const gatewayStart = gatewayHandlers.get("gateway_start");
expect(gatewayStart).toBeTypeOf("function");
// getProxyUrl now reports the active proxy so gateway_start re-routes in
// memory without ever auto-starting or awaiting the proxy.
mocked.getProxyUrl.mockReturnValue("http://127.0.0.1:8787");
await gatewayStart?.();
expect(mocked.ensureProxyStarted).toHaveBeenCalledTimes(2);
expect(mocked.ensureProxyStarted).not.toHaveBeenCalled();
expect(mocked.ensureProxyUrl).not.toHaveBeenCalled();
expect(writeConfigFile).not.toHaveBeenCalled();
expect(loadConfig).not.toHaveBeenCalled();
});
it("does not auto-start on gateway_start when no proxy URL is available", async () => {
const gatewayHandlers = new Map<string, () => Promise<void>>();
const api: any = {
config: {
plugins: {
entries: {
headroom: {
config: { gatewayProviderIds: ["claude"] },
},
},
},
models: {
providers: {
anthropic: { api: "anthropic-messages", baseUrl: "https://api.anthropic.com" },
},
},
},
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
registerContextEngine: vi.fn(),
registerTool: vi.fn(),
on: vi.fn((event: string, handler: () => Promise<void>) => {
gatewayHandlers.set(event, handler);
}),
};
headroomPlugin(api);
await Promise.resolve();
await gatewayHandlers.get("gateway_start")?.();
expect(mocked.ensureProxyStarted).not.toHaveBeenCalled();
expect(mocked.ensureProxyUrl).not.toHaveBeenCalled();
expect(api.config.models.providers.anthropic).toEqual({
api: "anthropic-messages",
baseUrl: "https://api.anthropic.com",
});
});
it("routes configured proxyUrl only after it probes as Headroom", async () => {
const gatewayHandlers = new Map<string, () => Promise<void>>();
stubConfiguredProxyProbe("headroom");
const api: any = {
config: {
plugins: {
entries: {
headroom: {
config: {
proxyUrl: "http://127.0.0.1:8787",
gatewayProviderIds: ["claude"],
},
},
},
},
models: {
providers: {
anthropic: { api: "anthropic-messages", baseUrl: "https://api.anthropic.com" },
},
},
},
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
registerContextEngine: vi.fn(),
registerTool: vi.fn(),
on: vi.fn((event: string, handler: () => Promise<void>) => {
gatewayHandlers.set(event, handler);
}),
};
headroomPlugin(api);
await gatewayHandlers.get("gateway_start")?.();
// Configured proxyUrl is probe-gated before provider mutation.
expect(mocked.ensureProxyStarted).not.toHaveBeenCalled();
expect(mocked.ensureProxyUrl).not.toHaveBeenCalled();
expect(api.config.models.providers.anthropic).toEqual({
api: "anthropic-messages",
baseUrl: "http://127.0.0.1:8787",
models: [],
});
});
it("does not route configured proxyUrl when the proxy is unavailable", async () => {
const gatewayHandlers = new Map<string, () => Promise<void>>();
stubConfiguredProxyProbe("down");
const api: any = {
config: {
plugins: {
entries: {
headroom: {
config: {
proxyUrl: "http://127.0.0.1:8787",
gatewayProviderIds: ["claude"],
},
},
},
},
models: {
providers: {
anthropic: { api: "anthropic-messages", baseUrl: "https://api.anthropic.com" },
},
},
},
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
registerContextEngine: vi.fn(),
registerTool: vi.fn(),
on: vi.fn((event: string, handler: () => Promise<void>) => {
gatewayHandlers.set(event, handler);
}),
};
headroomPlugin(api);
await Promise.resolve();
await Promise.resolve();
await gatewayHandlers.get("gateway_start")?.();
expect(mocked.ensureProxyStarted).not.toHaveBeenCalled();
expect(mocked.ensureProxyUrl).not.toHaveBeenCalled();
expect(api.config.models.providers.anthropic).toEqual({
api: "anthropic-messages",
baseUrl: "https://api.anthropic.com",
});
expect(api.logger.warn).toHaveBeenCalledWith(
expect.stringContaining("Skipping upstream gateway routing"),
);
});
it("does not route configured proxyUrl when only generic liveness endpoints respond", async () => {
const gatewayHandlers = new Map<string, () => Promise<void>>();
stubConfiguredProxyProbe("non-headroom");
const api: any = {
config: {
plugins: {
entries: {
headroom: {
config: {
proxyUrl: "http://127.0.0.1:8787",
gatewayProviderIds: ["claude"],
},
},
},
},
models: {
providers: {
anthropic: { api: "anthropic-messages", baseUrl: "https://api.anthropic.com" },
},
},
},
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
registerContextEngine: vi.fn(),
registerTool: vi.fn(),
on: vi.fn((event: string, handler: () => Promise<void>) => {
gatewayHandlers.set(event, handler);
}),
};
headroomPlugin(api);
await gatewayHandlers.get("gateway_start")?.();
expect(api.config.models.providers.anthropic).toEqual({
api: "anthropic-messages",
baseUrl: "https://api.anthropic.com",
});
expect(api.logger.warn).toHaveBeenCalledWith(
expect.stringContaining("configured proxyUrl is not a ready Headroom proxy"),
);
});
it("documents that the retrieve tool can be created from configured proxyUrl before routing is validated", () => {
stubConfiguredProxyProbe("down");
const api: any = {
config: {
plugins: {
entries: {
headroom: {
config: {
proxyUrl: "http://127.0.0.1:8787",
gatewayProviderIds: ["codex"],
},
},
},
},
models: {
providers: {},
},
},
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
registerContextEngine: vi.fn(),
registerTool: vi.fn(),
on: vi.fn(),
};
headroomPlugin(api);
const [toolFactory] = api.registerTool.mock.calls[0];
const tool = toolFactory({});
expect(tool).toEqual({ proxyUrl: "http://127.0.0.1:8787" });
expect(mocked.createHeadroomRetrieveTool).toHaveBeenCalledWith({
proxyUrl: "http://127.0.0.1:8787",
});
});
});

View file

@ -6,23 +6,29 @@ import {
probeHeadroomProxy,
} from "../src/proxy-manager.js";
const retrieveStatsBody = JSON.stringify({ store: { entry_count: 0 }, recent_retrievals: [] });
const proxyStatsBody = JSON.stringify({ proxy_inbound: { total: 1 } });
afterEach(() => {
vi.restoreAllMocks();
});
/** Stub fetch with a sequence of health/retrieve probe outcomes. */
function stubProbeSuccess() {
const mock = vi.fn()
.mockResolvedValueOnce({ ok: true, status: 200 }) // /health
.mockResolvedValueOnce({ ok: true, status: 200 }); // /v1/retrieve/stats
const mock = vi
.fn()
.mockResolvedValueOnce({ ok: false, status: 404 }) // /readyz
.mockResolvedValueOnce({
ok: true,
status: 200,
text: () => Promise.resolve(retrieveStatsBody),
}); // /v1/retrieve/stats
vi.stubGlobal("fetch", mock);
return mock;
}
function stubProbeNonHeadroom() {
const mock = vi.fn()
.mockResolvedValueOnce({ ok: true, status: 200 }) // /health OK
.mockResolvedValueOnce({ ok: false, status: 404 }); // /v1/retrieve/stats 404
// Every endpoint reachable but non-OK => reachable, non-Headroom (occupied port).
const mock = vi.fn().mockResolvedValue({ ok: false, status: 404 });
vi.stubGlobal("fetch", mock);
return mock;
}
@ -72,13 +78,114 @@ describe("isLocalProxyUrl", () => {
});
describe("probeHeadroomProxy", () => {
it("returns reachable+isHeadroom when both endpoints succeed", async () => {
stubProbeSuccess();
/**
* Resolve fetch outcomes by request path so tests express the new probe order
* (/readyz, /v1/retrieve/stats, /stats, /health) without depending on call
* sequencing. Unlisted paths reject (treated as unreachable).
*/
function stubByPath(byPath: Record<string, { ok: boolean; status: number; body?: string }>) {
const mock = vi.fn((url: string) => {
for (const [path, response] of Object.entries(byPath)) {
if (url.endsWith(path)) {
return Promise.resolve({
ok: response.ok,
status: response.status,
text: () => Promise.resolve(response.body ?? ""),
});
}
}
return Promise.reject(new Error("ECONNREFUSED"));
});
vi.stubGlobal("fetch", mock);
return mock;
}
it("does not treat /readyz success alone as Headroom identity", async () => {
stubByPath({
"/readyz": { ok: true, status: 200 },
"/v1/retrieve/stats": { ok: false, status: 404 },
"/stats": { ok: false, status: 404 },
"/health": { ok: false, status: 404 },
});
const result = await probeHeadroomProxy("http://127.0.0.1:8787");
expect(result.reachable).toBe(true);
expect(result.isHeadroom).toBe(false);
});
it("treats Headroom-shaped /v1/retrieve/stats 200 as Headroom even when /readyz is OK and /health is 503", async () => {
stubByPath({
"/health": { ok: false, status: 503 },
"/readyz": { ok: true, status: 200 },
"/v1/retrieve/stats": { ok: true, status: 200, body: retrieveStatsBody },
});
const result = await probeHeadroomProxy("http://127.0.0.1:8787");
expect(result).toEqual({ reachable: true, isHeadroom: true });
});
it("returns reachable but non-headroom when retrieve endpoint fails", async () => {
it("treats Headroom-shaped /v1/retrieve/stats 200 as Headroom even when /health is 503", async () => {
stubByPath({
"/health": { ok: false, status: 503 },
"/readyz": { ok: false, status: 404 },
"/v1/retrieve/stats": { ok: true, status: 200, body: retrieveStatsBody },
});
const result = await probeHeadroomProxy("http://127.0.0.1:8787");
expect(result).toEqual({ reachable: true, isHeadroom: true });
});
it("does not treat generic /v1/retrieve/stats 200 as Headroom identity", async () => {
stubByPath({
"/readyz": { ok: true, status: 200 },
"/v1/retrieve/stats": { ok: true, status: 200, body: JSON.stringify({ ok: true }) },
"/stats": { ok: false, status: 404 },
"/health": { ok: true, status: 200 },
});
const result = await probeHeadroomProxy("http://127.0.0.1:8787");
expect(result.reachable).toBe(true);
expect(result.isHeadroom).toBe(false);
});
it("falls through from auth-gated /v1/retrieve/stats to Headroom-shaped /stats", async () => {
stubByPath({
"/readyz": { ok: true, status: 200 },
"/v1/retrieve/stats": { ok: false, status: 403 },
"/stats": { ok: true, status: 200, body: proxyStatsBody },
});
const result = await probeHeadroomProxy("http://127.0.0.1:8787");
expect(result).toEqual({ reachable: true, isHeadroom: true });
});
it("continues probing when one endpoint is unreachable", async () => {
stubByPath({
"/readyz": { ok: true, status: 200 },
// /v1/retrieve/stats rejects because it is not listed.
"/stats": { ok: true, status: 200, body: proxyStatsBody },
});
const result = await probeHeadroomProxy("http://127.0.0.1:8787");
expect(result).toEqual({ reachable: true, isHeadroom: true });
});
it("falls back to /stats only when the response has a Headroom stats shape", async () => {
stubByPath({
// /readyz and /v1/retrieve/stats unavailable (reject), /stats answers.
"/stats": { ok: true, status: 200, body: proxyStatsBody },
});
const result = await probeHeadroomProxy("http://127.0.0.1:8787");
expect(result).toEqual({ reachable: true, isHeadroom: true });
});
it("does not treat generic /stats 200 as Headroom identity", async () => {
stubByPath({
"/readyz": { ok: false, status: 404 },
"/v1/retrieve/stats": { ok: false, status: 404 },
"/stats": { ok: true, status: 200, body: JSON.stringify({ uptime: 123 }) },
"/health": { ok: true, status: 200 },
});
const result = await probeHeadroomProxy("http://127.0.0.1:8787");
expect(result.reachable).toBe(true);
expect(result.isHeadroom).toBe(false);
});
it("returns reachable but non-headroom when identity endpoints are non-OK", async () => {
stubProbeNonHeadroom();
const result = await probeHeadroomProxy("http://127.0.0.1:8787");
expect(result.reachable).toBe(true);
@ -86,7 +193,7 @@ describe("probeHeadroomProxy", () => {
expect(result.reason).toMatch(/retrieve stats HTTP 404/);
});
it("returns unreachable when health check fails", async () => {
it("returns unreachable when no endpoint responds", async () => {
stubProbeUnreachable();
const result = await probeHeadroomProxy("http://127.0.0.1:8787");
expect(result.reachable).toBe(false);
@ -98,12 +205,20 @@ describe("ProxyManager.start", () => {
it("auto-detects running proxy on default candidates", async () => {
const manager = new ProxyManager({});
// Candidate 1: health fail. Candidate 2: health+retrieve succeed.
// Candidate 1 (127.0.0.1): all four probes fail.
// Candidate 2 (localhost): /v1/retrieve/stats succeeds.
const fetchMock = vi
.fn()
.mockRejectedValueOnce(new Error("down"))
.mockResolvedValueOnce({ ok: true, status: 200 })
.mockResolvedValueOnce({ ok: true, status: 200 });
.mockRejectedValueOnce(new Error("down")) // 127.0.0.1 /readyz
.mockRejectedValueOnce(new Error("down")) // 127.0.0.1 /v1/retrieve/stats
.mockRejectedValueOnce(new Error("down")) // 127.0.0.1 /stats
.mockRejectedValueOnce(new Error("down")) // 127.0.0.1 /health
.mockResolvedValueOnce({ ok: true, status: 200 }) // localhost /readyz
.mockResolvedValueOnce({
ok: true,
status: 200,
text: () => Promise.resolve(retrieveStatsBody),
}); // localhost /v1/retrieve/stats
vi.stubGlobal("fetch", fetchMock);
const startSpy = vi.spyOn(manager as any, "startHeadroomProxy");
@ -136,11 +251,20 @@ describe("ProxyManager.start", () => {
const manager = new ProxyManager({ proxyUrl: "http://127.0.0.1", autoStart: true });
const startSpy = vi.spyOn(manager as any, "startHeadroomProxy").mockResolvedValue(undefined);
// Initial probe of the single candidate fails on all four endpoints, then
// after auto-start the identity probe succeeds on /v1/retrieve/stats.
const fetchMock = vi
.fn()
.mockRejectedValueOnce(new Error("down"))
.mockResolvedValueOnce({ ok: true, status: 200 })
.mockResolvedValueOnce({ ok: true, status: 200 });
.mockRejectedValueOnce(new Error("down")) // /readyz
.mockRejectedValueOnce(new Error("down")) // /v1/retrieve/stats
.mockRejectedValueOnce(new Error("down")) // /stats
.mockRejectedValueOnce(new Error("down")) // /health
.mockResolvedValueOnce({ ok: true, status: 200 }) // post-start /readyz
.mockResolvedValueOnce({
ok: true,
status: 200,
text: () => Promise.resolve(retrieveStatsBody),
}); // post-start /v1/retrieve/stats
vi.stubGlobal("fetch", fetchMock);
const url = await manager.start();
@ -179,13 +303,24 @@ describe("ProxyManager.start", () => {
const manager = new ProxyManager({ autoStart: true });
const startSpy = vi.spyOn(manager as any, "startHeadroomProxy").mockResolvedValue(undefined);
// First two candidate probes fail (health only), then waitForHealthy probe succeeds.
// Both candidates fail all four probes, then the post-start identity probe
// succeeds on /v1/retrieve/stats.
const fetchMock = vi
.fn()
.mockRejectedValueOnce(new Error("down"))
.mockRejectedValueOnce(new Error("down"))
.mockResolvedValueOnce({ ok: true, status: 200 })
.mockResolvedValueOnce({ ok: true, status: 200 });
.mockRejectedValueOnce(new Error("down")) // 127.0.0.1 /readyz
.mockRejectedValueOnce(new Error("down")) // 127.0.0.1 /v1/retrieve/stats
.mockRejectedValueOnce(new Error("down")) // 127.0.0.1 /stats
.mockRejectedValueOnce(new Error("down")) // 127.0.0.1 /health
.mockRejectedValueOnce(new Error("down")) // localhost /readyz
.mockRejectedValueOnce(new Error("down")) // localhost /v1/retrieve/stats
.mockRejectedValueOnce(new Error("down")) // localhost /stats
.mockRejectedValueOnce(new Error("down")) // localhost /health
.mockResolvedValueOnce({ ok: true, status: 200 }) // post-start /readyz
.mockResolvedValueOnce({
ok: true,
status: 200,
text: () => Promise.resolve(retrieveStatsBody),
}); // post-start /v1/retrieve/stats
vi.stubGlobal("fetch", fetchMock);
const url = await manager.start();