headroom/plugins/openclaw/test/gateway-config.test.ts
felixboenkost-droid 6d116b15f1
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>
2026-06-22 22:52:59 -05:00

337 lines
9.3 KiB
TypeScript

import { describe, expect, it } from "vitest";
import {
applyGatewayProviderBaseUrls,
applyGatewayProviderBaseUrlsInPlace,
resolveGatewayProviderIds,
} from "../src/gateway-config.js";
describe("resolveGatewayProviderIds", () => {
it("routes openai-codex by default", () => {
expect(resolveGatewayProviderIds(undefined)).toEqual(["openai-codex"]);
});
it("allows an explicit provider list to override the default", () => {
expect(
resolveGatewayProviderIds({
gatewayProviderIds: ["anthropic", "github-copilot", "minimax-portal"],
}),
).toEqual(["anthropic", "github-copilot", "minimax-portal"]);
});
it("normalizes explicit provider ids and friendly aliases", () => {
expect(
resolveGatewayProviderIds({
gatewayProviderIds: [" claude ", "", "copilot", "codex", "gemini", "anthropic"],
}),
).toEqual(["anthropic", "github-copilot", "openai-codex", "google"]);
});
it("allows routing to be disabled", () => {
expect(resolveGatewayProviderIds({ routeCodexViaProxy: false })).toEqual([]);
});
});
describe("applyGatewayProviderBaseUrls", () => {
it("creates an openai-codex provider config when missing", () => {
const result = applyGatewayProviderBaseUrls({}, "http://127.0.0.1:8787", ["openai-codex"]);
expect(result.changed).toBe(true);
expect((result.config as any).models.providers["openai-codex"]).toEqual({
baseUrl: "http://127.0.0.1:8787/backend-api",
models: [],
});
});
it("creates provider configs for multiple configured provider ids", () => {
const result = applyGatewayProviderBaseUrls(
{},
"http://127.0.0.1:8787",
["anthropic", "openrouter", "google", "minimax-portal"],
);
expect(result.changed).toBe(true);
expect((result.config as any).models.providers).toEqual({
anthropic: {
baseUrl: "http://127.0.0.1:8787",
models: [],
},
openrouter: {
baseUrl: "http://127.0.0.1:8787",
models: [],
},
google: {
baseUrl: "http://127.0.0.1:8787",
models: [],
},
"minimax-portal": {
baseUrl: "http://127.0.0.1:8787",
models: [],
},
});
});
it("preserves existing provider config fields", () => {
const result = applyGatewayProviderBaseUrls(
{
models: {
providers: {
"openai-codex": {
api: "openai-codex-responses",
baseUrl: "https://chatgpt.com/backend-api",
},
},
},
},
"http://127.0.0.1:8787",
["openai-codex"],
);
expect(result.changed).toBe(true);
expect((result.config as any).models.providers["openai-codex"]).toEqual({
api: "openai-codex-responses",
baseUrl: "http://127.0.0.1:8787/backend-api",
models: [],
});
});
it("is a no-op when the provider already points at headroom", () => {
const cfg = {
models: {
providers: {
"openai-codex": {
baseUrl: "http://127.0.0.1:8787/backend-api",
models: [],
},
},
},
};
const result = applyGatewayProviderBaseUrls(cfg, "http://127.0.0.1:8787", ["openai-codex"]);
expect(result.changed).toBe(false);
expect(result.config).toEqual(cfg);
});
it("preserves upstream path segments when routing through the proxy", () => {
const result = applyGatewayProviderBaseUrls(
{
models: {
providers: {
anthropic: {
baseUrl: "https://api.anthropic.com/v1",
},
},
},
},
"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/v1",
models: [],
});
});
it("preserves protocol-specific GitHub Copilot OpenAI-family paths", () => {
const result = applyGatewayProviderBaseUrls(
{
models: {
providers: {
"github-copilot": {
baseUrl: "https://api.githubcopilot.com/v1",
},
},
},
},
"http://127.0.0.1:8787",
["github-copilot"],
);
expect(result.changed).toBe(true);
expect((result.config as any).models.providers["github-copilot"]).toEqual({
baseUrl: "http://127.0.0.1:8787/v1",
models: [],
});
});
it("preserves protocol-specific GitHub Copilot Claude-family paths", () => {
const result = applyGatewayProviderBaseUrls(
{
models: {
providers: {
"github-copilot": {
baseUrl: "https://api.githubcopilot.com/anthropic",
},
},
},
},
"http://127.0.0.1:8787",
["github-copilot"],
);
expect(result.changed).toBe(true);
expect((result.config as any).models.providers["github-copilot"]).toEqual({
baseUrl: "http://127.0.0.1:8787/anthropic",
models: [],
});
});
it("preserves OpenAI-compatible /api/v1 paths", () => {
const result = applyGatewayProviderBaseUrls(
{
models: {
providers: {
openrouter: {
baseUrl: "https://openrouter.ai/api/v1",
},
},
},
},
"http://127.0.0.1:8787",
["openrouter"],
);
expect(result.changed).toBe(true);
expect((result.config as any).models.providers.openrouter).toEqual({
baseUrl: "http://127.0.0.1:8787/api/v1",
models: [],
});
});
it("preserves Gemini /v1beta paths", () => {
const result = applyGatewayProviderBaseUrls(
{
models: {
providers: {
google: {
baseUrl: "https://generativelanguage.googleapis.com/v1beta",
},
},
},
},
"http://127.0.0.1:8787",
["google"],
);
expect(result.changed).toBe(true);
expect((result.config as any).models.providers.google).toEqual({
baseUrl: "http://127.0.0.1:8787/v1beta",
models: [],
});
});
it("does not invent a GitHub Copilot proxy baseUrl without an upstream baseUrl", () => {
const result = applyGatewayProviderBaseUrls({}, "http://127.0.0.1:8787", ["github-copilot"]);
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", () => {
it("updates the live config object in place", () => {
const cfg: any = { models: { providers: {} } };
const changed = applyGatewayProviderBaseUrlsInPlace(
cfg,
"http://127.0.0.1:8787",
["openai-codex"],
);
expect(changed).toBe(true);
expect(cfg.models.providers["openai-codex"]).toEqual({
baseUrl: "http://127.0.0.1:8787/backend-api",
models: [],
});
});
it("does not clobber existing provider logic when changing only the base URL", () => {
const cfg: any = {
models: {
providers: {
"openai-codex": {
api: "openai-codex-responses",
baseUrl: "https://chatgpt.com/backend-api",
envKey: "OPENAI_API_KEY",
models: ["gpt-5.3-codex"],
},
},
},
};
const changed = applyGatewayProviderBaseUrlsInPlace(
cfg,
"http://127.0.0.1:8787",
["openai-codex"],
);
expect(changed).toBe(true);
expect(cfg.models.providers["openai-codex"]).toEqual({
api: "openai-codex-responses",
envKey: "OPENAI_API_KEY",
baseUrl: "http://127.0.0.1:8787/backend-api",
models: ["gpt-5.3-codex"],
});
});
});