JerrettDavis commited on
Commit
0963758
·
1 Parent(s): db23f5d

feat(openclaw): support remote Headroom proxy (connect-only)

Browse files

Remove localhost-only restriction from proxyUrl validation. Remote URLs
are connect-only: the plugin probes and uses them but never attempts to
spawn a subprocess. Auto-start remains available for local addresses
(127.0.0.1/localhost) only.

- Accept http:// and https:// URLs for any hostname
- Add isLocalProxyUrl() helper to gate auto-start on local addresses
- Remote URLs that fail probe get a clear "Remote proxy not reachable"
error instead of falling through to auto-start
- Update plugin.json schema, network capabilities, and UI hints
- Update plugin README with local vs remote proxy sections
- Add tests: remote connect, remote fail-fast, isLocalProxyUrl

Build: tsc --noEmit clean
Tests: 29 passed (0 skipped)

README.md CHANGED
@@ -369,7 +369,7 @@ Context compression is a new space. Here's how the approaches differ:
369
 
370
  ## OpenClaw Plugin
371
 
372
- The [`@headroom-ai/openclaw`](plugins/openclaw) plugin integrates Headroom as a ContextEngine for [OpenClaw](https://github.com/openclaw/openclaw). It compresses tool outputs, code, logs, and structured data inline — 70-90% token savings with zero LLM calls.
373
 
374
  ### Install
375
 
 
369
 
370
  ## OpenClaw Plugin
371
 
372
+ The [`@headroom-ai/openclaw`](plugins/openclaw) plugin integrates Headroom as a ContextEngine for [OpenClaw](https://github.com/openclaw/openclaw). It compresses tool outputs, code, logs, and structured data inline — 70-90% token savings with zero LLM calls. The plugin can connect to a local or remote Headroom proxy and will auto-start one locally if needed.
373
 
374
  ### Install
375
 
plugins/openclaw/README.md CHANGED
@@ -9,7 +9,7 @@ pip install "headroom-ai[proxy]"
9
  openclaw plugins install --dangerously-force-unsafe-install headroom-ai/openclaw
10
  ```
11
 
12
- This plugin auto-starts `headroom proxy` when needed. OpenClaw treats process-launching plugins as unsafe by default, so `--dangerously-force-unsafe-install` is required.
13
 
14
  ## Local Development Install (Detection-Friendly)
15
 
@@ -48,20 +48,36 @@ Why this matters:
48
  }
49
  ```
50
 
51
- `proxyUrl` must be localhost (`127.0.0.1` or `localhost`). If the proxy is not running, the plugin will try to start it with `headroom proxy --host ... --port ...`.
52
- `proxyUrl` is optional. If omitted, the plugin auto-detects on:
53
  - `http://127.0.0.1:<proxyPort>`
54
  - `http://localhost:<proxyPort>`
 
55
  Default `proxyPort` is `8787`.
56
 
57
- If set, `proxyUrl` must be localhost (`127.0.0.1` or `localhost`).
58
- Auto-start launch order is:
 
59
  1. `headroom` from `PATH`
60
  2. local npm bin (`node_modules/.bin/headroom`)
61
  3. global npm bin
62
  4. Python module (`python -m headroom.cli proxy ...`)
 
63
  If `pythonPath` is set, it is tried first in the Python fallback step.
64
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
65
  ## Required Proxy Setup
66
 
67
  Optional: run Headroom proxy yourself before launching OpenClaw.
@@ -95,7 +111,7 @@ Compression is lossless via CCR (Compress-Cache-Retrieve): originals are stored
95
 
96
  | Option | Default | Description |
97
  |--------|---------|-------------|
98
- | `proxyUrl` | auto-detected | Optional URL of a Headroom proxy (`http://127.0.0.1:<port>` or `http://localhost:<port>`). |
99
  | `proxyPort` | `8787` | Port used for default auto-detect/auto-start when `proxyUrl` is not set. |
100
  | `pythonPath` | auto-detected | Optional Python executable override for Python fallback launcher. |
101
  | `autoStart` | `true` | Auto-start `headroom proxy` if not already running |
 
9
  openclaw plugins install --dangerously-force-unsafe-install headroom-ai/openclaw
10
  ```
11
 
12
+ This plugin can auto-start a local `headroom proxy` when needed. OpenClaw treats process-launching plugins as unsafe by default, so `--dangerously-force-unsafe-install` is required even if you plan to use a remote proxy (the capability is declared at install time).
13
 
14
  ## Local Development Install (Detection-Friendly)
15
 
 
48
  }
49
  ```
50
 
51
+ `proxyUrl` is optional. If omitted, the plugin auto-detects on localhost:
 
52
  - `http://127.0.0.1:<proxyPort>`
53
  - `http://localhost:<proxyPort>`
54
+
55
  Default `proxyPort` is `8787`.
56
 
57
+ ### Local proxy (auto-start)
58
+
59
+ When `proxyUrl` points to localhost (or is omitted), the plugin will auto-start `headroom proxy` if no running proxy is detected. Launch order:
60
  1. `headroom` from `PATH`
61
  2. local npm bin (`node_modules/.bin/headroom`)
62
  3. global npm bin
63
  4. Python module (`python -m headroom.cli proxy ...`)
64
+
65
  If `pythonPath` is set, it is tried first in the Python fallback step.
66
 
67
+ ### Remote proxy (connect-only)
68
+
69
+ Point `proxyUrl` to any reachable Headroom instance:
70
+
71
+ ```json
72
+ {
73
+ "config": {
74
+ "proxyUrl": "https://headroom.example.com:8787"
75
+ }
76
+ }
77
+ ```
78
+
79
+ Remote URLs are **connect-only** — the plugin probes the URL at startup and fails fast if the proxy is not reachable. No subprocess is spawned for remote addresses.
80
+
81
  ## Required Proxy Setup
82
 
83
  Optional: run Headroom proxy yourself before launching OpenClaw.
 
111
 
112
  | Option | Default | Description |
113
  |--------|---------|-------------|
114
+ | `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. |
115
  | `proxyPort` | `8787` | Port used for default auto-detect/auto-start when `proxyUrl` is not set. |
116
  | `pythonPath` | auto-detected | Optional Python executable override for Python fallback launcher. |
117
  | `autoStart` | `true` | Auto-start `headroom proxy` if not already running |
plugins/openclaw/openclaw.plugin.json CHANGED
@@ -3,7 +3,7 @@
3
  "uiHints": {
4
  "proxyUrl": {
5
  "label": "Proxy URL",
6
- "help": "Optional. URL of a Headroom proxy on localhost (example: http://127.0.0.1:8787). If omitted, plugin auto-detects defaults."
7
  },
8
  "proxyPort": {
9
  "label": "Proxy Port",
@@ -23,7 +23,7 @@
23
  },
24
  "proxyUrl": {
25
  "type": "string",
26
- "pattern": "^http:\\/\\/(localhost|127\\.0\\.0\\.1)(:\\d+)?\\/?$"
27
  },
28
  "proxyPort": {
29
  "type": "integer",
@@ -50,7 +50,9 @@
50
  "network": {
51
  "allow": [
52
  "http://localhost:*",
53
- "http://127.0.0.1:*"
 
 
54
  ]
55
  }
56
  }
 
3
  "uiHints": {
4
  "proxyUrl": {
5
  "label": "Proxy URL",
6
+ "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."
7
  },
8
  "proxyPort": {
9
  "label": "Proxy Port",
 
23
  },
24
  "proxyUrl": {
25
  "type": "string",
26
+ "pattern": "^https?:\\/\\/.+(:\\d+)?\\/?$"
27
  },
28
  "proxyPort": {
29
  "type": "integer",
 
50
  "network": {
51
  "allow": [
52
  "http://localhost:*",
53
+ "http://127.0.0.1:*",
54
+ "http://*:*",
55
+ "https://*:*"
56
  ]
57
  }
58
  }
plugins/openclaw/src/proxy-manager.ts CHANGED
@@ -1,10 +1,10 @@
1
  /**
2
- * Manages connectivity to a local Headroom proxy.
3
  *
4
  * Security model:
5
- * - Optional local process execution to auto-start Headroom proxy
 
6
  * - No environment variable access
7
- * - Localhost-only network access (127.0.0.1 / localhost)
8
  */
9
  import { spawn } from "node:child_process";
10
  import { spawnSync } from "node:child_process";
@@ -90,6 +90,14 @@ export class ProxyManager {
90
  }
91
  }
92
 
 
 
 
 
 
 
 
 
93
  if (this.config.autoStart !== false) {
94
  const startupUrl = explicitUrl ?? defaultCandidates[0];
95
  const startupProbe = probeByUrl.get(startupUrl);
@@ -305,11 +313,8 @@ export function normalizeAndValidateProxyUrl(proxyUrl: string): string {
305
  throw new Error(`Invalid proxyUrl: "${proxyUrl}"`);
306
  }
307
 
308
- if (parsed.protocol !== "http:") {
309
- throw new Error("proxyUrl must use http://");
310
- }
311
- if (parsed.hostname !== "127.0.0.1" && parsed.hostname !== "localhost") {
312
- throw new Error("proxyUrl host must be localhost or 127.0.0.1");
313
  }
314
 
315
  if (parsed.pathname !== "/" || parsed.search || parsed.hash) {
@@ -319,6 +324,16 @@ export function normalizeAndValidateProxyUrl(proxyUrl: string): string {
319
  return parsed.origin;
320
  }
321
 
 
 
 
 
 
 
 
 
 
 
322
  function withDefaultPort(proxyUrl: string, defaultPort: number): string {
323
  const parsed = new URL(proxyUrl);
324
  if (!parsed.port) {
 
1
  /**
2
+ * Manages connectivity to a Headroom proxy (local or remote).
3
  *
4
  * Security model:
5
+ * - Local proxies (127.0.0.1 / localhost) can be auto-started via subprocess
6
+ * - Remote proxies are connect-only: probe and use, never launch
7
  * - No environment variable access
 
8
  */
9
  import { spawn } from "node:child_process";
10
  import { spawnSync } from "node:child_process";
 
90
  }
91
  }
92
 
93
+ // Remote URLs are connect-only — never auto-start a subprocess for them
94
+ if (explicitUrl && !isLocalProxyUrl(explicitUrl)) {
95
+ throw new Error(
96
+ `Remote Headroom proxy not reachable at ${explicitUrl}. Ensure the proxy is running at that address.`,
97
+ );
98
+ }
99
+
100
+ // Auto-start is only available for local proxies
101
  if (this.config.autoStart !== false) {
102
  const startupUrl = explicitUrl ?? defaultCandidates[0];
103
  const startupProbe = probeByUrl.get(startupUrl);
 
313
  throw new Error(`Invalid proxyUrl: "${proxyUrl}"`);
314
  }
315
 
316
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
317
+ throw new Error("proxyUrl must use http:// or https://");
 
 
 
318
  }
319
 
320
  if (parsed.pathname !== "/" || parsed.search || parsed.hash) {
 
324
  return parsed.origin;
325
  }
326
 
327
+ /** Returns true if the URL points to a local address (localhost or 127.0.0.1). */
328
+ export function isLocalProxyUrl(proxyUrl: string): boolean {
329
+ try {
330
+ const parsed = new URL(proxyUrl);
331
+ return parsed.hostname === "127.0.0.1" || parsed.hostname === "localhost";
332
+ } catch {
333
+ return false;
334
+ }
335
+ }
336
+
337
  function withDefaultPort(proxyUrl: string, defaultPort: number): string {
338
  const parsed = new URL(proxyUrl);
339
  if (!parsed.port) {
plugins/openclaw/test/proxy-manager.test.ts CHANGED
@@ -2,6 +2,7 @@ import { describe, it, expect, afterEach, vi } from "vitest";
2
  import {
3
  ProxyManager,
4
  normalizeAndValidateProxyUrl,
 
5
  probeHeadroomProxy,
6
  } from "../src/proxy-manager.js";
7
 
@@ -15,19 +16,38 @@ describe("normalizeAndValidateProxyUrl", () => {
15
  expect(normalizeAndValidateProxyUrl("http://localhost:8787")).toBe("http://localhost:8787");
16
  });
17
 
18
- it("rejects non-local and malformed URLs", () => {
19
- expect(() => normalizeAndValidateProxyUrl("https://localhost:8787")).toThrow(
 
 
 
 
 
 
20
  /must use http/,
21
  );
22
- expect(() => normalizeAndValidateProxyUrl("http://example.com:8787")).toThrow(
23
- /must be localhost/,
24
- );
25
  expect(() => normalizeAndValidateProxyUrl("http://localhost:8787/path")).toThrow(
26
  /must not include a path/,
27
  );
28
  });
29
  });
30
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
31
  describe("probeHeadroomProxy", () => {
32
  it("returns reachable+isHeadroom when both endpoints succeed", async () => {
33
  const fetchMock = vi
@@ -120,6 +140,33 @@ describe("ProxyManager.start", () => {
120
  expect(startSpy).toHaveBeenCalledWith("http://127.0.0.1:8787", 8787);
121
  });
122
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
123
  it("auto-starts when nothing is detected", async () => {
124
  const manager = new ProxyManager({ autoStart: true });
125
  const startSpy = vi.spyOn(manager as any, "startHeadroomProxy").mockResolvedValue(undefined);
 
2
  import {
3
  ProxyManager,
4
  normalizeAndValidateProxyUrl,
5
+ isLocalProxyUrl,
6
  probeHeadroomProxy,
7
  } from "../src/proxy-manager.js";
8
 
 
16
  expect(normalizeAndValidateProxyUrl("http://localhost:8787")).toBe("http://localhost:8787");
17
  });
18
 
19
+ it("accepts remote URLs", () => {
20
+ expect(normalizeAndValidateProxyUrl("http://example.com:8787")).toBe("http://example.com:8787");
21
+ expect(normalizeAndValidateProxyUrl("https://headroom.example.com")).toBe("https://headroom.example.com");
22
+ expect(normalizeAndValidateProxyUrl("https://headroom.example.com:9090")).toBe("https://headroom.example.com:9090");
23
+ });
24
+
25
+ it("rejects malformed URLs", () => {
26
+ expect(() => normalizeAndValidateProxyUrl("ftp://localhost:8787")).toThrow(
27
  /must use http/,
28
  );
 
 
 
29
  expect(() => normalizeAndValidateProxyUrl("http://localhost:8787/path")).toThrow(
30
  /must not include a path/,
31
  );
32
  });
33
  });
34
 
35
+ describe("isLocalProxyUrl", () => {
36
+ it("returns true for localhost addresses", () => {
37
+ expect(isLocalProxyUrl("http://127.0.0.1:8787")).toBe(true);
38
+ expect(isLocalProxyUrl("http://localhost:8787")).toBe(true);
39
+ });
40
+
41
+ it("returns false for remote addresses", () => {
42
+ expect(isLocalProxyUrl("http://example.com:8787")).toBe(false);
43
+ expect(isLocalProxyUrl("https://headroom.example.com")).toBe(false);
44
+ });
45
+
46
+ it("returns false for invalid URLs", () => {
47
+ expect(isLocalProxyUrl("not-a-url")).toBe(false);
48
+ });
49
+ });
50
+
51
  describe("probeHeadroomProxy", () => {
52
  it("returns reachable+isHeadroom when both endpoints succeed", async () => {
53
  const fetchMock = vi
 
140
  expect(startSpy).toHaveBeenCalledWith("http://127.0.0.1:8787", 8787);
141
  });
142
 
143
+ it("connects to remote proxy without auto-start", async () => {
144
+ const manager = new ProxyManager({ proxyUrl: "http://headroom.remote.example:8787", autoStart: true });
145
+ const startSpy = vi.spyOn(manager as any, "startHeadroomProxy").mockResolvedValue(undefined);
146
+
147
+ // Remote probe succeeds
148
+ const fetchMock = vi
149
+ .fn()
150
+ .mockResolvedValueOnce({ ok: true, status: 200 }) // /health
151
+ .mockResolvedValueOnce({ ok: true, status: 200 }); // /v1/retrieve/stats
152
+ vi.stubGlobal("fetch", fetchMock);
153
+
154
+ const url = await manager.start();
155
+ expect(url).toBe("http://headroom.remote.example:8787");
156
+ expect(startSpy).not.toHaveBeenCalled();
157
+ });
158
+
159
+ it("fails fast for unreachable remote proxy without attempting auto-start", async () => {
160
+ const manager = new ProxyManager({ proxyUrl: "https://headroom.remote.example:8787", autoStart: true });
161
+ const startSpy = vi.spyOn(manager as any, "startHeadroomProxy").mockResolvedValue(undefined);
162
+
163
+ const fetchMock = vi.fn().mockRejectedValue(new Error("ECONNREFUSED"));
164
+ vi.stubGlobal("fetch", fetchMock);
165
+
166
+ await expect(manager.start()).rejects.toThrow(/Remote Headroom proxy not reachable/);
167
+ expect(startSpy).not.toHaveBeenCalled();
168
+ });
169
+
170
  it("auto-starts when nothing is detected", async () => {
171
  const manager = new ProxyManager({ autoStart: true });
172
  const startSpy = vi.spyOn(manager as any, "startHeadroomProxy").mockResolvedValue(undefined);