Spaces:
Build error
Build error
Commit ·
0963758
1
Parent(s): db23f5d
feat(openclaw): support remote Headroom proxy (connect-only)
Browse filesRemove 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-
|
| 13 |
|
| 14 |
## Local Development Install (Detection-Friendly)
|
| 15 |
|
|
@@ -48,20 +48,36 @@ Why this matters:
|
|
| 48 |
}
|
| 49 |
```
|
| 50 |
|
| 51 |
-
`proxyUrl`
|
| 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 |
-
|
| 58 |
-
|
|
|
|
| 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>`
|
| 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
|
| 7 |
},
|
| 8 |
"proxyPort": {
|
| 9 |
"label": "Proxy Port",
|
|
@@ -23,7 +23,7 @@
|
|
| 23 |
},
|
| 24 |
"proxyUrl": {
|
| 25 |
"type": "string",
|
| 26 |
-
"pattern": "^
|
| 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
|
| 3 |
*
|
| 4 |
* Security model:
|
| 5 |
-
* -
|
|
|
|
| 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("
|
| 19 |
-
expect(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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);
|