Harden openclaw plugin proxy handling and metadata

This commit is contained in:
JerrettDavis 2026-04-02 21:27:12 -05:00
parent c33f74d39d
commit 205ec54df2
12 changed files with 195 additions and 279 deletions

View file

@ -0,0 +1,2 @@
node_modules/
.env

View file

@ -6,7 +6,7 @@ Context compression plugin for [OpenClaw](https://github.com/openclaw/openclaw).
```bash
pip install "headroom-ai[proxy]"
openclaw plugins install @headroom-ai/openclaw
openclaw plugins install headroom-ai/openclaw
```
## Configure
@ -14,6 +14,14 @@ openclaw plugins install @headroom-ai/openclaw
```json
{
"plugins": {
"entries": {
"headroom": {
"enabled": true,
"config": {
"proxyUrl": "http://127.0.0.1:8787"
}
}
},
"slots": {
"contextEngine": "headroom"
}
@ -21,7 +29,25 @@ openclaw plugins install @headroom-ai/openclaw
}
```
That's it. The plugin auto-starts the Headroom proxy if it's not already running.
`proxyUrl` is required and must be localhost (`127.0.0.1` or `localhost`). The plugin never starts processes and only connects to the configured local proxy.
## Required Proxy Setup
Run Headroom proxy yourself before launching OpenClaw.
Python install:
```bash
pip install "headroom-ai[proxy]"
headroom proxy --host 127.0.0.1 --port 8787
```
NPM install:
```bash
npm install -g headroom-ai
headroom proxy --host 127.0.0.1 --port 8787
```
## How It Works
@ -38,10 +64,7 @@ Compression is lossless via CCR (Compress-Cache-Retrieve): originals are stored
| Option | Default | Description |
|--------|---------|-------------|
| `proxyUrl` | auto-detected | URL of the Headroom proxy |
| `autoStart` | `true` | Start proxy automatically if not running |
| `pythonPath` | auto-detected | Path to Python binary |
| `proxyPort` | `8787` | Port for auto-started proxy |
| `proxyUrl` | required | URL of an already running Headroom proxy (`http://127.0.0.1:<port>` or `http://localhost:<port>`) |
## Comparison with lossless-claw

View file

@ -3,15 +3,7 @@
"uiHints": {
"proxyUrl": {
"label": "Proxy URL",
"help": "URL of the Headroom proxy (auto-detected or auto-started if not set)"
},
"pythonPath": {
"label": "Python Path",
"help": "Path to Python binary (auto-detected if not set)"
},
"autoStart": {
"label": "Auto-Start Proxy",
"help": "Automatically start the Headroom proxy if not already running"
"help": "Required. URL of an already running Headroom proxy on localhost (example: http://127.0.0.1:8787)"
}
},
"configSchema": {
@ -22,20 +14,17 @@
"type": "boolean"
},
"proxyUrl": {
"type": "string"
},
"pythonPath": {
"type": "string"
},
"autoStart": {
"type": "boolean",
"default": true
},
"proxyPort": {
"type": "integer",
"minimum": 0,
"maximum": 65535
"type": "string",
"pattern": "^http:\\/\\/(localhost|127\\.0\\.0\\.1)(:\\d+)?$"
}
}
},
"capabilities": {
"network": {
"allow": [
"http://localhost:*",
"http://127.0.0.1:*"
]
}
}
}

View file

@ -1,11 +1,11 @@
{
"name": "@headroom-ai/openclaw",
"name": "headroom-openclaw",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@headroom-ai/openclaw",
"name": "headroom-openclaw",
"version": "0.1.0",
"license": "Apache-2.0",
"dependencies": {
@ -16,6 +16,9 @@
"typescript": "^5.5.0",
"vitest": "^2.0.0"
},
"engines": {
"node": ">=20"
},
"peerDependencies": {
"openclaw": "*"
},

View file

@ -33,9 +33,23 @@
"vitest": "^2.0.0"
},
"openclaw": {
"hooks": {
"contextEngine": "dist/index.js",
"tools": [
"dist/index.js"
]
},
"extensions": [
"./dist/index.js"
]
],
"capabilities": {
"network": {
"allow": [
"http://localhost:*",
"http://127.0.0.1:*"
]
}
}
},
"license": "Apache-2.0"
}

View file

@ -114,7 +114,7 @@ export class HeadroomContextEngine {
baseUrl: this.proxyUrl,
fallback: true,
tokenBudget: params.tokenBudget,
});
} as any);
if (!result.compressed || result.tokensSaved === 0) {
return { messages: params.messages, estimatedTokens: result.tokensBefore };

View file

@ -1,5 +1,5 @@
export { default } from "./plugin/index.js";
export { HeadroomContextEngine } from "./engine.js";
export { ProxyManager } from "./proxy-manager.js";
export { ProxyManager, normalizeAndValidateProxyUrl, probeHeadroomProxy } from "./proxy-manager.js";
export { agentToOpenAI, openAIToAgent } from "./convert.js";
export { createHeadroomRetrieveTool } from "./tools/headroom-retrieve.js";

View file

@ -2,7 +2,7 @@
* Headroom OpenClaw Plugin register ContextEngine + CCR retrieval tool.
*
* Usage:
* openclaw plugins install @headroom-ai/openclaw
* openclaw plugins install headroom-ai/openclaw
*
* Configuration (in ~/.openclaw/config.json or ~/.clawdbot/clawdbot.json):
* {
@ -16,13 +16,21 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { HeadroomContextEngine } from "../engine.js";
import { normalizeAndValidateProxyUrl } from "../proxy-manager.js";
import { createHeadroomRetrieveTool } from "../tools/headroom-retrieve.js";
export default function headroomPlugin(api: any) {
const config = api.config?.plugins?.entries?.headroom?.config ?? {};
const logger = api.logger ?? console;
const rawProxyUrl = config.proxyUrl;
if (!rawProxyUrl || typeof rawProxyUrl !== "string") {
throw new Error(
'[headroom] Missing required config: plugins.entries.headroom.config.proxyUrl (example: "http://127.0.0.1:8787")',
);
}
const proxyUrl = normalizeAndValidateProxyUrl(rawProxyUrl);
const engine = new HeadroomContextEngine(config, {
const engine = new HeadroomContextEngine({ ...config, proxyUrl }, {
info: (m: string) => logger.info(m),
warn: (m: string) => logger.warn(m),
error: (m: string) => logger.error(m),
@ -34,9 +42,8 @@ export default function headroomPlugin(api: any) {
// Register CCR retrieval tool (active once proxy is running)
api.registerTool((ctx: any) => {
const proxyUrl = engine.getProxyUrl();
if (!proxyUrl) return null;
return createHeadroomRetrieveTool({ proxyUrl });
const activeProxyUrl = engine.getProxyUrl() ?? proxyUrl;
return createHeadroomRetrieveTool({ proxyUrl: activeProxyUrl });
});
logger.info("[headroom] Plugin registered");

View file

@ -1,27 +1,14 @@
/**
* Manages the Headroom proxy process lifecycle.
* Manages connectivity to an externally managed Headroom proxy.
*
* - Detects if a proxy is already running (e.g., user has `headroom proxy` for Claude Code)
* - If not, spawns one as a child process with auto-assigned port
* - Health checks, restart on crash, graceful shutdown
* Security model:
* - No process execution
* - No environment variable access
* - Localhost-only network access (127.0.0.1 / localhost)
*/
import { spawn, type ChildProcess } from "node:child_process";
import { createWriteStream } from "node:fs";
import { join } from "node:path";
import { homedir } from "node:os";
const DEFAULT_PORT = 8787;
const HEALTH_CHECK_INTERVAL_MS = 30_000;
const STARTUP_TIMEOUT_MS = 15_000;
const RESTART_DELAY_MS = 2_000;
const MAX_RESTART_ATTEMPTS = 3;
export interface ProxyManagerConfig {
proxyUrl?: string;
pythonPath?: string;
autoStart?: boolean;
proxyPort?: number;
}
export interface ProxyManagerLogger {
@ -31,6 +18,12 @@ export interface ProxyManagerLogger {
debug(message: string): void;
}
export interface ProxyProbeResult {
reachable: boolean;
isHeadroom: boolean;
reason?: string;
}
const defaultLogger: ProxyManagerLogger = {
info: (m) => console.log(`[headroom] ${m}`),
warn: (m) => console.warn(`[headroom] ${m}`),
@ -41,12 +34,7 @@ const defaultLogger: ProxyManagerLogger = {
export class ProxyManager {
private config: ProxyManagerConfig;
private logger: ProxyManagerLogger;
private process: ChildProcess | null = null;
private proxyUrl: string | null = null;
private weStartedIt = false;
private restartCount = 0;
private healthInterval: ReturnType<typeof setInterval> | null = null;
private disposed = false;
constructor(config: ProxyManagerConfig = {}, logger?: ProxyManagerLogger) {
this.config = config;
@ -54,149 +42,39 @@ export class ProxyManager {
}
/**
* Ensure a proxy is available. Returns the URL.
*
* 1. If proxyUrl is configured, use it
* 2. Check if proxy is already running on default port
* 3. If autoStart, spawn one
* Ensure a proxy is available. Returns the normalized URL origin.
*/
async start(): Promise<string> {
// Option 1: Explicit URL configured
if (this.config.proxyUrl) {
const url = this.config.proxyUrl.replace(/\/+$/, "");
if (await this.healthCheck(url)) {
this.proxyUrl = url;
this.logger.info(`Connected to proxy at ${url}`);
return url;
}
throw new Error(`Headroom proxy not reachable at ${url}`);
if (!this.config.proxyUrl) {
throw new Error(
"Headroom proxy URL is required. Configure plugins.entries.headroom.config.proxyUrl " +
'(example: "http://127.0.0.1:8787").',
);
}
// Option 2: Check default port
const defaultUrl = `http://127.0.0.1:${DEFAULT_PORT}`;
if (await this.healthCheck(defaultUrl)) {
this.proxyUrl = defaultUrl;
this.logger.info(`Found running proxy at ${defaultUrl}`);
this.startHealthMonitor();
return defaultUrl;
const url = normalizeAndValidateProxyUrl(this.config.proxyUrl);
const probe = await probeHeadroomProxy(url);
if (probe.reachable && probe.isHeadroom) {
this.proxyUrl = url;
this.logger.info(`Headroom proxy already running at ${url}`);
return url;
}
// Option 3: Auto-start
if (this.config.autoStart !== false) {
return this.spawnProxy();
if (probe.reachable && !probe.isHeadroom) {
throw new Error(
`Service reachable at ${url}, but it does not appear to be a Headroom proxy (${probe.reason ?? "unknown service"}).`,
);
}
throw new Error(
"Headroom proxy not running. Start with: headroom proxy --port 8787\n" +
"Or install: pip install 'headroom-ai[proxy]'",
);
throw new Error(`Headroom proxy not reachable at ${url}. Ensure the proxy is running first.`);
}
/**
* Spawn the headroom proxy as a child process.
*/
private async spawnProxy(): Promise<string> {
const pythonPath = await this.findPython();
if (!pythonPath) {
throw new Error(
"Python not found. Install Python 3.10+ and run: pip install 'headroom-ai[proxy]'",
);
}
// Check if headroom-ai is installed
const installed = await this.checkHeadroomInstalled(pythonPath);
if (!installed) {
throw new Error(
"headroom-ai Python package not found.\n" +
"Install with: pip install 'headroom-ai[proxy]'",
);
}
const port = this.config.proxyPort ?? 0; // 0 = OS picks a free port
const actualPort = port === 0 ? await this.findFreePort() : port;
const url = `http://127.0.0.1:${actualPort}`;
this.logger.info(`Starting proxy on port ${actualPort}...`);
// Log file
const logDir = join(homedir(), ".headroom", "logs");
const logPath = join(logDir, "openclaw-proxy.log");
let logStream: ReturnType<typeof createWriteStream> | null = null;
try {
const { mkdirSync } = await import("node:fs");
mkdirSync(logDir, { recursive: true });
logStream = createWriteStream(logPath, { flags: "a" });
} catch {
// Can't create log file — use /dev/null
}
const proc = spawn(
pythonPath,
["-m", "headroom.cli", "proxy", "--port", String(actualPort)],
{
env: { ...process.env, PYTHONIOENCODING: "utf-8" },
stdio: ["ignore", logStream ? "pipe" : "ignore", logStream ? "pipe" : "ignore"],
detached: false,
},
);
if (logStream) {
proc.stdout?.pipe(logStream);
proc.stderr?.pipe(logStream);
}
proc.on("exit", (code) => {
if (!this.disposed && this.weStartedIt) {
this.logger.warn(`Proxy exited with code ${code}`);
this.handleCrash();
}
});
this.process = proc;
this.weStartedIt = true;
// Wait for healthy
const healthy = await this.waitForHealthy(url, STARTUP_TIMEOUT_MS);
if (!healthy) {
proc.kill();
this.process = null;
throw new Error(
`Proxy failed to start within ${STARTUP_TIMEOUT_MS / 1000}s. Check ${logPath}`,
);
}
this.proxyUrl = url;
this.logger.info(`Proxy started on port ${actualPort} (PID: ${proc.pid})`);
this.startHealthMonitor();
return url;
}
/**
* Stop the proxy if we started it.
* No-op: plugin never starts or manages external processes.
*/
async stop(): Promise<void> {
this.disposed = true;
if (this.healthInterval) {
clearInterval(this.healthInterval);
this.healthInterval = null;
}
if (this.process && this.weStartedIt) {
this.logger.info("Stopping proxy...");
this.process.kill("SIGTERM");
// Give it 3s to shutdown gracefully
await new Promise<void>((resolve) => {
const timeout = setTimeout(() => {
this.process?.kill("SIGKILL");
resolve();
}, 3000);
this.process?.on("exit", () => {
clearTimeout(timeout);
resolve();
});
});
this.process = null;
}
this.proxyUrl = null;
}
getUrl(): string | null {
@ -204,94 +82,64 @@ export class ProxyManager {
}
// --- Internal ---
}
private async healthCheck(url: string): Promise<boolean> {
try {
const resp = await fetch(`${url}/health`, {
signal: AbortSignal.timeout(3000),
});
return resp.ok;
} catch {
return false;
}
export function normalizeAndValidateProxyUrl(proxyUrl: string): string {
let parsed: URL;
try {
parsed = new URL(proxyUrl);
} catch {
throw new Error(`Invalid proxyUrl: "${proxyUrl}"`);
}
private async waitForHealthy(url: string, timeoutMs: number): Promise<boolean> {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
if (await this.healthCheck(url)) return true;
await new Promise((r) => setTimeout(r, 500));
}
return false;
if (parsed.protocol !== "http:") {
throw new Error("proxyUrl must use http://");
}
if (parsed.hostname !== "127.0.0.1" && parsed.hostname !== "localhost") {
throw new Error("proxyUrl host must be localhost or 127.0.0.1");
}
private startHealthMonitor(): void {
if (this.healthInterval) return;
this.healthInterval = setInterval(async () => {
if (this.proxyUrl && !(await this.healthCheck(this.proxyUrl))) {
this.logger.warn("Proxy health check failed");
if (this.weStartedIt) this.handleCrash();
}
}, HEALTH_CHECK_INTERVAL_MS);
if (parsed.pathname !== "/" || parsed.search || parsed.hash) {
throw new Error("proxyUrl must not include a path, query, or hash");
}
private async handleCrash(): Promise<void> {
if (this.disposed) return;
if (this.restartCount >= MAX_RESTART_ATTEMPTS) {
this.logger.error(`Proxy crashed ${MAX_RESTART_ATTEMPTS} times. Giving up.`);
return;
}
this.restartCount++;
this.logger.info(`Restarting proxy (attempt ${this.restartCount}/${MAX_RESTART_ATTEMPTS})...`);
await new Promise((r) => setTimeout(r, RESTART_DELAY_MS));
try {
await this.spawnProxy();
} catch (e) {
this.logger.error(`Restart failed: ${e}`);
}
}
return parsed.origin;
}
private async findPython(): Promise<string | null> {
if (this.config.pythonPath) return this.config.pythonPath;
/**
* Probe a configured URL and verify whether it is a running Headroom proxy.
*/
export async function probeHeadroomProxy(proxyUrl: string): Promise<ProxyProbeResult> {
const origin = normalizeAndValidateProxyUrl(proxyUrl);
for (const cmd of ["python3", "python"]) {
try {
const { execSync } = await import("node:child_process");
const version = execSync(`${cmd} --version 2>&1`, { encoding: "utf-8" }).trim();
if (version.includes("Python 3.")) return cmd;
} catch {
continue;
}
}
return null;
}
private async checkHeadroomInstalled(pythonPath: string): Promise<boolean> {
try {
const { execSync } = await import("node:child_process");
execSync(`${pythonPath} -c "import headroom"`, {
encoding: "utf-8",
stdio: "pipe",
});
return true;
} catch {
return false;
}
}
private async findFreePort(): Promise<number> {
const { createServer } = await import("node:net");
return new Promise((resolve, reject) => {
const server = createServer();
server.listen(0, () => {
const addr = server.address();
if (addr && typeof addr === "object") {
const port = addr.port;
server.close(() => resolve(port));
} else {
reject(new Error("Could not find free port"));
}
});
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}` };
}
} catch {
return { reachable: false, isHeadroom: false, reason: "health check failed" };
}
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}`,
};
} catch {
return {
reachable: true,
isHeadroom: false,
reason: "retrieve stats endpoint unavailable",
};
}
}

View file

@ -6,12 +6,15 @@
*/
/* eslint-disable @typescript-eslint/no-explicit-any */
import { normalizeAndValidateProxyUrl } from "../proxy-manager.js";
export interface RetrieveToolConfig {
proxyUrl: string;
}
export function createHeadroomRetrieveTool(config: RetrieveToolConfig) {
const proxyOrigin = normalizeAndValidateProxyUrl(config.proxyUrl);
return {
name: "headroom_retrieve",
description:
@ -45,8 +48,8 @@ export function createHeadroomRetrieveTool(config: RetrieveToolConfig) {
try {
const url = query
? `${config.proxyUrl}/v1/retrieve/${hash}?query=${encodeURIComponent(query)}`
: `${config.proxyUrl}/v1/retrieve/${hash}`;
? `${proxyOrigin}/v1/retrieve/${hash}?query=${encodeURIComponent(query)}`
: `${proxyOrigin}/v1/retrieve/${hash}`;
const resp = await fetch(url, {
signal: AbortSignal.timeout(10_000),

View file

@ -7,12 +7,39 @@
* Requires: Python 3 + headroom-ai[proxy] installed
* Run: HEADROOM_INTEGRATION=1 npx vitest run test/engine.test.ts
*/
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { describe, it, expect, beforeAll, afterAll, vi, afterEach } from "vitest";
import { HeadroomContextEngine } from "../src/engine.js";
import { agentToOpenAI, openAIToAgent } from "../src/convert.js";
import { ProxyManager } from "../src/proxy-manager.js";
import { ProxyManager, probeHeadroomProxy } from "../src/proxy-manager.js";
const RUN = process.env.HEADROOM_INTEGRATION === "1";
const PROXY_URL = process.env.HEADROOM_PROXY_URL ?? "http://127.0.0.1:8787";
afterEach(() => {
vi.restoreAllMocks();
});
describe("Proxy probing", () => {
it("detects running Headroom proxy", async () => {
const fetchMock = vi.fn()
.mockResolvedValueOnce({ ok: true, status: 200 })
.mockResolvedValueOnce({ ok: true, status: 200 });
vi.stubGlobal("fetch", fetchMock);
const result = await probeHeadroomProxy("http://127.0.0.1:8787");
expect(result).toEqual({ reachable: true, isHeadroom: true });
});
it("flags non-headroom service at configured URL", async () => {
const fetchMock = vi.fn()
.mockResolvedValueOnce({ ok: true, status: 200 })
.mockResolvedValueOnce({ ok: false, status: 404 });
vi.stubGlobal("fetch", fetchMock);
const manager = new ProxyManager({ proxyUrl: "http://127.0.0.1:8787" });
await expect(manager.start()).rejects.toThrow(/does not appear to be a Headroom proxy/);
});
});
describe("AgentMessage conversion", () => {
it("converts user message", () => {
@ -119,11 +146,11 @@ describe("AgentMessage conversion", () => {
});
describe.skipIf(!RUN)("ProxyManager", () => {
it("detects running proxy or starts one", { timeout: 30000 }, async () => {
const manager = new ProxyManager({ autoStart: true });
it("connects to configured proxy URL", { timeout: 30000 }, async () => {
const manager = new ProxyManager({ proxyUrl: PROXY_URL });
try {
const url = await manager.start();
expect(url).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/);
expect(url).toMatch(/^http:\/\/(127\.0\.0\.1|localhost):\d+$/);
// Verify health
const resp = await fetch(`${url}/health`);
@ -138,7 +165,7 @@ describe.skipIf(!RUN)("HeadroomContextEngine", () => {
let engine: HeadroomContextEngine;
beforeAll(async () => {
engine = new HeadroomContextEngine({ autoStart: true });
engine = new HeadroomContextEngine({ proxyUrl: PROXY_URL });
await engine.bootstrap({
sessionId: "test-session",
sessionFile: "/tmp/test-session.jsonl",

View file

@ -3,7 +3,7 @@
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022"],
"lib": ["ES2022", "DOM"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,