mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-10 14:27:00 -04:00
## 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>
199 lines
6.3 KiB
TypeScript
199 lines
6.3 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
|
|
const mocked = vi.hoisted(() => ({
|
|
start: vi.fn(async () => "http://127.0.0.1:8787"),
|
|
stop: vi.fn(async () => undefined),
|
|
logger: {
|
|
debug: vi.fn(),
|
|
error: vi.fn(),
|
|
info: vi.fn(),
|
|
warn: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
vi.mock("headroom-ai", () => ({
|
|
compress: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("../src/proxy-manager.js", () => ({
|
|
ProxyManager: class {
|
|
start = mocked.start;
|
|
stop = mocked.stop;
|
|
},
|
|
defaultLogger: mocked.logger,
|
|
}));
|
|
|
|
import { HeadroomContextEngine } from "../src/engine.js";
|
|
|
|
afterEach(() => {
|
|
mocked.start.mockReset();
|
|
mocked.start.mockResolvedValue("http://127.0.0.1:8787");
|
|
mocked.stop.mockClear();
|
|
mocked.logger.debug.mockClear();
|
|
mocked.logger.error.mockClear();
|
|
mocked.logger.info.mockClear();
|
|
mocked.logger.warn.mockClear();
|
|
});
|
|
|
|
describe("HeadroomContextEngine proxy startup helpers", () => {
|
|
it("bootstraps by scheduling proxy startup when enabled", async () => {
|
|
const engine = new HeadroomContextEngine();
|
|
|
|
await expect(
|
|
engine.bootstrap({
|
|
sessionId: "session-1",
|
|
sessionFile: "session.jsonl",
|
|
}),
|
|
).resolves.toEqual({
|
|
bootstrapped: true,
|
|
reason: "proxy startup scheduled",
|
|
});
|
|
expect(mocked.start).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("removes unsubscribed proxy listeners before notifying readiness", async () => {
|
|
const engine = new HeadroomContextEngine();
|
|
const first = vi.fn();
|
|
const second = vi.fn();
|
|
|
|
const unsubscribeFirst = engine.onProxyReady(first);
|
|
engine.onProxyReady(second);
|
|
unsubscribeFirst();
|
|
|
|
engine.ensureProxyStarted();
|
|
await engine.ensureProxyUrl();
|
|
|
|
expect(first).not.toHaveBeenCalled();
|
|
expect(second).toHaveBeenCalledWith("http://127.0.0.1:8787");
|
|
});
|
|
|
|
it("returns the existing proxy URL without starting again", async () => {
|
|
const engine = new HeadroomContextEngine();
|
|
|
|
(engine as { proxyUrl: string | null }).proxyUrl = "http://127.0.0.1:8787";
|
|
|
|
await expect(engine.ensureProxyUrl()).resolves.toBe("http://127.0.0.1:8787");
|
|
expect(mocked.start).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("throws when proxy startup is disabled", async () => {
|
|
const engine = new HeadroomContextEngine({ enabled: false });
|
|
|
|
await expect(engine.ensureProxyUrl()).rejects.toThrow("Headroom proxy startup is disabled");
|
|
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" }];
|
|
|
|
await expect(
|
|
engine.assemble({
|
|
sessionId: "session-1",
|
|
messages,
|
|
}),
|
|
).resolves.toEqual({
|
|
messages,
|
|
estimatedTokens: 0,
|
|
});
|
|
expect(mocked.start).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|