diff --git a/plugins/openclaw/.npmignore b/plugins/openclaw/.npmignore new file mode 100644 index 000000000..713d5006d --- /dev/null +++ b/plugins/openclaw/.npmignore @@ -0,0 +1,2 @@ +node_modules/ +.env diff --git a/plugins/openclaw/README.md b/plugins/openclaw/README.md index 9287e49c0..e4249446e 100644 --- a/plugins/openclaw/README.md +++ b/plugins/openclaw/README.md @@ -6,7 +6,7 @@ Context compression plugin for [OpenClaw](https://github.com/openclaw/openclaw). ```bash pip install "headroom-ai[proxy]" -openclaw plugins install @headroom-ai/openclaw +openclaw plugins install headroom-ai/openclaw ``` ## Configure @@ -14,6 +14,14 @@ openclaw plugins install @headroom-ai/openclaw ```json { "plugins": { + "entries": { + "headroom": { + "enabled": true, + "config": { + "proxyUrl": "http://127.0.0.1:8787" + } + } + }, "slots": { "contextEngine": "headroom" } @@ -21,7 +29,25 @@ openclaw plugins install @headroom-ai/openclaw } ``` -That's it. The plugin auto-starts the Headroom proxy if it's not already running. +`proxyUrl` is required and must be localhost (`127.0.0.1` or `localhost`). The plugin never starts processes and only connects to the configured local proxy. + +## Required Proxy Setup + +Run Headroom proxy yourself before launching OpenClaw. + +Python install: + +```bash +pip install "headroom-ai[proxy]" +headroom proxy --host 127.0.0.1 --port 8787 +``` + +NPM install: + +```bash +npm install -g headroom-ai +headroom proxy --host 127.0.0.1 --port 8787 +``` ## How It Works @@ -38,10 +64,7 @@ Compression is lossless via CCR (Compress-Cache-Retrieve): originals are stored | Option | Default | Description | |--------|---------|-------------| -| `proxyUrl` | auto-detected | URL of the Headroom proxy | -| `autoStart` | `true` | Start proxy automatically if not running | -| `pythonPath` | auto-detected | Path to Python binary | -| `proxyPort` | `8787` | Port for auto-started proxy | +| `proxyUrl` | required | URL of an already running Headroom proxy (`http://127.0.0.1:` or `http://localhost:`) | ## Comparison with lossless-claw diff --git a/plugins/openclaw/openclaw.plugin.json b/plugins/openclaw/openclaw.plugin.json index 36ed77203..3a0dd76db 100644 --- a/plugins/openclaw/openclaw.plugin.json +++ b/plugins/openclaw/openclaw.plugin.json @@ -3,15 +3,7 @@ "uiHints": { "proxyUrl": { "label": "Proxy URL", - "help": "URL of the Headroom proxy (auto-detected or auto-started if not set)" - }, - "pythonPath": { - "label": "Python Path", - "help": "Path to Python binary (auto-detected if not set)" - }, - "autoStart": { - "label": "Auto-Start Proxy", - "help": "Automatically start the Headroom proxy if not already running" + "help": "Required. URL of an already running Headroom proxy on localhost (example: http://127.0.0.1:8787)" } }, "configSchema": { @@ -22,20 +14,17 @@ "type": "boolean" }, "proxyUrl": { - "type": "string" - }, - "pythonPath": { - "type": "string" - }, - "autoStart": { - "type": "boolean", - "default": true - }, - "proxyPort": { - "type": "integer", - "minimum": 0, - "maximum": 65535 + "type": "string", + "pattern": "^http:\\/\\/(localhost|127\\.0\\.0\\.1)(:\\d+)?$" } } + }, + "capabilities": { + "network": { + "allow": [ + "http://localhost:*", + "http://127.0.0.1:*" + ] + } } } diff --git a/plugins/openclaw/package-lock.json b/plugins/openclaw/package-lock.json index 5e874af11..f0b7e2281 100644 --- a/plugins/openclaw/package-lock.json +++ b/plugins/openclaw/package-lock.json @@ -1,11 +1,11 @@ { - "name": "@headroom-ai/openclaw", + "name": "headroom-openclaw", "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "@headroom-ai/openclaw", + "name": "headroom-openclaw", "version": "0.1.0", "license": "Apache-2.0", "dependencies": { @@ -16,6 +16,9 @@ "typescript": "^5.5.0", "vitest": "^2.0.0" }, + "engines": { + "node": ">=20" + }, "peerDependencies": { "openclaw": "*" }, diff --git a/plugins/openclaw/package.json b/plugins/openclaw/package.json index 2e34eba48..b1b348299 100644 --- a/plugins/openclaw/package.json +++ b/plugins/openclaw/package.json @@ -33,9 +33,23 @@ "vitest": "^2.0.0" }, "openclaw": { + "hooks": { + "contextEngine": "dist/index.js", + "tools": [ + "dist/index.js" + ] + }, "extensions": [ "./dist/index.js" - ] + ], + "capabilities": { + "network": { + "allow": [ + "http://localhost:*", + "http://127.0.0.1:*" + ] + } + } }, "license": "Apache-2.0" } diff --git a/plugins/openclaw/src/engine.ts b/plugins/openclaw/src/engine.ts index 7b24b917e..73ed16366 100644 --- a/plugins/openclaw/src/engine.ts +++ b/plugins/openclaw/src/engine.ts @@ -114,7 +114,7 @@ export class HeadroomContextEngine { baseUrl: this.proxyUrl, fallback: true, tokenBudget: params.tokenBudget, - }); + } as any); if (!result.compressed || result.tokensSaved === 0) { return { messages: params.messages, estimatedTokens: result.tokensBefore }; diff --git a/plugins/openclaw/src/index.ts b/plugins/openclaw/src/index.ts index 266942e86..7db0a5fc0 100644 --- a/plugins/openclaw/src/index.ts +++ b/plugins/openclaw/src/index.ts @@ -1,5 +1,5 @@ export { default } from "./plugin/index.js"; export { HeadroomContextEngine } from "./engine.js"; -export { ProxyManager } from "./proxy-manager.js"; +export { ProxyManager, normalizeAndValidateProxyUrl, probeHeadroomProxy } from "./proxy-manager.js"; export { agentToOpenAI, openAIToAgent } from "./convert.js"; export { createHeadroomRetrieveTool } from "./tools/headroom-retrieve.js"; diff --git a/plugins/openclaw/src/plugin/index.ts b/plugins/openclaw/src/plugin/index.ts index 560339904..1c81f0e68 100644 --- a/plugins/openclaw/src/plugin/index.ts +++ b/plugins/openclaw/src/plugin/index.ts @@ -2,7 +2,7 @@ * Headroom OpenClaw Plugin — register ContextEngine + CCR retrieval tool. * * Usage: - * openclaw plugins install @headroom-ai/openclaw + * openclaw plugins install headroom-ai/openclaw * * Configuration (in ~/.openclaw/config.json or ~/.clawdbot/clawdbot.json): * { @@ -16,13 +16,21 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { HeadroomContextEngine } from "../engine.js"; +import { normalizeAndValidateProxyUrl } from "../proxy-manager.js"; import { createHeadroomRetrieveTool } from "../tools/headroom-retrieve.js"; export default function headroomPlugin(api: any) { const config = api.config?.plugins?.entries?.headroom?.config ?? {}; const logger = api.logger ?? console; + const rawProxyUrl = config.proxyUrl; + if (!rawProxyUrl || typeof rawProxyUrl !== "string") { + throw new Error( + '[headroom] Missing required config: plugins.entries.headroom.config.proxyUrl (example: "http://127.0.0.1:8787")', + ); + } + const proxyUrl = normalizeAndValidateProxyUrl(rawProxyUrl); - const engine = new HeadroomContextEngine(config, { + const engine = new HeadroomContextEngine({ ...config, proxyUrl }, { info: (m: string) => logger.info(m), warn: (m: string) => logger.warn(m), error: (m: string) => logger.error(m), @@ -34,9 +42,8 @@ export default function headroomPlugin(api: any) { // Register CCR retrieval tool (active once proxy is running) api.registerTool((ctx: any) => { - const proxyUrl = engine.getProxyUrl(); - if (!proxyUrl) return null; - return createHeadroomRetrieveTool({ proxyUrl }); + const activeProxyUrl = engine.getProxyUrl() ?? proxyUrl; + return createHeadroomRetrieveTool({ proxyUrl: activeProxyUrl }); }); logger.info("[headroom] Plugin registered"); diff --git a/plugins/openclaw/src/proxy-manager.ts b/plugins/openclaw/src/proxy-manager.ts index 42f797dae..f8b5be8aa 100644 --- a/plugins/openclaw/src/proxy-manager.ts +++ b/plugins/openclaw/src/proxy-manager.ts @@ -1,27 +1,14 @@ /** - * Manages the Headroom proxy process lifecycle. + * Manages connectivity to an externally managed Headroom proxy. * - * - Detects if a proxy is already running (e.g., user has `headroom proxy` for Claude Code) - * - If not, spawns one as a child process with auto-assigned port - * - Health checks, restart on crash, graceful shutdown + * Security model: + * - No process execution + * - No environment variable access + * - Localhost-only network access (127.0.0.1 / localhost) */ -import { spawn, type ChildProcess } from "node:child_process"; -import { createWriteStream } from "node:fs"; -import { join } from "node:path"; -import { homedir } from "node:os"; - -const DEFAULT_PORT = 8787; -const HEALTH_CHECK_INTERVAL_MS = 30_000; -const STARTUP_TIMEOUT_MS = 15_000; -const RESTART_DELAY_MS = 2_000; -const MAX_RESTART_ATTEMPTS = 3; - export interface ProxyManagerConfig { proxyUrl?: string; - pythonPath?: string; - autoStart?: boolean; - proxyPort?: number; } export interface ProxyManagerLogger { @@ -31,6 +18,12 @@ export interface ProxyManagerLogger { debug(message: string): void; } +export interface ProxyProbeResult { + reachable: boolean; + isHeadroom: boolean; + reason?: string; +} + const defaultLogger: ProxyManagerLogger = { info: (m) => console.log(`[headroom] ${m}`), warn: (m) => console.warn(`[headroom] ${m}`), @@ -41,12 +34,7 @@ const defaultLogger: ProxyManagerLogger = { export class ProxyManager { private config: ProxyManagerConfig; private logger: ProxyManagerLogger; - private process: ChildProcess | null = null; private proxyUrl: string | null = null; - private weStartedIt = false; - private restartCount = 0; - private healthInterval: ReturnType | null = null; - private disposed = false; constructor(config: ProxyManagerConfig = {}, logger?: ProxyManagerLogger) { this.config = config; @@ -54,149 +42,39 @@ export class ProxyManager { } /** - * Ensure a proxy is available. Returns the URL. - * - * 1. If proxyUrl is configured, use it - * 2. Check if proxy is already running on default port - * 3. If autoStart, spawn one + * Ensure a proxy is available. Returns the normalized URL origin. */ async start(): Promise { - // Option 1: Explicit URL configured - if (this.config.proxyUrl) { - const url = this.config.proxyUrl.replace(/\/+$/, ""); - if (await this.healthCheck(url)) { - this.proxyUrl = url; - this.logger.info(`Connected to proxy at ${url}`); - return url; - } - throw new Error(`Headroom proxy not reachable at ${url}`); + if (!this.config.proxyUrl) { + throw new Error( + "Headroom proxy URL is required. Configure plugins.entries.headroom.config.proxyUrl " + + '(example: "http://127.0.0.1:8787").', + ); } - // Option 2: Check default port - const defaultUrl = `http://127.0.0.1:${DEFAULT_PORT}`; - if (await this.healthCheck(defaultUrl)) { - this.proxyUrl = defaultUrl; - this.logger.info(`Found running proxy at ${defaultUrl}`); - this.startHealthMonitor(); - return defaultUrl; + const url = normalizeAndValidateProxyUrl(this.config.proxyUrl); + const probe = await probeHeadroomProxy(url); + + if (probe.reachable && probe.isHeadroom) { + this.proxyUrl = url; + this.logger.info(`Headroom proxy already running at ${url}`); + return url; } - // Option 3: Auto-start - if (this.config.autoStart !== false) { - return this.spawnProxy(); + if (probe.reachable && !probe.isHeadroom) { + throw new Error( + `Service reachable at ${url}, but it does not appear to be a Headroom proxy (${probe.reason ?? "unknown service"}).`, + ); } - throw new Error( - "Headroom proxy not running. Start with: headroom proxy --port 8787\n" + - "Or install: pip install 'headroom-ai[proxy]'", - ); + throw new Error(`Headroom proxy not reachable at ${url}. Ensure the proxy is running first.`); } /** - * Spawn the headroom proxy as a child process. - */ - private async spawnProxy(): Promise { - const pythonPath = await this.findPython(); - if (!pythonPath) { - throw new Error( - "Python not found. Install Python 3.10+ and run: pip install 'headroom-ai[proxy]'", - ); - } - - // Check if headroom-ai is installed - const installed = await this.checkHeadroomInstalled(pythonPath); - if (!installed) { - throw new Error( - "headroom-ai Python package not found.\n" + - "Install with: pip install 'headroom-ai[proxy]'", - ); - } - - const port = this.config.proxyPort ?? 0; // 0 = OS picks a free port - const actualPort = port === 0 ? await this.findFreePort() : port; - const url = `http://127.0.0.1:${actualPort}`; - - this.logger.info(`Starting proxy on port ${actualPort}...`); - - // Log file - const logDir = join(homedir(), ".headroom", "logs"); - const logPath = join(logDir, "openclaw-proxy.log"); - - let logStream: ReturnType | null = null; - try { - const { mkdirSync } = await import("node:fs"); - mkdirSync(logDir, { recursive: true }); - logStream = createWriteStream(logPath, { flags: "a" }); - } catch { - // Can't create log file — use /dev/null - } - - const proc = spawn( - pythonPath, - ["-m", "headroom.cli", "proxy", "--port", String(actualPort)], - { - env: { ...process.env, PYTHONIOENCODING: "utf-8" }, - stdio: ["ignore", logStream ? "pipe" : "ignore", logStream ? "pipe" : "ignore"], - detached: false, - }, - ); - - if (logStream) { - proc.stdout?.pipe(logStream); - proc.stderr?.pipe(logStream); - } - - proc.on("exit", (code) => { - if (!this.disposed && this.weStartedIt) { - this.logger.warn(`Proxy exited with code ${code}`); - this.handleCrash(); - } - }); - - this.process = proc; - this.weStartedIt = true; - - // Wait for healthy - const healthy = await this.waitForHealthy(url, STARTUP_TIMEOUT_MS); - if (!healthy) { - proc.kill(); - this.process = null; - throw new Error( - `Proxy failed to start within ${STARTUP_TIMEOUT_MS / 1000}s. Check ${logPath}`, - ); - } - - this.proxyUrl = url; - this.logger.info(`Proxy started on port ${actualPort} (PID: ${proc.pid})`); - this.startHealthMonitor(); - return url; - } - - /** - * Stop the proxy if we started it. + * No-op: plugin never starts or manages external processes. */ async stop(): Promise { - this.disposed = true; - if (this.healthInterval) { - clearInterval(this.healthInterval); - this.healthInterval = null; - } - if (this.process && this.weStartedIt) { - this.logger.info("Stopping proxy..."); - this.process.kill("SIGTERM"); - // Give it 3s to shutdown gracefully - await new Promise((resolve) => { - const timeout = setTimeout(() => { - this.process?.kill("SIGKILL"); - resolve(); - }, 3000); - this.process?.on("exit", () => { - clearTimeout(timeout); - resolve(); - }); - }); - this.process = null; - } + this.proxyUrl = null; } getUrl(): string | null { @@ -204,94 +82,64 @@ export class ProxyManager { } // --- Internal --- +} - private async healthCheck(url: string): Promise { - try { - const resp = await fetch(`${url}/health`, { - signal: AbortSignal.timeout(3000), - }); - return resp.ok; - } catch { - return false; - } +export function normalizeAndValidateProxyUrl(proxyUrl: string): string { + let parsed: URL; + try { + parsed = new URL(proxyUrl); + } catch { + throw new Error(`Invalid proxyUrl: "${proxyUrl}"`); } - private async waitForHealthy(url: string, timeoutMs: number): Promise { - const start = Date.now(); - while (Date.now() - start < timeoutMs) { - if (await this.healthCheck(url)) return true; - await new Promise((r) => setTimeout(r, 500)); - } - return false; + if (parsed.protocol !== "http:") { + throw new Error("proxyUrl must use http://"); + } + if (parsed.hostname !== "127.0.0.1" && parsed.hostname !== "localhost") { + throw new Error("proxyUrl host must be localhost or 127.0.0.1"); } - private startHealthMonitor(): void { - if (this.healthInterval) return; - this.healthInterval = setInterval(async () => { - if (this.proxyUrl && !(await this.healthCheck(this.proxyUrl))) { - this.logger.warn("Proxy health check failed"); - if (this.weStartedIt) this.handleCrash(); - } - }, HEALTH_CHECK_INTERVAL_MS); + if (parsed.pathname !== "/" || parsed.search || parsed.hash) { + throw new Error("proxyUrl must not include a path, query, or hash"); } - private async handleCrash(): Promise { - if (this.disposed) return; - if (this.restartCount >= MAX_RESTART_ATTEMPTS) { - this.logger.error(`Proxy crashed ${MAX_RESTART_ATTEMPTS} times. Giving up.`); - return; - } - this.restartCount++; - this.logger.info(`Restarting proxy (attempt ${this.restartCount}/${MAX_RESTART_ATTEMPTS})...`); - await new Promise((r) => setTimeout(r, RESTART_DELAY_MS)); - try { - await this.spawnProxy(); - } catch (e) { - this.logger.error(`Restart failed: ${e}`); - } - } + return parsed.origin; +} - private async findPython(): Promise { - if (this.config.pythonPath) return this.config.pythonPath; +/** + * Probe a configured URL and verify whether it is a running Headroom proxy. + */ +export async function probeHeadroomProxy(proxyUrl: string): Promise { + const origin = normalizeAndValidateProxyUrl(proxyUrl); - for (const cmd of ["python3", "python"]) { - try { - const { execSync } = await import("node:child_process"); - const version = execSync(`${cmd} --version 2>&1`, { encoding: "utf-8" }).trim(); - if (version.includes("Python 3.")) return cmd; - } catch { - continue; - } - } - return null; - } - - private async checkHeadroomInstalled(pythonPath: string): Promise { - try { - const { execSync } = await import("node:child_process"); - execSync(`${pythonPath} -c "import headroom"`, { - encoding: "utf-8", - stdio: "pipe", - }); - return true; - } catch { - return false; - } - } - - private async findFreePort(): Promise { - const { createServer } = await import("node:net"); - return new Promise((resolve, reject) => { - const server = createServer(); - server.listen(0, () => { - const addr = server.address(); - if (addr && typeof addr === "object") { - const port = addr.port; - server.close(() => resolve(port)); - } else { - reject(new Error("Could not find free port")); - } - }); + try { + const health = await fetch(`${origin}/health`, { + signal: AbortSignal.timeout(3_000), }); + if (!health.ok) { + return { reachable: false, isHeadroom: false, reason: `health HTTP ${health.status}` }; + } + } catch { + return { reachable: false, isHeadroom: false, reason: "health check failed" }; + } + + try { + const retrieveStats = await fetch(`${origin}/v1/retrieve/stats`, { + signal: AbortSignal.timeout(3_000), + }); + if (retrieveStats.ok) { + return { reachable: true, isHeadroom: true }; + } + return { + reachable: true, + isHeadroom: false, + reason: `retrieve stats HTTP ${retrieveStats.status}`, + }; + } catch { + return { + reachable: true, + isHeadroom: false, + reason: "retrieve stats endpoint unavailable", + }; } } diff --git a/plugins/openclaw/src/tools/headroom-retrieve.ts b/plugins/openclaw/src/tools/headroom-retrieve.ts index 7373a0b07..28f2187e5 100644 --- a/plugins/openclaw/src/tools/headroom-retrieve.ts +++ b/plugins/openclaw/src/tools/headroom-retrieve.ts @@ -6,12 +6,15 @@ */ /* eslint-disable @typescript-eslint/no-explicit-any */ +import { normalizeAndValidateProxyUrl } from "../proxy-manager.js"; export interface RetrieveToolConfig { proxyUrl: string; } export function createHeadroomRetrieveTool(config: RetrieveToolConfig) { + const proxyOrigin = normalizeAndValidateProxyUrl(config.proxyUrl); + return { name: "headroom_retrieve", description: @@ -45,8 +48,8 @@ export function createHeadroomRetrieveTool(config: RetrieveToolConfig) { try { const url = query - ? `${config.proxyUrl}/v1/retrieve/${hash}?query=${encodeURIComponent(query)}` - : `${config.proxyUrl}/v1/retrieve/${hash}`; + ? `${proxyOrigin}/v1/retrieve/${hash}?query=${encodeURIComponent(query)}` + : `${proxyOrigin}/v1/retrieve/${hash}`; const resp = await fetch(url, { signal: AbortSignal.timeout(10_000), diff --git a/plugins/openclaw/test/engine.test.ts b/plugins/openclaw/test/engine.test.ts index 686f18adc..d23f9fbc8 100644 --- a/plugins/openclaw/test/engine.test.ts +++ b/plugins/openclaw/test/engine.test.ts @@ -7,12 +7,39 @@ * Requires: Python 3 + headroom-ai[proxy] installed * Run: HEADROOM_INTEGRATION=1 npx vitest run test/engine.test.ts */ -import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { describe, it, expect, beforeAll, afterAll, vi, afterEach } from "vitest"; import { HeadroomContextEngine } from "../src/engine.js"; import { agentToOpenAI, openAIToAgent } from "../src/convert.js"; -import { ProxyManager } from "../src/proxy-manager.js"; +import { ProxyManager, probeHeadroomProxy } from "../src/proxy-manager.js"; const RUN = process.env.HEADROOM_INTEGRATION === "1"; +const PROXY_URL = process.env.HEADROOM_PROXY_URL ?? "http://127.0.0.1:8787"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("Proxy probing", () => { + it("detects running Headroom proxy", async () => { + const fetchMock = vi.fn() + .mockResolvedValueOnce({ ok: true, status: 200 }) + .mockResolvedValueOnce({ ok: true, status: 200 }); + vi.stubGlobal("fetch", fetchMock); + + const result = await probeHeadroomProxy("http://127.0.0.1:8787"); + expect(result).toEqual({ reachable: true, isHeadroom: true }); + }); + + it("flags non-headroom service at configured URL", async () => { + const fetchMock = vi.fn() + .mockResolvedValueOnce({ ok: true, status: 200 }) + .mockResolvedValueOnce({ ok: false, status: 404 }); + vi.stubGlobal("fetch", fetchMock); + + const manager = new ProxyManager({ proxyUrl: "http://127.0.0.1:8787" }); + await expect(manager.start()).rejects.toThrow(/does not appear to be a Headroom proxy/); + }); +}); describe("AgentMessage conversion", () => { it("converts user message", () => { @@ -119,11 +146,11 @@ describe("AgentMessage conversion", () => { }); describe.skipIf(!RUN)("ProxyManager", () => { - it("detects running proxy or starts one", { timeout: 30000 }, async () => { - const manager = new ProxyManager({ autoStart: true }); + it("connects to configured proxy URL", { timeout: 30000 }, async () => { + const manager = new ProxyManager({ proxyUrl: PROXY_URL }); try { const url = await manager.start(); - expect(url).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/); + expect(url).toMatch(/^http:\/\/(127\.0\.0\.1|localhost):\d+$/); // Verify health const resp = await fetch(`${url}/health`); @@ -138,7 +165,7 @@ describe.skipIf(!RUN)("HeadroomContextEngine", () => { let engine: HeadroomContextEngine; beforeAll(async () => { - engine = new HeadroomContextEngine({ autoStart: true }); + engine = new HeadroomContextEngine({ proxyUrl: PROXY_URL }); await engine.bootstrap({ sessionId: "test-session", sessionFile: "/tmp/test-session.jsonl", diff --git a/plugins/openclaw/tsconfig.json b/plugins/openclaw/tsconfig.json index 7fad3d0c2..2095c82f5 100644 --- a/plugins/openclaw/tsconfig.json +++ b/plugins/openclaw/tsconfig.json @@ -3,7 +3,7 @@ "target": "ES2022", "module": "ESNext", "moduleResolution": "bundler", - "lib": ["ES2022"], + "lib": ["ES2022", "DOM"], "strict": true, "esModuleInterop": true, "skipLibCheck": true,