Spaces:
Build error
Build error
Commit ·
b149c75
1
Parent(s): 44e938e
Harden openclaw plugin proxy handling and metadata
Browse files- plugins/openclaw/.npmignore +2 -0
- plugins/openclaw/README.md +29 -6
- plugins/openclaw/openclaw.plugin.json +11 -22
- plugins/openclaw/package-lock.json +5 -2
- plugins/openclaw/package.json +15 -1
- plugins/openclaw/src/engine.ts +1 -1
- plugins/openclaw/src/index.ts +1 -1
- plugins/openclaw/src/plugin/index.ts +12 -5
- plugins/openclaw/src/proxy-manager.ts +71 -223
- plugins/openclaw/src/tools/headroom-retrieve.ts +5 -2
- plugins/openclaw/test/engine.test.ts +33 -6
- plugins/openclaw/tsconfig.json +1 -1
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
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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` |
|
| 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
|
| 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": "
|
| 3 |
"version": "0.1.0",
|
| 4 |
"lockfileVersion": 3,
|
| 5 |
"requires": true,
|
| 6 |
"packages": {
|
| 7 |
"": {
|
| 8 |
-
"name": "
|
| 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
|
| 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
|
| 38 |
-
|
| 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
|
| 3 |
*
|
| 4 |
-
*
|
| 5 |
-
* -
|
| 6 |
-
* -
|
|
|
|
| 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 |
-
|
| 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 |
-
"
|
| 111 |
-
|
| 112 |
);
|
| 113 |
}
|
| 114 |
|
| 115 |
-
const
|
| 116 |
-
const
|
| 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 (
|
| 145 |
-
|
| 146 |
-
|
|
|
|
| 147 |
}
|
| 148 |
|
| 149 |
-
|
| 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 |
-
`
|
| 166 |
);
|
| 167 |
}
|
| 168 |
|
| 169 |
-
|
| 170 |
-
this.logger.info(`Proxy started on port ${actualPort} (PID: ${proc.pid})`);
|
| 171 |
-
this.startHealthMonitor();
|
| 172 |
-
return url;
|
| 173 |
}
|
| 174 |
|
| 175 |
/**
|
| 176 |
-
*
|
| 177 |
*/
|
| 178 |
async stop(): Promise<void> {
|
| 179 |
-
this.
|
| 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 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
} catch {
|
| 215 |
-
return false;
|
| 216 |
-
}
|
| 217 |
}
|
| 218 |
|
| 219 |
-
|
| 220 |
-
|
| 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 |
-
|
| 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 |
-
|
| 239 |
-
|
| 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 |
-
|
| 255 |
-
|
| 256 |
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
} catch {
|
| 263 |
-
continue;
|
| 264 |
-
}
|
| 265 |
-
}
|
| 266 |
-
return null;
|
| 267 |
-
}
|
| 268 |
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
});
|
| 276 |
-
return true;
|
| 277 |
-
} catch {
|
| 278 |
-
return false;
|
| 279 |
}
|
|
|
|
|
|
|
| 280 |
}
|
| 281 |
|
| 282 |
-
|
| 283 |
-
const
|
| 284 |
-
|
| 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 |
-
? `${
|
| 49 |
-
: `${
|
| 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("
|
| 123 |
-
const manager = new ProxyManager({
|
| 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({
|
| 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,
|