JerrettDavis commited on
Commit
b149c75
·
1 Parent(s): 44e938e

Harden openclaw plugin proxy handling and metadata

Browse files
plugins/openclaw/.npmignore ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ node_modules/
2
+ .env
plugins/openclaw/README.md CHANGED
@@ -6,7 +6,7 @@ Context compression plugin for [OpenClaw](https://github.com/openclaw/openclaw).
6
 
7
  ```bash
8
  pip install "headroom-ai[proxy]"
9
- openclaw plugins install @headroom-ai/openclaw
10
  ```
11
 
12
  ## Configure
@@ -14,6 +14,14 @@ openclaw plugins install @headroom-ai/openclaw
14
  ```json
15
  {
16
  "plugins": {
 
 
 
 
 
 
 
 
17
  "slots": {
18
  "contextEngine": "headroom"
19
  }
@@ -21,7 +29,25 @@ openclaw plugins install @headroom-ai/openclaw
21
  }
22
  ```
23
 
24
- That's it. The plugin auto-starts the Headroom proxy if it's not already running.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
25
 
26
  ## How It Works
27
 
@@ -38,10 +64,7 @@ Compression is lossless via CCR (Compress-Cache-Retrieve): originals are stored
38
 
39
  | Option | Default | Description |
40
  |--------|---------|-------------|
41
- | `proxyUrl` | auto-detected | URL of the Headroom proxy |
42
- | `autoStart` | `true` | Start proxy automatically if not running |
43
- | `pythonPath` | auto-detected | Path to Python binary |
44
- | `proxyPort` | `8787` | Port for auto-started proxy |
45
 
46
  ## Comparison with lossless-claw
47
 
 
6
 
7
  ```bash
8
  pip install "headroom-ai[proxy]"
9
+ openclaw plugins install headroom-ai/openclaw
10
  ```
11
 
12
  ## Configure
 
14
  ```json
15
  {
16
  "plugins": {
17
+ "entries": {
18
+ "headroom": {
19
+ "enabled": true,
20
+ "config": {
21
+ "proxyUrl": "http://127.0.0.1:8787"
22
+ }
23
+ }
24
+ },
25
  "slots": {
26
  "contextEngine": "headroom"
27
  }
 
29
  }
30
  ```
31
 
32
+ `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.
33
+
34
+ ## Required Proxy Setup
35
+
36
+ Run Headroom proxy yourself before launching OpenClaw.
37
+
38
+ Python install:
39
+
40
+ ```bash
41
+ pip install "headroom-ai[proxy]"
42
+ headroom proxy --host 127.0.0.1 --port 8787
43
+ ```
44
+
45
+ NPM install:
46
+
47
+ ```bash
48
+ npm install -g headroom-ai
49
+ headroom proxy --host 127.0.0.1 --port 8787
50
+ ```
51
 
52
  ## How It Works
53
 
 
64
 
65
  | Option | Default | Description |
66
  |--------|---------|-------------|
67
+ | `proxyUrl` | required | URL of an already running Headroom proxy (`http://127.0.0.1:<port>` or `http://localhost:<port>`) |
 
 
 
68
 
69
  ## Comparison with lossless-claw
70
 
plugins/openclaw/openclaw.plugin.json CHANGED
@@ -3,15 +3,7 @@
3
  "uiHints": {
4
  "proxyUrl": {
5
  "label": "Proxy URL",
6
- "help": "URL of the Headroom proxy (auto-detected or auto-started if not set)"
7
- },
8
- "pythonPath": {
9
- "label": "Python Path",
10
- "help": "Path to Python binary (auto-detected if not set)"
11
- },
12
- "autoStart": {
13
- "label": "Auto-Start Proxy",
14
- "help": "Automatically start the Headroom proxy if not already running"
15
  }
16
  },
17
  "configSchema": {
@@ -22,20 +14,17 @@
22
  "type": "boolean"
23
  },
24
  "proxyUrl": {
25
- "type": "string"
26
- },
27
- "pythonPath": {
28
- "type": "string"
29
- },
30
- "autoStart": {
31
- "type": "boolean",
32
- "default": true
33
- },
34
- "proxyPort": {
35
- "type": "integer",
36
- "minimum": 0,
37
- "maximum": 65535
38
  }
39
  }
 
 
 
 
 
 
 
 
40
  }
41
  }
 
3
  "uiHints": {
4
  "proxyUrl": {
5
  "label": "Proxy URL",
6
+ "help": "Required. URL of an already running Headroom proxy on localhost (example: http://127.0.0.1:8787)"
 
 
 
 
 
 
 
 
7
  }
8
  },
9
  "configSchema": {
 
14
  "type": "boolean"
15
  },
16
  "proxyUrl": {
17
+ "type": "string",
18
+ "pattern": "^http:\\/\\/(localhost|127\\.0\\.0\\.1)(:\\d+)?$"
 
 
 
 
 
 
 
 
 
 
 
19
  }
20
  }
21
+ },
22
+ "capabilities": {
23
+ "network": {
24
+ "allow": [
25
+ "http://localhost:*",
26
+ "http://127.0.0.1:*"
27
+ ]
28
+ }
29
  }
30
  }
plugins/openclaw/package-lock.json CHANGED
@@ -1,11 +1,11 @@
1
  {
2
- "name": "@headroom-ai/openclaw",
3
  "version": "0.1.0",
4
  "lockfileVersion": 3,
5
  "requires": true,
6
  "packages": {
7
  "": {
8
- "name": "@headroom-ai/openclaw",
9
  "version": "0.1.0",
10
  "license": "Apache-2.0",
11
  "dependencies": {
@@ -16,6 +16,9 @@
16
  "typescript": "^5.5.0",
17
  "vitest": "^2.0.0"
18
  },
 
 
 
19
  "peerDependencies": {
20
  "openclaw": "*"
21
  },
 
1
  {
2
+ "name": "headroom-openclaw",
3
  "version": "0.1.0",
4
  "lockfileVersion": 3,
5
  "requires": true,
6
  "packages": {
7
  "": {
8
+ "name": "headroom-openclaw",
9
  "version": "0.1.0",
10
  "license": "Apache-2.0",
11
  "dependencies": {
 
16
  "typescript": "^5.5.0",
17
  "vitest": "^2.0.0"
18
  },
19
+ "engines": {
20
+ "node": ">=20"
21
+ },
22
  "peerDependencies": {
23
  "openclaw": "*"
24
  },
plugins/openclaw/package.json CHANGED
@@ -33,9 +33,23 @@
33
  "vitest": "^2.0.0"
34
  },
35
  "openclaw": {
 
 
 
 
 
 
36
  "extensions": [
37
  "./dist/index.js"
38
- ]
 
 
 
 
 
 
 
 
39
  },
40
  "license": "Apache-2.0"
41
  }
 
33
  "vitest": "^2.0.0"
34
  },
35
  "openclaw": {
36
+ "hooks": {
37
+ "contextEngine": "dist/index.js",
38
+ "tools": [
39
+ "dist/index.js"
40
+ ]
41
+ },
42
  "extensions": [
43
  "./dist/index.js"
44
+ ],
45
+ "capabilities": {
46
+ "network": {
47
+ "allow": [
48
+ "http://localhost:*",
49
+ "http://127.0.0.1:*"
50
+ ]
51
+ }
52
+ }
53
  },
54
  "license": "Apache-2.0"
55
  }
plugins/openclaw/src/engine.ts CHANGED
@@ -114,7 +114,7 @@ export class HeadroomContextEngine {
114
  baseUrl: this.proxyUrl,
115
  fallback: true,
116
  tokenBudget: params.tokenBudget,
117
- });
118
 
119
  if (!result.compressed || result.tokensSaved === 0) {
120
  return { messages: params.messages, estimatedTokens: result.tokensBefore };
 
114
  baseUrl: this.proxyUrl,
115
  fallback: true,
116
  tokenBudget: params.tokenBudget,
117
+ } as any);
118
 
119
  if (!result.compressed || result.tokensSaved === 0) {
120
  return { messages: params.messages, estimatedTokens: result.tokensBefore };
plugins/openclaw/src/index.ts CHANGED
@@ -1,5 +1,5 @@
1
  export { default } from "./plugin/index.js";
2
  export { HeadroomContextEngine } from "./engine.js";
3
- export { ProxyManager } from "./proxy-manager.js";
4
  export { agentToOpenAI, openAIToAgent } from "./convert.js";
5
  export { createHeadroomRetrieveTool } from "./tools/headroom-retrieve.js";
 
1
  export { default } from "./plugin/index.js";
2
  export { HeadroomContextEngine } from "./engine.js";
3
+ export { ProxyManager, normalizeAndValidateProxyUrl, probeHeadroomProxy } from "./proxy-manager.js";
4
  export { agentToOpenAI, openAIToAgent } from "./convert.js";
5
  export { createHeadroomRetrieveTool } from "./tools/headroom-retrieve.js";
plugins/openclaw/src/plugin/index.ts CHANGED
@@ -2,7 +2,7 @@
2
  * Headroom OpenClaw Plugin — register ContextEngine + CCR retrieval tool.
3
  *
4
  * Usage:
5
- * openclaw plugins install @headroom-ai/openclaw
6
  *
7
  * Configuration (in ~/.openclaw/config.json or ~/.clawdbot/clawdbot.json):
8
  * {
@@ -16,13 +16,21 @@
16
  /* eslint-disable @typescript-eslint/no-explicit-any */
17
 
18
  import { HeadroomContextEngine } from "../engine.js";
 
19
  import { createHeadroomRetrieveTool } from "../tools/headroom-retrieve.js";
20
 
21
  export default function headroomPlugin(api: any) {
22
  const config = api.config?.plugins?.entries?.headroom?.config ?? {};
23
  const logger = api.logger ?? console;
 
 
 
 
 
 
 
24
 
25
- const engine = new HeadroomContextEngine(config, {
26
  info: (m: string) => logger.info(m),
27
  warn: (m: string) => logger.warn(m),
28
  error: (m: string) => logger.error(m),
@@ -34,9 +42,8 @@ export default function headroomPlugin(api: any) {
34
 
35
  // Register CCR retrieval tool (active once proxy is running)
36
  api.registerTool((ctx: any) => {
37
- const proxyUrl = engine.getProxyUrl();
38
- if (!proxyUrl) return null;
39
- return createHeadroomRetrieveTool({ proxyUrl });
40
  });
41
 
42
  logger.info("[headroom] Plugin registered");
 
2
  * Headroom OpenClaw Plugin — register ContextEngine + CCR retrieval tool.
3
  *
4
  * Usage:
5
+ * openclaw plugins install headroom-ai/openclaw
6
  *
7
  * Configuration (in ~/.openclaw/config.json or ~/.clawdbot/clawdbot.json):
8
  * {
 
16
  /* eslint-disable @typescript-eslint/no-explicit-any */
17
 
18
  import { HeadroomContextEngine } from "../engine.js";
19
+ import { normalizeAndValidateProxyUrl } from "../proxy-manager.js";
20
  import { createHeadroomRetrieveTool } from "../tools/headroom-retrieve.js";
21
 
22
  export default function headroomPlugin(api: any) {
23
  const config = api.config?.plugins?.entries?.headroom?.config ?? {};
24
  const logger = api.logger ?? console;
25
+ const rawProxyUrl = config.proxyUrl;
26
+ if (!rawProxyUrl || typeof rawProxyUrl !== "string") {
27
+ throw new Error(
28
+ '[headroom] Missing required config: plugins.entries.headroom.config.proxyUrl (example: "http://127.0.0.1:8787")',
29
+ );
30
+ }
31
+ const proxyUrl = normalizeAndValidateProxyUrl(rawProxyUrl);
32
 
33
+ const engine = new HeadroomContextEngine({ ...config, proxyUrl }, {
34
  info: (m: string) => logger.info(m),
35
  warn: (m: string) => logger.warn(m),
36
  error: (m: string) => logger.error(m),
 
42
 
43
  // Register CCR retrieval tool (active once proxy is running)
44
  api.registerTool((ctx: any) => {
45
+ const activeProxyUrl = engine.getProxyUrl() ?? proxyUrl;
46
+ return createHeadroomRetrieveTool({ proxyUrl: activeProxyUrl });
 
47
  });
48
 
49
  logger.info("[headroom] Plugin registered");
plugins/openclaw/src/proxy-manager.ts CHANGED
@@ -1,27 +1,14 @@
1
  /**
2
- * Manages the Headroom proxy process lifecycle.
3
  *
4
- * - Detects if a proxy is already running (e.g., user has `headroom proxy` for Claude Code)
5
- * - If not, spawns one as a child process with auto-assigned port
6
- * - Health checks, restart on crash, graceful shutdown
 
7
  */
8
 
9
- import { spawn, type ChildProcess } from "node:child_process";
10
- import { createWriteStream } from "node:fs";
11
- import { join } from "node:path";
12
- import { homedir } from "node:os";
13
-
14
- const DEFAULT_PORT = 8787;
15
- const HEALTH_CHECK_INTERVAL_MS = 30_000;
16
- const STARTUP_TIMEOUT_MS = 15_000;
17
- const RESTART_DELAY_MS = 2_000;
18
- const MAX_RESTART_ATTEMPTS = 3;
19
-
20
  export interface ProxyManagerConfig {
21
  proxyUrl?: string;
22
- pythonPath?: string;
23
- autoStart?: boolean;
24
- proxyPort?: number;
25
  }
26
 
27
  export interface ProxyManagerLogger {
@@ -31,6 +18,12 @@ export interface ProxyManagerLogger {
31
  debug(message: string): void;
32
  }
33
 
 
 
 
 
 
 
34
  const defaultLogger: ProxyManagerLogger = {
35
  info: (m) => console.log(`[headroom] ${m}`),
36
  warn: (m) => console.warn(`[headroom] ${m}`),
@@ -41,12 +34,7 @@ const defaultLogger: ProxyManagerLogger = {
41
  export class ProxyManager {
42
  private config: ProxyManagerConfig;
43
  private logger: ProxyManagerLogger;
44
- private process: ChildProcess | null = null;
45
  private proxyUrl: string | null = null;
46
- private weStartedIt = false;
47
- private restartCount = 0;
48
- private healthInterval: ReturnType<typeof setInterval> | null = null;
49
- private disposed = false;
50
 
51
  constructor(config: ProxyManagerConfig = {}, logger?: ProxyManagerLogger) {
52
  this.config = config;
@@ -54,149 +42,39 @@ export class ProxyManager {
54
  }
55
 
56
  /**
57
- * Ensure a proxy is available. Returns the URL.
58
- *
59
- * 1. If proxyUrl is configured, use it
60
- * 2. Check if proxy is already running on default port
61
- * 3. If autoStart, spawn one
62
  */
63
  async start(): Promise<string> {
64
- // Option 1: Explicit URL configured
65
- if (this.config.proxyUrl) {
66
- const url = this.config.proxyUrl.replace(/\/+$/, "");
67
- if (await this.healthCheck(url)) {
68
- this.proxyUrl = url;
69
- this.logger.info(`Connected to proxy at ${url}`);
70
- return url;
71
- }
72
- throw new Error(`Headroom proxy not reachable at ${url}`);
73
- }
74
-
75
- // Option 2: Check default port
76
- const defaultUrl = `http://127.0.0.1:${DEFAULT_PORT}`;
77
- if (await this.healthCheck(defaultUrl)) {
78
- this.proxyUrl = defaultUrl;
79
- this.logger.info(`Found running proxy at ${defaultUrl}`);
80
- this.startHealthMonitor();
81
- return defaultUrl;
82
- }
83
-
84
- // Option 3: Auto-start
85
- if (this.config.autoStart !== false) {
86
- return this.spawnProxy();
87
- }
88
-
89
- throw new Error(
90
- "Headroom proxy not running. Start with: headroom proxy --port 8787\n" +
91
- "Or install: pip install 'headroom-ai[proxy]'",
92
- );
93
- }
94
-
95
- /**
96
- * Spawn the headroom proxy as a child process.
97
- */
98
- private async spawnProxy(): Promise<string> {
99
- const pythonPath = await this.findPython();
100
- if (!pythonPath) {
101
- throw new Error(
102
- "Python not found. Install Python 3.10+ and run: pip install 'headroom-ai[proxy]'",
103
- );
104
- }
105
-
106
- // Check if headroom-ai is installed
107
- const installed = await this.checkHeadroomInstalled(pythonPath);
108
- if (!installed) {
109
  throw new Error(
110
- "headroom-ai Python package not found.\n" +
111
- "Install with: pip install 'headroom-ai[proxy]'",
112
  );
113
  }
114
 
115
- const port = this.config.proxyPort ?? 0; // 0 = OS picks a free port
116
- const actualPort = port === 0 ? await this.findFreePort() : port;
117
- const url = `http://127.0.0.1:${actualPort}`;
118
-
119
- this.logger.info(`Starting proxy on port ${actualPort}...`);
120
-
121
- // Log file
122
- const logDir = join(homedir(), ".headroom", "logs");
123
- const logPath = join(logDir, "openclaw-proxy.log");
124
-
125
- let logStream: ReturnType<typeof createWriteStream> | null = null;
126
- try {
127
- const { mkdirSync } = await import("node:fs");
128
- mkdirSync(logDir, { recursive: true });
129
- logStream = createWriteStream(logPath, { flags: "a" });
130
- } catch {
131
- // Can't create log file — use /dev/null
132
- }
133
-
134
- const proc = spawn(
135
- pythonPath,
136
- ["-m", "headroom.cli", "proxy", "--port", String(actualPort)],
137
- {
138
- env: { ...process.env, PYTHONIOENCODING: "utf-8" },
139
- stdio: ["ignore", logStream ? "pipe" : "ignore", logStream ? "pipe" : "ignore"],
140
- detached: false,
141
- },
142
- );
143
 
144
- if (logStream) {
145
- proc.stdout?.pipe(logStream);
146
- proc.stderr?.pipe(logStream);
 
147
  }
148
 
149
- proc.on("exit", (code) => {
150
- if (!this.disposed && this.weStartedIt) {
151
- this.logger.warn(`Proxy exited with code ${code}`);
152
- this.handleCrash();
153
- }
154
- });
155
-
156
- this.process = proc;
157
- this.weStartedIt = true;
158
-
159
- // Wait for healthy
160
- const healthy = await this.waitForHealthy(url, STARTUP_TIMEOUT_MS);
161
- if (!healthy) {
162
- proc.kill();
163
- this.process = null;
164
  throw new Error(
165
- `Proxy failed to start within ${STARTUP_TIMEOUT_MS / 1000}s. Check ${logPath}`,
166
  );
167
  }
168
 
169
- this.proxyUrl = url;
170
- this.logger.info(`Proxy started on port ${actualPort} (PID: ${proc.pid})`);
171
- this.startHealthMonitor();
172
- return url;
173
  }
174
 
175
  /**
176
- * Stop the proxy if we started it.
177
  */
178
  async stop(): Promise<void> {
179
- this.disposed = true;
180
- if (this.healthInterval) {
181
- clearInterval(this.healthInterval);
182
- this.healthInterval = null;
183
- }
184
- if (this.process && this.weStartedIt) {
185
- this.logger.info("Stopping proxy...");
186
- this.process.kill("SIGTERM");
187
- // Give it 3s to shutdown gracefully
188
- await new Promise<void>((resolve) => {
189
- const timeout = setTimeout(() => {
190
- this.process?.kill("SIGKILL");
191
- resolve();
192
- }, 3000);
193
- this.process?.on("exit", () => {
194
- clearTimeout(timeout);
195
- resolve();
196
- });
197
- });
198
- this.process = null;
199
- }
200
  }
201
 
202
  getUrl(): string | null {
@@ -204,94 +82,64 @@ export class ProxyManager {
204
  }
205
 
206
  // --- Internal ---
 
207
 
208
- private async healthCheck(url: string): Promise<boolean> {
209
- try {
210
- const resp = await fetch(`${url}/health`, {
211
- signal: AbortSignal.timeout(3000),
212
- });
213
- return resp.ok;
214
- } catch {
215
- return false;
216
- }
217
  }
218
 
219
- private async waitForHealthy(url: string, timeoutMs: number): Promise<boolean> {
220
- const start = Date.now();
221
- while (Date.now() - start < timeoutMs) {
222
- if (await this.healthCheck(url)) return true;
223
- await new Promise((r) => setTimeout(r, 500));
224
- }
225
- return false;
226
  }
227
-
228
- private startHealthMonitor(): void {
229
- if (this.healthInterval) return;
230
- this.healthInterval = setInterval(async () => {
231
- if (this.proxyUrl && !(await this.healthCheck(this.proxyUrl))) {
232
- this.logger.warn("Proxy health check failed");
233
- if (this.weStartedIt) this.handleCrash();
234
- }
235
- }, HEALTH_CHECK_INTERVAL_MS);
236
  }
237
 
238
- private async handleCrash(): Promise<void> {
239
- if (this.disposed) return;
240
- if (this.restartCount >= MAX_RESTART_ATTEMPTS) {
241
- this.logger.error(`Proxy crashed ${MAX_RESTART_ATTEMPTS} times. Giving up.`);
242
- return;
243
- }
244
- this.restartCount++;
245
- this.logger.info(`Restarting proxy (attempt ${this.restartCount}/${MAX_RESTART_ATTEMPTS})...`);
246
- await new Promise((r) => setTimeout(r, RESTART_DELAY_MS));
247
- try {
248
- await this.spawnProxy();
249
- } catch (e) {
250
- this.logger.error(`Restart failed: ${e}`);
251
- }
252
  }
253
 
254
- private async findPython(): Promise<string | null> {
255
- if (this.config.pythonPath) return this.config.pythonPath;
256
 
257
- for (const cmd of ["python3", "python"]) {
258
- try {
259
- const { execSync } = await import("node:child_process");
260
- const version = execSync(`${cmd} --version 2>&1`, { encoding: "utf-8" }).trim();
261
- if (version.includes("Python 3.")) return cmd;
262
- } catch {
263
- continue;
264
- }
265
- }
266
- return null;
267
- }
268
 
269
- private async checkHeadroomInstalled(pythonPath: string): Promise<boolean> {
270
- try {
271
- const { execSync } = await import("node:child_process");
272
- execSync(`${pythonPath} -c "import headroom"`, {
273
- encoding: "utf-8",
274
- stdio: "pipe",
275
- });
276
- return true;
277
- } catch {
278
- return false;
279
  }
 
 
280
  }
281
 
282
- private async findFreePort(): Promise<number> {
283
- const { createServer } = await import("node:net");
284
- return new Promise((resolve, reject) => {
285
- const server = createServer();
286
- server.listen(0, () => {
287
- const addr = server.address();
288
- if (addr && typeof addr === "object") {
289
- const port = addr.port;
290
- server.close(() => resolve(port));
291
- } else {
292
- reject(new Error("Could not find free port"));
293
- }
294
- });
295
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
296
  }
297
  }
 
1
  /**
2
+ * Manages connectivity to an externally managed Headroom proxy.
3
  *
4
+ * Security model:
5
+ * - No process execution
6
+ * - No environment variable access
7
+ * - Localhost-only network access (127.0.0.1 / localhost)
8
  */
9
 
 
 
 
 
 
 
 
 
 
 
 
10
  export interface ProxyManagerConfig {
11
  proxyUrl?: string;
 
 
 
12
  }
13
 
14
  export interface ProxyManagerLogger {
 
18
  debug(message: string): void;
19
  }
20
 
21
+ export interface ProxyProbeResult {
22
+ reachable: boolean;
23
+ isHeadroom: boolean;
24
+ reason?: string;
25
+ }
26
+
27
  const defaultLogger: ProxyManagerLogger = {
28
  info: (m) => console.log(`[headroom] ${m}`),
29
  warn: (m) => console.warn(`[headroom] ${m}`),
 
34
  export class ProxyManager {
35
  private config: ProxyManagerConfig;
36
  private logger: ProxyManagerLogger;
 
37
  private proxyUrl: string | null = null;
 
 
 
 
38
 
39
  constructor(config: ProxyManagerConfig = {}, logger?: ProxyManagerLogger) {
40
  this.config = config;
 
42
  }
43
 
44
  /**
45
+ * Ensure a proxy is available. Returns the normalized URL origin.
 
 
 
 
46
  */
47
  async start(): Promise<string> {
48
+ if (!this.config.proxyUrl) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
49
  throw new Error(
50
+ "Headroom proxy URL is required. Configure plugins.entries.headroom.config.proxyUrl " +
51
+ '(example: "http://127.0.0.1:8787").',
52
  );
53
  }
54
 
55
+ const url = normalizeAndValidateProxyUrl(this.config.proxyUrl);
56
+ const probe = await probeHeadroomProxy(url);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
57
 
58
+ if (probe.reachable && probe.isHeadroom) {
59
+ this.proxyUrl = url;
60
+ this.logger.info(`Headroom proxy already running at ${url}`);
61
+ return url;
62
  }
63
 
64
+ if (probe.reachable && !probe.isHeadroom) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
65
  throw new Error(
66
+ `Service reachable at ${url}, but it does not appear to be a Headroom proxy (${probe.reason ?? "unknown service"}).`,
67
  );
68
  }
69
 
70
+ throw new Error(`Headroom proxy not reachable at ${url}. Ensure the proxy is running first.`);
 
 
 
71
  }
72
 
73
  /**
74
+ * No-op: plugin never starts or manages external processes.
75
  */
76
  async stop(): Promise<void> {
77
+ this.proxyUrl = null;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
78
  }
79
 
80
  getUrl(): string | null {
 
82
  }
83
 
84
  // --- Internal ---
85
+ }
86
 
87
+ export function normalizeAndValidateProxyUrl(proxyUrl: string): string {
88
+ let parsed: URL;
89
+ try {
90
+ parsed = new URL(proxyUrl);
91
+ } catch {
92
+ throw new Error(`Invalid proxyUrl: "${proxyUrl}"`);
 
 
 
93
  }
94
 
95
+ if (parsed.protocol !== "http:") {
96
+ throw new Error("proxyUrl must use http://");
 
 
 
 
 
97
  }
98
+ if (parsed.hostname !== "127.0.0.1" && parsed.hostname !== "localhost") {
99
+ throw new Error("proxyUrl host must be localhost or 127.0.0.1");
 
 
 
 
 
 
 
100
  }
101
 
102
+ if (parsed.pathname !== "/" || parsed.search || parsed.hash) {
103
+ throw new Error("proxyUrl must not include a path, query, or hash");
 
 
 
 
 
 
 
 
 
 
 
 
104
  }
105
 
106
+ return parsed.origin;
107
+ }
108
 
109
+ /**
110
+ * Probe a configured URL and verify whether it is a running Headroom proxy.
111
+ */
112
+ export async function probeHeadroomProxy(proxyUrl: string): Promise<ProxyProbeResult> {
113
+ const origin = normalizeAndValidateProxyUrl(proxyUrl);
 
 
 
 
 
 
114
 
115
+ try {
116
+ const health = await fetch(`${origin}/health`, {
117
+ signal: AbortSignal.timeout(3_000),
118
+ });
119
+ if (!health.ok) {
120
+ return { reachable: false, isHeadroom: false, reason: `health HTTP ${health.status}` };
 
 
 
 
121
  }
122
+ } catch {
123
+ return { reachable: false, isHeadroom: false, reason: "health check failed" };
124
  }
125
 
126
+ try {
127
+ const retrieveStats = await fetch(`${origin}/v1/retrieve/stats`, {
128
+ signal: AbortSignal.timeout(3_000),
 
 
 
 
 
 
 
 
 
 
129
  });
130
+ if (retrieveStats.ok) {
131
+ return { reachable: true, isHeadroom: true };
132
+ }
133
+ return {
134
+ reachable: true,
135
+ isHeadroom: false,
136
+ reason: `retrieve stats HTTP ${retrieveStats.status}`,
137
+ };
138
+ } catch {
139
+ return {
140
+ reachable: true,
141
+ isHeadroom: false,
142
+ reason: "retrieve stats endpoint unavailable",
143
+ };
144
  }
145
  }
plugins/openclaw/src/tools/headroom-retrieve.ts CHANGED
@@ -6,12 +6,15 @@
6
  */
7
 
8
  /* eslint-disable @typescript-eslint/no-explicit-any */
 
9
 
10
  export interface RetrieveToolConfig {
11
  proxyUrl: string;
12
  }
13
 
14
  export function createHeadroomRetrieveTool(config: RetrieveToolConfig) {
 
 
15
  return {
16
  name: "headroom_retrieve",
17
  description:
@@ -45,8 +48,8 @@ export function createHeadroomRetrieveTool(config: RetrieveToolConfig) {
45
 
46
  try {
47
  const url = query
48
- ? `${config.proxyUrl}/v1/retrieve/${hash}?query=${encodeURIComponent(query)}`
49
- : `${config.proxyUrl}/v1/retrieve/${hash}`;
50
 
51
  const resp = await fetch(url, {
52
  signal: AbortSignal.timeout(10_000),
 
6
  */
7
 
8
  /* eslint-disable @typescript-eslint/no-explicit-any */
9
+ import { normalizeAndValidateProxyUrl } from "../proxy-manager.js";
10
 
11
  export interface RetrieveToolConfig {
12
  proxyUrl: string;
13
  }
14
 
15
  export function createHeadroomRetrieveTool(config: RetrieveToolConfig) {
16
+ const proxyOrigin = normalizeAndValidateProxyUrl(config.proxyUrl);
17
+
18
  return {
19
  name: "headroom_retrieve",
20
  description:
 
48
 
49
  try {
50
  const url = query
51
+ ? `${proxyOrigin}/v1/retrieve/${hash}?query=${encodeURIComponent(query)}`
52
+ : `${proxyOrigin}/v1/retrieve/${hash}`;
53
 
54
  const resp = await fetch(url, {
55
  signal: AbortSignal.timeout(10_000),
plugins/openclaw/test/engine.test.ts CHANGED
@@ -7,12 +7,39 @@
7
  * Requires: Python 3 + headroom-ai[proxy] installed
8
  * Run: HEADROOM_INTEGRATION=1 npx vitest run test/engine.test.ts
9
  */
10
- import { describe, it, expect, beforeAll, afterAll } from "vitest";
11
  import { HeadroomContextEngine } from "../src/engine.js";
12
  import { agentToOpenAI, openAIToAgent } from "../src/convert.js";
13
- import { ProxyManager } from "../src/proxy-manager.js";
14
 
15
  const RUN = process.env.HEADROOM_INTEGRATION === "1";
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16
 
17
  describe("AgentMessage conversion", () => {
18
  it("converts user message", () => {
@@ -119,11 +146,11 @@ describe("AgentMessage conversion", () => {
119
  });
120
 
121
  describe.skipIf(!RUN)("ProxyManager", () => {
122
- it("detects running proxy or starts one", { timeout: 30000 }, async () => {
123
- const manager = new ProxyManager({ autoStart: true });
124
  try {
125
  const url = await manager.start();
126
- expect(url).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/);
127
 
128
  // Verify health
129
  const resp = await fetch(`${url}/health`);
@@ -138,7 +165,7 @@ describe.skipIf(!RUN)("HeadroomContextEngine", () => {
138
  let engine: HeadroomContextEngine;
139
 
140
  beforeAll(async () => {
141
- engine = new HeadroomContextEngine({ autoStart: true });
142
  await engine.bootstrap({
143
  sessionId: "test-session",
144
  sessionFile: "/tmp/test-session.jsonl",
 
7
  * Requires: Python 3 + headroom-ai[proxy] installed
8
  * Run: HEADROOM_INTEGRATION=1 npx vitest run test/engine.test.ts
9
  */
10
+ import { describe, it, expect, beforeAll, afterAll, vi, afterEach } from "vitest";
11
  import { HeadroomContextEngine } from "../src/engine.js";
12
  import { agentToOpenAI, openAIToAgent } from "../src/convert.js";
13
+ import { ProxyManager, probeHeadroomProxy } from "../src/proxy-manager.js";
14
 
15
  const RUN = process.env.HEADROOM_INTEGRATION === "1";
16
+ const PROXY_URL = process.env.HEADROOM_PROXY_URL ?? "http://127.0.0.1:8787";
17
+
18
+ afterEach(() => {
19
+ vi.restoreAllMocks();
20
+ });
21
+
22
+ describe("Proxy probing", () => {
23
+ it("detects running Headroom proxy", async () => {
24
+ const fetchMock = vi.fn()
25
+ .mockResolvedValueOnce({ ok: true, status: 200 })
26
+ .mockResolvedValueOnce({ ok: true, status: 200 });
27
+ vi.stubGlobal("fetch", fetchMock);
28
+
29
+ const result = await probeHeadroomProxy("http://127.0.0.1:8787");
30
+ expect(result).toEqual({ reachable: true, isHeadroom: true });
31
+ });
32
+
33
+ it("flags non-headroom service at configured URL", async () => {
34
+ const fetchMock = vi.fn()
35
+ .mockResolvedValueOnce({ ok: true, status: 200 })
36
+ .mockResolvedValueOnce({ ok: false, status: 404 });
37
+ vi.stubGlobal("fetch", fetchMock);
38
+
39
+ const manager = new ProxyManager({ proxyUrl: "http://127.0.0.1:8787" });
40
+ await expect(manager.start()).rejects.toThrow(/does not appear to be a Headroom proxy/);
41
+ });
42
+ });
43
 
44
  describe("AgentMessage conversion", () => {
45
  it("converts user message", () => {
 
146
  });
147
 
148
  describe.skipIf(!RUN)("ProxyManager", () => {
149
+ it("connects to configured proxy URL", { timeout: 30000 }, async () => {
150
+ const manager = new ProxyManager({ proxyUrl: PROXY_URL });
151
  try {
152
  const url = await manager.start();
153
+ expect(url).toMatch(/^http:\/\/(127\.0\.0\.1|localhost):\d+$/);
154
 
155
  // Verify health
156
  const resp = await fetch(`${url}/health`);
 
165
  let engine: HeadroomContextEngine;
166
 
167
  beforeAll(async () => {
168
+ engine = new HeadroomContextEngine({ proxyUrl: PROXY_URL });
169
  await engine.bootstrap({
170
  sessionId: "test-session",
171
  sessionFile: "/tmp/test-session.jsonl",
plugins/openclaw/tsconfig.json CHANGED
@@ -3,7 +3,7 @@
3
  "target": "ES2022",
4
  "module": "ESNext",
5
  "moduleResolution": "bundler",
6
- "lib": ["ES2022"],
7
  "strict": true,
8
  "esModuleInterop": true,
9
  "skipLibCheck": true,
 
3
  "target": "ES2022",
4
  "module": "ESNext",
5
  "moduleResolution": "bundler",
6
+ "lib": ["ES2022", "DOM"],
7
  "strict": true,
8
  "esModuleInterop": true,
9
  "skipLibCheck": true,