diff --git a/mcp/README.md b/mcp/README.md new file mode 100644 index 000000000..4add21dcb --- /dev/null +++ b/mcp/README.md @@ -0,0 +1,107 @@ +# ClawBox MCP + +The agent's interface to OpenClaw OS. `clawbox-mcp.ts` is a [Model Context +Protocol](https://modelcontextprotocol.io) server (stdio transport) that +exposes ~45 tools so the OpenClaw agent can drive the device — shell, files, +web, browser automation, the desktop, system control, code projects, and a +full Claude-Code-style coding suite. + +```text +agent ──stdio (MCP)──▶ clawbox-mcp.ts ──HTTP(Bearer)──▶ Next.js /setup-api/* ──▶ device + clawbox-cli.ts (same backend, shell-callable) +``` + +- **`clawbox-mcp.ts`** — the full MCP server. Spawned as a stdio subprocess of + the OpenClaw gateway. +- **`clawbox-cli.ts`** — a thin shell wrapper over a subset of the same + `/setup-api/*` calls, for when the agent only has `exec` (no MCP tool + calling). `clawbox webapp create`, `app open`, `notify`, `system info`, + `code …`. + +## Authentication + +`/setup-api/*` is gated by `src/middleware.ts` once setup completes. Service +callers (the MCP/CLI have no session cookie) authenticate with a per-install +**bearer token**: + +- **Source of truth:** `data/.mcp-token` (mode 0600), minted on first read by + `src/lib/mcp-token.ts`. Override via the `CLAWBOX_MCP_TOKEN` env var. +- **Injection:** both the MCP server and the CLI send + `Authorization: Bearer ` on every call. The MCP reads it from its env + (`scripts/gateway-pre-start.sh` injects it); the CLI reads `CLAWBOX_MCP_TOKEN` + and falls back to the `data/.mcp-token` file (it's launched separately and + may not inherit the env). +- **Verification:** `middleware.ts` → `verifyMcpBearer()` (constant-time), + scoped to `/setup-api/*` only. + +> Without the bearer, every call is `307`-redirected to `/login`: POSTs surface +> as `405`, GETs return the login HTML that `JSON.parse` chokes on with +> **"Failed to parse JSON"**. If you see that, your token is missing or wrong — +> run the `clawbox_health` tool. + +## Tool catalog (~45) + +| Category | Tools | +|----------|-------| +| **Diagnostics** | `clawbox_health` (token + API reachability), `clawbox_context` (field guide) | +| **Shell** | `bash` (dangerous commands blocked by default — see below), `task_status` | +| **Files** | `read_file`, `write_file`, `edit_file`, `list_directory`, `glob`, `grep` | +| **Web** | `web_fetch`, `web_search`, `notebook_edit` | +| **Agent / tasks** | `agent`, `task_create`, `task_update`, `task_get`, `task_list`, `task_stop` | +| **System** | `system_stats`, `system_info`, `system_power` | +| **Browser (CDP)** | `browser_open`, `browser_launch`, `browser_navigate`, `browser_click`, `browser_type`, `browser_keypress`, `browser_scroll`, `browser_screenshot`, `browser_close` | +| **App store** | `app_search`, `app_install`, `app_uninstall` | +| **Network** | `wifi_scan`, `wifi_status`, `vnc_status` | +| **Preferences** | `preferences_get`, `preferences_set` | +| **Desktop UI** | `ui_open_app`, `ui_list_apps`, `ui_notify` | +| **Webapps** | `webapp_create`, `webapp_update` | +| **Code projects** | `code_project_init`, `code_project_list`, `code_project_build`, `code_project_delete` | + +## Errors are structured + +Every tool handler is wrapped so a failure returns a parseable envelope (as the +tool's text content, with `isError: true`) instead of a free-form string: + +```json +{ "error": true, "code": "AUTH_FAILED", "message": "...", "details": "..." } +``` + +`code` is one of: `AUTH_FAILED` (401/403 — bad/missing bearer), `NOT_FOUND` +(404), `ENDPOINT_DOWN` (5xx), `API_ERROR` (other non-2xx), `TIMEOUT`, +`INVALID_RESPONSE` (non-JSON body), `INTERNAL`, or `DANGEROUS_COMMAND` (see +below). Branch on `code` rather than scraping `message`. + +## `bash` safety + +`bash` hard-**blocks** destructive commands (`rm -rf /`, `dd of=/dev/…`, +`mkfs.*`, redirect-to-raw-device, fork bombs, `kill -9 -1`, stopping critical +services, etc.), returning `{ error: true, code: "DANGEROUS_COMMAND" }`. To run +one anyway, pass `allowDangerous: true` (you accept responsibility; the override +is logged). Git-safety patterns (`--no-verify`, `git add -A`, …) only **warn**. + +## Testing + +```bash +# 1. Health first — proves the token works end-to-end +CLAWBOX_MCP_TOKEN=$(cat data/.mcp-token) bun run mcp/clawbox-mcp.ts +# then send an MCP tools/call for clawbox_health → { "healthy": true, ... } + +# 2. Full smoke test of every tool over JSON-RPC stdio +bash mcp/test-tools.sh + +# 3. CLI sanity (uses the data/.mcp-token fallback) +bun run mcp/clawbox-cli.ts system info # → JSON, not "Failed to parse JSON" +``` + +The MCP runs under **bun** (types stripped at runtime) and `mcp/` is excluded +from the Next `tsconfig`, so it is not part of the app's typecheck — keep the +runtime smoke tests green. + +## Common failure modes + +| Symptom | Cause | Fix | +|---------|-------|-----| +| `Failed to parse JSON` / `API 405` on every call | missing/invalid bearer (`307 → /login`) | run `clawbox_health`; ensure `CLAWBOX_MCP_TOKEN` or `data/.mcp-token` is set | +| `{ code: "AUTH_FAILED" }` | token rejected by middleware | re-check the token matches `data/.mcp-token` | +| `{ code: "DANGEROUS_COMMAND" }` | `bash` blocked a destructive command | pass `allowDangerous: true` if intentional | +| A created webapp doesn't appear on the desktop | the desktop reconciles `data/webapps/` on load | reload the desktop; the app grid re-syncs from the server | diff --git a/mcp/clawbox-cli.ts b/mcp/clawbox-cli.ts index 3389bd5fe..e7df838a3 100644 --- a/mcp/clawbox-cli.ts +++ b/mcp/clawbox-cli.ts @@ -14,11 +14,52 @@ * clawbox system info */ +import { readFileSync } from "fs"; +import { join } from "path"; + const API_BASE = process.env.CLAWBOX_API_BASE || "http://127.0.0.1:80"; const UI_PICKUP_DELAY_MS = 2500; // Time for the desktop UI to poll and pick up KV actions +// MCP bearer token. /setup-api/* is session-gated by src/middleware.ts once +// setup completes, but it also accepts this per-install bearer (see +// src/lib/mcp-token.ts) in lieu of a session cookie. Without it every call is +// 307'd to /login and we'd JSON.parse the login HTML — the classic +// "invalid JSON response: Failed to parse JSON" failure. clawbox-mcp.ts reads +// this from its env; the CLI is launched separately (from the agent's shell) +// which may not inherit that env, so fall back to the on-disk token the +// gateway pre-start script wrote. Loaded lazily so token-free commands like +// `app list` still work without it. +let cachedToken: string | null = null; +function getApiToken(): string { + if (cachedToken) return cachedToken; + const fromEnv = process.env.CLAWBOX_MCP_TOKEN; + if (fromEnv && fromEnv.length >= 16) { + cachedToken = fromEnv; + return fromEnv; + } + // Mirror src/lib/mcp-token.ts so a dev/local CLI run finds the same token + // file the server wrote under the cwd, not just the on-device install path. + const root = process.env.CLAWBOX_ROOT + || (process.env.NODE_ENV === "development" ? process.cwd() : "/home/clawbox/clawbox"); + try { + const raw = readFileSync(join(root, "data", ".mcp-token"), "utf-8").trim(); + if (raw.length >= 16) { + cachedToken = raw; + return raw; + } + } catch { + // fall through to the missing-token error + } + console.error("MCP token not found: set CLAWBOX_MCP_TOKEN or ensure data/.mcp-token exists (is the gateway pre-start script running?)."); + process.exit(1); +} + async function api(path: string, options?: RequestInit) { - const res = await fetch(`${API_BASE}${path}`, options); + const headers = new Headers(options?.headers); + if (!headers.has("authorization")) { + headers.set("authorization", `Bearer ${getApiToken()}`); + } + const res = await fetch(`${API_BASE}${path}`, { ...options, headers }); if (!res.ok) { const body = await res.text().catch(() => ""); console.error(`Error ${res.status}: ${body}`); diff --git a/mcp/clawbox-mcp.ts b/mcp/clawbox-mcp.ts index e65cb0eb9..3eedaca52 100644 --- a/mcp/clawbox-mcp.ts +++ b/mcp/clawbox-mcp.ts @@ -71,9 +71,71 @@ const CLAWBOX_STUB = `You are the AI inside a ClawBox — a private NVIDIA Jetso const CLAWBOX_MCP_INSTRUCTIONS = `${CLAWBOX_STUB}\n\n${BROWSER_ROUTING_INSTRUCTIONS}`; // ══════════════════════════════════════════════════════════════════════ -// HTTP HELPERS +// HTTP HELPERS + STRUCTURED ERRORS // ══════════════════════════════════════════════════════════════════════ +// Thrown by api() on a non-2xx response so the tool() wrapper can classify it +// (status → code) instead of agents having to scrape a free-form string. +class ApiError extends Error { + constructor(readonly status: number, readonly body: string) { + super(`API ${status}: ${body}`); + this.name = "ApiError"; + } +} + +// Thrown by spawnBackground when a hard-blocked command is run without an +// explicit override, so the block is enforced at the shared spawn chokepoint — +// the bash tool, background tasks, and the agent tool can't diverge. +class DangerousCommandError extends Error { + constructor(readonly blocked: string[]) { + super(`Command blocked: ${blocked.join("; ")}`); + this.name = "DangerousCommandError"; + } +} + +type ToolErrorCode = + | "AUTH_FAILED" | "NOT_FOUND" | "ENDPOINT_DOWN" | "API_ERROR" + | "TIMEOUT" | "INVALID_RESPONSE" | "DANGEROUS_COMMAND" | "INTERNAL"; + +function classifyError(err: unknown): { code: ToolErrorCode; message: string; details?: string } { + if (err instanceof DangerousCommandError) { + return { code: "DANGEROUS_COMMAND", message: `${err.message}. Pass allowDangerous:true to override (you accept responsibility).` }; + } + if (err instanceof ApiError) { + const details = err.body ? err.body.slice(0, 500) : undefined; + if (err.status === 401 || err.status === 403) { + return { code: "AUTH_FAILED", message: "MCP token rejected by /setup-api/* — check CLAWBOX_MCP_TOKEN or data/.mcp-token.", details }; + } + if (err.status === 404) return { code: "NOT_FOUND", message: err.message, details }; + if (err.status >= 500) return { code: "ENDPOINT_DOWN", message: err.message, details }; + return { code: "API_ERROR", message: err.message, details }; + } + if (err instanceof Error) { + const m = err.message.toLowerCase(); + if (m.includes("aborted") || m.includes("timed out") || m.includes("timeout")) { + return { code: "TIMEOUT", message: err.message }; + } + if (m.includes("json") || m.includes("unexpected token") || m.includes("parse")) { + return { code: "INVALID_RESPONSE", message: err.message, details: err.stack }; + } + return { code: "INTERNAL", message: err.message, details: err.stack }; + } + return { code: "INTERNAL", message: String(err) }; +} + +// Structured tool-error envelope: agents branch on `code` instead of parsing +// English. Returned as the tool's text content with isError:true. +function toolErrorResult(err: unknown) { + const { code, message, details } = classifyError(err); + return { + content: [{ + type: "text" as const, + text: JSON.stringify({ error: true, code, message, ...(details ? { details } : {}) }, null, 2), + }], + isError: true as const, + }; +} + async function api(path: string, options?: RequestInit) { // Inject the MCP bearer on every call. middleware.ts accepts this for // /setup-api/* paths in lieu of a session cookie; without it we hit @@ -85,7 +147,7 @@ async function api(path: string, options?: RequestInit) { const res = await fetch(`${API_BASE}${path}`, { ...options, headers }); if (!res.ok) { const body = await res.text().catch(() => ""); - throw new Error(`API ${res.status}: ${body}`); + throw new ApiError(res.status, body); } return res.json(); } @@ -160,17 +222,18 @@ const GIT_SAFETY_PATTERNS: [RegExp, string][] = [ [/\bgit\s+add\s+(-A|--all|\.)(\s|$)/, "git add -A/--all/. — may stage secrets or large files. Prefer adding specific files."], ]; -const READ_ONLY_PATTERNS = /^\s*(ls|cat|head|tail|less|more|find|grep|rg|wc|file|stat|du|df|top|ps|who|id|uname|hostname|date|echo|printf|which|type|env|printenv|set)\b/; - -function detectDangerousCommand(cmd: string): string[] { +// Split severities: DANGEROUS patterns are hard-blocked (require an explicit +// allowDangerous override); GIT_SAFETY patterns are advisory and only warn. +function detectDangerousCommand(cmd: string): { blocked: string[]; warnings: string[] } { + const blocked: string[] = []; const warnings: string[] = []; for (const [pattern, msg] of DANGEROUS_PATTERNS) { - if (pattern.test(cmd)) warnings.push(`⚠ DANGEROUS: ${msg}`); + if (pattern.test(cmd)) blocked.push(msg); } for (const [pattern, msg] of GIT_SAFETY_PATTERNS) { - if (pattern.test(cmd)) warnings.push(`⚠ GIT SAFETY: ${msg}`); + if (pattern.test(cmd)) warnings.push(msg); } - return warnings; + return { blocked, warnings }; } // ══════════════════════════════════════════════════════════════════════ @@ -213,7 +276,15 @@ function evictStaleBgTasks() { } } -function spawnBackground(command: string, timeoutMs: number, desc = "", workDir = HOME): BgTask { +function spawnBackground(command: string, timeoutMs: number, desc = "", workDir = HOME, allowDangerous = false): BgTask { + // Shared enforcement chokepoint: bash-background AND the agent tool spawn + // through here, so the dangerous-command block can't be bypassed by routing a + // command through `agent` instead of `bash`. + const { blocked } = detectDangerousCommand(command); + if (blocked.length && !allowDangerous) { + console.error(`[clawbox-mcp] BLOCKED dangerous command (spawnBackground): ${command} (${blocked.join("; ")})`); + throw new DangerousCommandError(blocked); + } evictStaleBgTasks(); const id = `bg-${++bgTaskSeq}`; const task: BgTask = { @@ -432,11 +503,41 @@ const server = new McpServer( { instructions: CLAWBOX_MCP_INSTRUCTIONS }, ); +// Thin wrapper over server.tool that catches any throw from a handler and +// returns a structured { error, code, message, details } envelope (via +// toolErrorResult) instead of letting the SDK surface an opaque error string. +// Every tool below registers through this. Mirrors server.tool's two arities: +// tool(name, desc, zodShape, handler) — tool with params +// tool(name, desc, handler) — no-param tool +/* eslint-disable @typescript-eslint/no-explicit-any -- the SDK's per-tool + arg types can't be expressed through a single wrapper signature; the zod + shape still validates args at runtime. */ +function tool( + name: string, + description: string, + shapeOrHandler: Record | ((...a: any[]) => any), + maybeHandler?: (...a: any[]) => any, +) { + const handler = (maybeHandler ?? shapeOrHandler) as (...a: any[]) => any; + const wrapped = async (...args: any[]) => { + try { + return await handler(...args); + } catch (err) { + return toolErrorResult(err); + } + }; + if (maybeHandler) { + return server.tool(name, description, shapeOrHandler as Record, wrapped as any); + } + return server.tool(name, description, wrapped as any); +} +/* eslint-enable @typescript-eslint/no-explicit-any */ + // ══════════════════════════════════════════════════════════════════════ // TOOL: bash // ══════════════════════════════════════════════════════════════════════ -server.tool( +tool( "bash", `Execute a shell command on the ClawBox device and return stdout/stderr. @@ -464,17 +565,41 @@ GIT SAFETY: description: z.string().optional().describe("Brief description of what this command does"), run_in_background: z.boolean().optional().describe("Run in background, return task ID immediately"), cwd: z.string().optional().describe("Working directory (default: /home/clawbox)"), + allowDangerous: z.boolean().optional().describe("Override the dangerous-command block (rm -rf /, dd, mkfs, fork bombs, etc.). You accept responsibility. Default false."), }, - async ({ command, timeout, description, run_in_background, cwd }) => { + async ({ command, timeout, description, run_in_background, cwd, allowDangerous }) => { const timeoutMs = Math.min(timeout ?? COMMAND_TIMEOUT, MAX_COMMAND_TIMEOUT); const workDir = cwd || HOME; - // Dangerous command detection - const warnings = detectDangerousCommand(command); - const warningText = warnings.length ? warnings.join("\n") + "\n\n" : ""; + // Severity-split detection: hard-block destructive commands unless the + // caller explicitly opts in; git-safety patterns only warn. + const { blocked, warnings } = detectDangerousCommand(command); + if (blocked.length && allowDangerous !== true) { + console.error(`[clawbox-mcp] BLOCKED dangerous command: ${command} (${blocked.join("; ")})`); + return { + content: [{ + type: "text" as const, + text: JSON.stringify({ + error: true, + code: "DANGEROUS_COMMAND", + message: `Command blocked: ${blocked.join("; ")}. Pass allowDangerous:true to override (you accept responsibility).`, + details: command, + }, null, 2), + }], + isError: true, + }; + } + if (blocked.length) { + console.error(`[clawbox-mcp] DANGEROUS command overridden via allowDangerous: ${command} (${blocked.join("; ")})`); + } + const notes = [ + ...blocked.map((b) => `⚠ DANGEROUS (overridden): ${b}`), + ...warnings.map((w) => `⚠ GIT SAFETY: ${w}`), + ]; + const warningText = notes.length ? notes.join("\n") + "\n\n" : ""; if (run_in_background) { - const task = spawnBackground(command, timeoutMs, description || "", workDir); + const task = spawnBackground(command, timeoutMs, description || "", workDir, allowDangerous); return { content: [{ type: "text", @@ -494,11 +619,60 @@ GIT SAFETY: } ); +// ══════════════════════════════════════════════════════════════════════ +// TOOL: clawbox_health +// ══════════════════════════════════════════════════════════════════════ + +async function checkApiEndpoint(path: string): Promise<{ ok: boolean; detail: string }> { + try { + const res = await fetch(`${API_BASE}${path}`, { + headers: { + accept: "application/json", + ...(API_TOKEN ? { authorization: `Bearer ${API_TOKEN}` } : {}), + }, + // Don't follow a 302→/login: surface it as a non-ok status (an auth + // failure) instead of landing on the login HTML and reporting a JSON + // parse error. + redirect: "manual", + signal: AbortSignal.timeout(5_000), + }); + if (!res.ok) { + return { ok: false, detail: res.status === 401 || res.status === 403 ? `HTTP ${res.status} (token rejected)` : `HTTP ${res.status}` }; + } + await res.json(); // ensure the body parses (a login HTML page would throw) + return { ok: true, detail: `HTTP ${res.status}` }; + } catch (err) { + return { ok: false, detail: err instanceof Error ? err.message : String(err) }; + } +} + +tool( + "clawbox_health", + `Check that the MCP bearer token is valid and the ClawBox /setup-api/* surface is reachable. Run this first when tools are failing — it pinpoints auth (token rejected) vs connectivity (endpoint down) vs a specific endpoint, instead of leaving you to guess from an opaque tool error. Returns { healthy, checks, timestamp }.`, + async () => { + const checks: Record = {}; + // The MCP authenticates with API_TOKEN from its env; middleware verifies it + // against the shared data/.mcp-token. An empty/short token here is the root + // cause of the AUTH_FAILED tool errors. + checks.mcp_token = API_TOKEN + ? { ok: API_TOKEN.length >= 16, detail: API_TOKEN.length >= 16 ? "present in env" : "present but too short (<16 chars)" } + : { ok: false, detail: "CLAWBOX_MCP_TOKEN not set in the MCP server env" }; + // Independent round-trips — run them together so a slow/hung endpoint + // doesn't double the diagnostic's wall-clock (each has a 5s timeout). + [checks.api_system_info, checks.api_preferences] = await Promise.all([ + checkApiEndpoint("/setup-api/system/info"), + checkApiEndpoint("/setup-api/preferences?all=1"), + ]); + const healthy = Object.values(checks).every((c) => c.ok); + return { content: [{ type: "text" as const, text: JSON.stringify({ healthy, checks, timestamp: Date.now() }, null, 2) }] }; + } +); + // ══════════════════════════════════════════════════════════════════════ // TOOL: task_status // ══════════════════════════════════════════════════════════════════════ -server.tool( +tool( "task_status", "Check the status and output of a background bash task.", { id: z.string().describe("Background task ID (e.g., 'bg-1')") }, @@ -519,7 +693,7 @@ server.tool( // TOOL: read_file // ══════════════════════════════════════════════════════════════════════ -server.tool( +tool( "read_file", `Read a file from the filesystem. Returns content with line numbers (cat -n format). @@ -641,7 +815,7 @@ Usage: // TOOL: write_file // ══════════════════════════════════════════════════════════════════════ -server.tool( +tool( "write_file", `Write content to a file. Creates the file if it doesn't exist, overwrites if it does. Parent directories are created automatically. @@ -704,7 +878,7 @@ IMPORTANT: // TOOL: edit_file // ══════════════════════════════════════════════════════════════════════ -server.tool( +tool( "edit_file", `Edit a file by replacing an exact string match. Preferred way to modify existing files — changes only the targeted section. @@ -789,7 +963,7 @@ RULES: // TOOL: list_directory // ══════════════════════════════════════════════════════════════════════ -server.tool( +tool( "list_directory", "List files and directories at a given path. Returns names, types, and sizes.", { path: z.string().optional().describe("Directory path (default: /home/clawbox/clawbox)") }, @@ -814,7 +988,7 @@ server.tool( // TOOL: glob // ══════════════════════════════════════════════════════════════════════ -server.tool( +tool( "glob", `Fast file pattern matching. Find files by name using glob syntax. @@ -876,7 +1050,7 @@ Use this for finding files by name. For searching file *contents*, use grep.`, // TOOL: grep // ══════════════════════════════════════════════════════════════════════ -server.tool( +tool( "grep", `Search file contents for a text pattern. Uses ripgrep (rg) or grep. @@ -989,7 +1163,7 @@ Supports regex, context lines, case-insensitive, multiline, file type filtering. // TOOL: web_fetch // ══════════════════════════════════════════════════════════════════════ -server.tool( +tool( "web_fetch", `Fetch a URL and return the page content as readable text/markdown. @@ -1070,7 +1244,7 @@ If the URL redirects to a different host, a warning is shown.`, // TOOL: web_search // ══════════════════════════════════════════════════════════════════════ -server.tool( +tool( "web_search", `Search the web and return results with titles, URLs, and snippets. After searching, use web_fetch to read specific pages in full.`, @@ -1150,7 +1324,7 @@ After searching, use web_fetch to read specific pages in full.`, // TOOL: notebook_edit // ══════════════════════════════════════════════════════════════════════ -server.tool( +tool( "notebook_edit", `Edit Jupyter notebook (.ipynb) cells. Supports replacing cell content, inserting new cells, and deleting cells. @@ -1218,7 +1392,7 @@ Use read_file first to see the notebook cells and their indices.`, // TOOL: agent (sub-agent delegation) // ══════════════════════════════════════════════════════════════════════ -server.tool( +tool( "agent", `Spawn a background sub-agent that executes a sequence of shell commands autonomously. @@ -1254,7 +1428,7 @@ Returns a task ID — check progress with task_status.`, // TASK MANAGEMENT (full-featured) // ══════════════════════════════════════════════════════════════════════ -server.tool( +tool( "task_create", `Create a task to track progress on multi-step work. @@ -1289,7 +1463,7 @@ Tasks can have dependencies (blockedBy) — a blocked task cannot start until it } ); -server.tool( +tool( "task_update", `Update a task's status, details, or dependencies. @@ -1353,7 +1527,7 @@ When completing a task, any tasks it blocks become unblocked.`, } ); -server.tool( +tool( "task_get", "Get full details of a specific task including description, dependencies, and metadata.", { task_id: z.string().describe("Task ID") }, @@ -1392,7 +1566,7 @@ server.tool( } ); -server.tool( +tool( "task_list", `List all tasks. Shows user tasks and background tasks with status. @@ -1419,7 +1593,7 @@ Prefer working on tasks in ID order (lowest first). Blocked tasks cannot start u } ); -server.tool( +tool( "task_stop", "Stop a running background task by killing its process.", { task_id: z.string().describe("Background task ID (e.g., 'bg-1')") }, @@ -1441,17 +1615,17 @@ server.tool( // CLAWBOX SYSTEM TOOLS // ══════════════════════════════════════════════════════════════════════ -server.tool("system_stats", "Get comprehensive system statistics: CPU, memory, disk, network, temperature, GPU, top processes", async () => { +tool("system_stats", "Get comprehensive system statistics: CPU, memory, disk, network, temperature, GPU, top processes", async () => { const stats = await api("/setup-api/system/stats"); return { content: [{ type: "text", text: JSON.stringify(stats, null, 2) }] }; }); -server.tool("system_info", "Get basic system info: hostname, CPU, memory, temperature, disk", async () => { +tool("system_info", "Get basic system info: hostname, CPU, memory, temperature, disk", async () => { const info = await api("/setup-api/system/info"); return { content: [{ type: "text", text: JSON.stringify(info, null, 2) }] }; }); -server.tool("system_power", "Restart or shut down the ClawBox device", +tool("system_power", "Restart or shut down the ClawBox device", { action: z.enum(["restart", "shutdown"]).describe("Power action") }, async ({ action }) => { await apiPost("/setup-api/system/power", { action }); @@ -1506,7 +1680,7 @@ async function browserAction(action: string, params: Record = { return apiPost("/setup-api/browser", { action, sessionId, ...params }) as Promise>; } -server.tool("browser_open", +tool("browser_open", `Preferred tool when the user asks to open the browser, open a website, or start browsing. Attaches to the live Chromium window running on the ClawBox desktop via CDP and optionally navigates to a URL. Returns a screenshot. This controls the real browser visible in VNC, not the Browser Setup desktop app. @@ -1523,7 +1697,7 @@ Workflow: browser_open → browser_screenshot → browser_click/type → browser } ); -server.tool("browser_launch", +tool("browser_launch", `Alias of browser_open for compatibility. Attach to the live Chromium window running on the ClawBox desktop via CDP and optionally navigate to a URL. Returns a screenshot. This controls the real browser visible in VNC, not a separate hidden browser. @@ -1539,7 +1713,7 @@ Workflow: browser_launch → browser_screenshot → browser_click/type → brows } ); -server.tool("browser_navigate", +tool("browser_navigate", "Navigate the browser to a new URL. Returns screenshot of the loaded page.", { url: z.string().describe("URL to navigate to") }, async ({ url }) => { @@ -1548,7 +1722,7 @@ server.tool("browser_navigate", } ); -server.tool("browser_click", +tool("browser_click", "Click at x,y coordinates in the current desktop browser screenshot. Use browser_screenshot to find element positions.", { x: z.number().describe("X coordinate"), y: z.number().describe("Y coordinate"), button: z.enum(["left", "right", "middle"]).optional().describe("Mouse button (default: left)") }, async ({ x, y, button }) => { @@ -1557,7 +1731,7 @@ server.tool("browser_click", } ); -server.tool("browser_type", +tool("browser_type", "Type text into the currently focused element in the browser. Click an input field first.", { text: z.string().describe("Text to type") }, async ({ text }) => { @@ -1566,7 +1740,7 @@ server.tool("browser_type", } ); -server.tool("browser_keypress", +tool("browser_keypress", "Press a special key. Common keys: Enter, Tab, Escape, Backspace, ArrowDown, ArrowUp, ArrowLeft, ArrowRight.", { key: z.string().describe("Key name (e.g. Enter, Tab, Escape)") }, async ({ key }) => { @@ -1575,7 +1749,7 @@ server.tool("browser_keypress", } ); -server.tool("browser_scroll", +tool("browser_scroll", "Scroll the page at the given coordinates. Positive deltaY scrolls down.", { x: z.number().describe("X coordinate"), y: z.number().describe("Y coordinate"), deltaX: z.number().optional().describe("Horizontal scroll"), deltaY: z.number().describe("Vertical scroll (positive=down)") }, async ({ x, y, deltaX, deltaY }) => { @@ -1584,7 +1758,7 @@ server.tool("browser_scroll", } ); -server.tool("browser_screenshot", +tool("browser_screenshot", "Take a screenshot of the current desktop browser page. Use this to see what's on screen before clicking.", async () => { const result = await browserAction("screenshot"); @@ -1592,7 +1766,7 @@ server.tool("browser_screenshot", } ); -server.tool("browser_close", "End the current browser control session. The desktop Chromium window stays open.", async () => { +tool("browser_close", "End the current browser control session. The desktop Chromium window stays open.", async () => { if (currentSessionId) { try { await apiPost("/setup-api/browser", { action: "close", sessionId: currentSessionId }); } catch {} currentSessionId = null; @@ -1604,7 +1778,7 @@ server.tool("browser_close", "End the current browser control session. The deskt // APP STORE // ══════════════════════════════════════════════════════════════════════ -server.tool("app_search", "Search the ClawBox app store", +tool("app_search", "Search the ClawBox app store", { query: z.string().optional().describe("Search query"), category: z.string().optional().describe("Category"), limit: z.number().optional().describe("Max results") }, async ({ query, category, limit }) => { const p = new URLSearchParams(); @@ -1613,12 +1787,12 @@ server.tool("app_search", "Search the ClawBox app store", } ); -server.tool("app_install", "Install an app from the ClawBox store", +tool("app_install", "Install an app from the ClawBox store", { appId: z.string().describe("App ID") }, async ({ appId }) => { await apiPost("/setup-api/apps/install", { appId }); return { content: [{ type: "text", text: `App '${appId}' installed.` }] }; } ); -server.tool("app_uninstall", "Uninstall an app from ClawBox", +tool("app_uninstall", "Uninstall an app from ClawBox", { appId: z.string().describe("App ID") }, async ({ appId }) => { await apiPost("/setup-api/apps/uninstall", { appId }); return { content: [{ type: "text", text: `App '${appId}' uninstalled.` }] }; } ); @@ -1627,13 +1801,13 @@ server.tool("app_uninstall", "Uninstall an app from ClawBox", // NETWORK // ══════════════════════════════════════════════════════════════════════ -server.tool("wifi_scan", "Scan for WiFi networks", async () => { +tool("wifi_scan", "Scan for WiFi networks", async () => { return { content: [{ type: "text", text: JSON.stringify((await api("/setup-api/wifi/scan")).networks, null, 2) }] }; }); -server.tool("wifi_status", "Get WiFi connection status", async () => { +tool("wifi_status", "Get WiFi connection status", async () => { return { content: [{ type: "text", text: JSON.stringify(await api("/setup-api/wifi/status"), null, 2) }] }; }); -server.tool("vnc_status", "Check VNC server status", async () => { +tool("vnc_status", "Check VNC server status", async () => { return { content: [{ type: "text", text: JSON.stringify(await api("/setup-api/vnc"), null, 2) }] }; }); @@ -1641,14 +1815,14 @@ server.tool("vnc_status", "Check VNC server status", async () => { // PREFERENCES // ══════════════════════════════════════════════════════════════════════ -server.tool("preferences_get", "Get ClawBox user preferences", +tool("preferences_get", "Get ClawBox user preferences", { keys: z.string().optional().describe("Comma-separated keys (omit for all)") }, async ({ keys }) => { return { content: [{ type: "text", text: JSON.stringify(await api(`/setup-api/preferences${keys ? `?keys=${encodeURIComponent(keys)}` : ""}`), null, 2) }] }; } ); -server.tool("preferences_set", "Set ClawBox user preferences", +tool("preferences_set", "Set ClawBox user preferences", { preferences: z.string().describe("JSON string of key-value pairs") }, async ({ preferences }) => { const parsed = JSON.parse(preferences); @@ -1671,7 +1845,7 @@ const AVAILABLE_APPS = [ { id: "vnc", name: "Remote Desktop", description: "VNC viewer" }, ]; -server.tool("ui_open_app", "Open an app on the ClawBox desktop. For real web browsing, use browser_open or browser_launch instead of opening the 'browser' app.", +tool("ui_open_app", "Open an app on the ClawBox desktop. For real web browsing, use browser_open or browser_launch instead of opening the 'browser' app.", { appId: z.string().describe("App ID") }, async ({ appId }) => { await apiPost("/setup-api/kv", { key: "ui:pending-action", value: JSON.stringify({ type: "open_app", appId, ts: Date.now() }) }); @@ -1687,7 +1861,7 @@ server.tool("ui_open_app", "Open an app on the ClawBox desktop. For real web bro } ); -server.tool("ui_list_apps", "List apps available on the ClawBox desktop", async () => { +tool("ui_list_apps", "List apps available on the ClawBox desktop", async () => { let installed: { id: string; name: string }[] = []; try { const r = await runShell("ls /home/clawbox/.openclaw/skills/ 2>/dev/null"); @@ -1697,7 +1871,7 @@ server.tool("ui_list_apps", "List apps available on the ClawBox desktop", async return { content: [{ type: "text", text: `Apps:\n${all.join("\n")}` }] }; }); -server.tool("ui_notify", "Show a notification on the ClawBox desktop", +tool("ui_notify", "Show a notification on the ClawBox desktop", { message: z.string().describe("Message") }, async ({ message }) => { await apiPost("/setup-api/kv", { key: "ui:pending-action", value: JSON.stringify({ type: "notify", message, ts: Date.now() }) }); @@ -1709,7 +1883,7 @@ server.tool("ui_notify", "Show a notification on the ClawBox desktop", // WEBAPP CREATION // ══════════════════════════════════════════════════════════════════════ -server.tool("webapp_create", +tool("webapp_create", `Create a single-file web app on the ClawBox desktop. For multi-file apps, use code_project_* instead. Write complete standalone HTML with inline CSS/JS. Dark theme: bg #1a1a2e, text #e0e0e0, accent #f97316. No CDN links. @@ -1740,7 +1914,7 @@ Values are strings — JSON.stringify objects before saving, JSON.parse after lo } ); -server.tool("webapp_update", "Update an existing webapp's HTML.", +tool("webapp_update", "Update an existing webapp's HTML.", { appId: z.string().describe("App ID"), html: z.string().describe("Updated HTML") }, async ({ appId, html }) => { await apiPost("/setup-api/webapps", { appId, html }); @@ -1756,7 +1930,7 @@ async function codeApi(action: string, body: Record = {}) { return apiPost("/setup-api/code", { action, ...body }); } -server.tool("code_project_init", +tool("code_project_init", `Create a new code project for building a ClawBox webapp. 1. Init → scaffolds index.html + style.css + app.js 2. Use read_file/write_file/edit_file on files in data/code-projects// @@ -1786,13 +1960,13 @@ Namespace keys with projectId prefix (e.g. "todo:items"). Values are strings — } ); -server.tool("code_project_list", "List all code projects.", async () => { +tool("code_project_list", "List all code projects.", async () => { const data = await codeApi("list-projects") as { projects: { projectId: string; name: string; updated: string }[] }; if (!data.projects.length) return { content: [{ type: "text", text: "No projects." }] }; return { content: [{ type: "text", text: `Projects:\n${data.projects.map((p) => `${p.projectId} — ${p.name} (${new Date(p.updated).toLocaleDateString()})`).join("\n")}` }] }; }); -server.tool("code_project_build", +tool("code_project_build", `Build and deploy a code project. Inlines CSS/JS into index.html, deploys to desktop, opens the app.`, { projectId: z.string().describe("Project ID"), @@ -1817,7 +1991,7 @@ server.tool("code_project_build", } ); -server.tool("code_project_delete", "Delete a code project source files.", +tool("code_project_delete", "Delete a code project source files.", { projectId: z.string().describe("Project ID") }, async ({ projectId }) => { await codeApi("delete-project", { projectId }); @@ -1834,7 +2008,7 @@ server.tool("code_project_delete", "Delete a code project source files.", // specific message for each ("not found" vs "is empty") instead of // collapsing both into a misleading "not found". let cachedFieldGuide: string | null | undefined = undefined; -server.tool( +tool( "clawbox_context", "Return the ClawBox field guide: what ClawBox is, the mascot, available tools, architecture, and house rules. Call once at the start of a session to understand the device you're operating.", async () => { diff --git a/src/app/setup-api/webapps/route.ts b/src/app/setup-api/webapps/route.ts index d8dbf2ee4..d521f4056 100644 --- a/src/app/setup-api/webapps/route.ts +++ b/src/app/setup-api/webapps/route.ts @@ -3,7 +3,7 @@ export const dynamic = "force-dynamic"; import { NextRequest, NextResponse } from "next/server"; import fs from "fs/promises"; import path from "path"; -import { WEBAPPS_DIR, APP_ID_RE } from "@/lib/code-projects"; +import { WEBAPPS_DIR, APP_ID_RE, deployWebapp, writeWebappIndex } from "@/lib/code-projects"; const MIME_TYPES: Record = { ".html": "text/html; charset=utf-8", @@ -73,16 +73,34 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: "HTML content too large (max 1MB)" }, { status: 413 }); } - const appDir = path.join(WEBAPPS_DIR, appId); - await fs.mkdir(appDir, { recursive: true }); - await fs.writeFile(path.join(appDir, "index.html"), html, "utf-8"); - - // Save metadata - await fs.writeFile( - path.join(appDir, "meta.json"), - JSON.stringify({ name: name || appId, color: color || "#f97316", icon: icon || "" }), - "utf-8" - ); + // Distinguish a create from an update by whether the payload carries a + // `name` property — not its truthiness. A POST with `name: ""` is an + // invalid create (400), not a silent update that would leave the app + // without meta.json or desktop registration. + const hasName = Object.prototype.hasOwnProperty.call(body, "name"); + if (hasName) { + // Create: write index.html + meta.json and durably register on the + // desktop via the shared chokepoint (keeps the on-disk layout in lockstep + // with buildProject, and the app appears even if the desktop wasn't open + // to consume the ui:pending-action handoff). + if (typeof name !== "string" || name.trim() === "") { + return NextResponse.json({ error: "Name is required" }, { status: 400 }); + } + await deployWebapp(appId, html, { name, color, icon }); + } else { + // Update: only rewrite the HTML. Re-stamping meta.json here would clobber + // the saved display name (an update carries no `name`), and re-registering + // is unnecessary — the app is already on the desktop. Reject updates to an + // app that was never created so a typo'd appId can't half-deploy. + const exists = await fs + .stat(path.join(WEBAPPS_DIR, appId, "meta.json")) + .then(() => true) + .catch(() => false); + if (!exists) { + return NextResponse.json({ error: "Webapp not found" }, { status: 404 }); + } + await writeWebappIndex(appId, html); + } return NextResponse.json({ success: true, diff --git a/src/lib/code-projects.ts b/src/lib/code-projects.ts index 6de32f1d7..15c4b8bf3 100644 --- a/src/lib/code-projects.ts +++ b/src/lib/code-projects.ts @@ -9,6 +9,7 @@ import fs from "fs/promises"; import path from "path"; import { DATA_DIR } from "./config-store"; +import { registerWebappInPreferences } from "./webapp-registry"; // ── Paths ── @@ -470,20 +471,65 @@ export async function buildProject( } ); - // Deploy to webapps directory - const webappDir = path.join(WEBAPPS_DIR, projectId); - await fs.mkdir(webappDir, { recursive: true }); - await fs.writeFile(path.join(webappDir, "index.html"), html, "utf-8"); - await fs.writeFile( - path.join(webappDir, "meta.json"), - JSON.stringify({ name, color, icon: "" }), - "utf-8" - ); + // First build registers the app on the desktop via the shared chokepoint + // (same on-disk layout + meta.json shape as the webapps POST route). A + // rebuild only refreshes index.html — re-running deployWebapp would clobber + // the saved icon and re-surface an app the user intentionally hid. + const alreadyDeployed = await fs + .stat(path.join(WEBAPPS_DIR, projectId, "meta.json")) + .then(() => true) + .catch(() => false); + if (alreadyDeployed) { + await writeWebappIndex(projectId, html); + } else { + await deployWebapp(projectId, html, { name, color }); + } const url = `/setup-api/webapps?app=${projectId}`; return { html, url, filesInlined }; } +/** + * Refresh only the deployed index.html for an existing webapp. The shared + * "update" chokepoint (webapps POST update branch + buildProject rebuilds) — + * it deliberately leaves meta.json and the desktop registration untouched so a + * rebuild can't wipe the saved icon or re-surface an app the user hid. + */ +export async function writeWebappIndex(appId: string, html: string): Promise { + const dir = path.join(WEBAPPS_DIR, appId); + await fs.mkdir(dir, { recursive: true }); + await fs.writeFile(path.join(dir, "index.html"), html, "utf-8"); +} + +/** + * Deploy a single-page webapp to data/webapps// (index.html + meta.json) + * and durably register it on the desktop. The one chokepoint shared by the + * webapps POST route and buildProject — so the on-disk layout, the meta.json + * shape, and the preference registration can't drift between the two create + * paths (and can't be half-applied by one caller forgetting a step). + * + * Create-time only: it (re)writes meta.json and re-registers in preferences. + * For rebuilds of an existing app use writeWebappIndex so existing metadata + * and hidden/uninstalled state are preserved. + */ +export async function deployWebapp( + appId: string, + html: string, + meta: { name: string; color?: string; icon?: string }, +): Promise { + await writeWebappIndex(appId, html); + await fs.writeFile( + path.join(WEBAPPS_DIR, appId, "meta.json"), + JSON.stringify({ name: meta.name, color: meta.color || "#f97316", icon: meta.icon || "" }), + "utf-8", + ); + await registerWebappInPreferences(appId, meta.name, { + color: meta.color, + iconUrl: meta.icon, + webappUrl: `/setup-api/webapps?app=${appId}`, + }); +} + // ── Helpers ── function escapeHtml(s: string): string { diff --git a/src/lib/webapp-registry.ts b/src/lib/webapp-registry.ts new file mode 100644 index 000000000..59f794f08 --- /dev/null +++ b/src/lib/webapp-registry.ts @@ -0,0 +1,50 @@ +import { getAll, setMany } from "@/lib/config-store"; + +interface InstalledMeta { + name: string; + color: string; + iconUrl: string; + webappUrl: string; +} + +/** + * Durably register a webapp on the desktop by writing the same preference keys + * the live desktop writes when it consumes a `register_webapp` ui:pending-action + * (see src/app/page.tsx). That handoff only lands if the desktop happens to be + * open and polling — so a webapp created while the desktop is closed gets its + * HTML saved but never reaches the app grid. Persisting here closes that gap: + * the desktop reads `installed_apps` / `installed_meta` from + * /setup-api/preferences on mount, so the app shows up on its next load. + * + * Idempotent (add-if-missing); also un-hides the app, mirroring the live + * handler. The ui:pending-action emit stays in place for instant updates on an + * already-open desktop — this is the durability backstop. + */ +export async function registerWebappInPreferences( + appId: string, + name: string, + opts: { color?: string; iconUrl?: string; webappUrl?: string } = {}, +): Promise { + // One read of the config, not three — config-store.get() re-reads and + // re-parses the whole file on each call, and reading the three keys together + // also narrows the read-modify-write window. + const prefs = await getAll(); + const installedApps = (prefs["pref:installed_apps"] as string[] | undefined) ?? []; + const installedMeta = (prefs["pref:installed_meta"] as Record | undefined) ?? {}; + const hiddenInstalled = (prefs["pref:hidden_installed"] as string[] | undefined) ?? []; + + await setMany({ + "pref:installed_apps": installedApps.includes(appId) ? installedApps : [...installedApps, appId], + "pref:installed_meta": { + ...installedMeta, + [appId]: { + name, + color: opts.color || "#f97316", + iconUrl: opts.iconUrl || "", + webappUrl: opts.webappUrl || `/setup-api/webapps?app=${appId}`, + }, + }, + // A freshly (re)created app shouldn't stay hidden. + "pref:hidden_installed": hiddenInstalled.filter((id) => id !== appId), + }); +} diff --git a/src/tests/routes/webapps.test.ts b/src/tests/routes/webapps.test.ts index 90e543a3a..68082e192 100644 --- a/src/tests/routes/webapps.test.ts +++ b/src/tests/routes/webapps.test.ts @@ -6,18 +6,26 @@ vi.mock("fs/promises", () => ({ readFile: vi.fn(), mkdir: vi.fn().mockResolvedValue(undefined), writeFile: vi.fn().mockResolvedValue(undefined), + stat: vi.fn(), }, })); vi.mock("@/lib/code-projects", () => ({ WEBAPPS_DIR: "/tmp/webapps", APP_ID_RE: /^[a-z0-9][a-z0-9_-]{0,63}$/, + // The create path now deploys + registers via this shared chokepoint; stub + // it so the route test doesn't hit real config IO (it owns the desktop + // registration, covered separately in code-projects/webapp-registry tests). + deployWebapp: vi.fn().mockResolvedValue(undefined), + // The update path refreshes only index.html via this helper. + writeWebappIndex: vi.fn().mockResolvedValue(undefined), })); import fs from "fs/promises"; const mockReadFile = vi.mocked(fs.readFile); const mockMkdir = vi.mocked(fs.mkdir); const mockWriteFile = vi.mocked(fs.writeFile); +const mockStat = vi.mocked(fs.stat); describe("/setup-api/webapps", () => { let GET: (req: NextRequest) => Promise; @@ -108,5 +116,36 @@ describe("/setup-api/webapps", () => { const res = await POST(req); expect(res.status).toBe(413); }); + + it("rejects a create with an empty name", async () => { + const req = new NextRequest(new URL("http://localhost/setup-api/webapps"), { + method: "POST", + body: JSON.stringify({ appId: "myapp", html: "", name: "" }), + }); + const res = await POST(req); + expect(res.status).toBe(400); + }); + + it("updates an existing webapp when no name is sent", async () => { + mockStat.mockResolvedValue({} as never); + const req = new NextRequest(new URL("http://localhost/setup-api/webapps"), { + method: "POST", + body: JSON.stringify({ appId: "myapp", html: "updated" }), + }); + const res = await POST(req); + const body = await res.json(); + expect(res.status).toBe(200); + expect(body.success).toBe(true); + }); + + it("returns 404 when updating a webapp that does not exist", async () => { + mockStat.mockRejectedValue(new Error("ENOENT") as never); + const req = new NextRequest(new URL("http://localhost/setup-api/webapps"), { + method: "POST", + body: JSON.stringify({ appId: "ghost", html: "" }), + }); + const res = await POST(req); + expect(res.status).toBe(404); + }); }); }); diff --git a/src/tests/unit/clawbox-mcp-browser-guidance.test.ts b/src/tests/unit/clawbox-mcp-browser-guidance.test.ts index 3ec56c931..3ea0aba99 100644 --- a/src/tests/unit/clawbox-mcp-browser-guidance.test.ts +++ b/src/tests/unit/clawbox-mcp-browser-guidance.test.ts @@ -10,7 +10,7 @@ describe("clawbox MCP browser guidance", () => { ); expect(mcpSource).toContain("Use the dedicated browser_* tools for web browsing and browser automation."); - expect(mcpSource).toContain('server.tool("browser_open"'); + expect(mcpSource).toContain('tool("browser_open"'); expect(mcpSource).toContain('Do not use ui_open_app("browser") for normal browsing.'); }); diff --git a/src/tests/unit/code-projects.test.ts b/src/tests/unit/code-projects.test.ts index 013729c56..f618d5104 100644 --- a/src/tests/unit/code-projects.test.ts +++ b/src/tests/unit/code-projects.test.ts @@ -38,6 +38,12 @@ vi.mock("@/lib/config-store", () => ({ DATA_DIR: "/tmp/test-data", })); +// buildProject now registers the built app on the desktop (durability backstop). +// Stub it so the build tests stay focused on the build output, not config IO. +vi.mock("@/lib/webapp-registry", () => ({ + registerWebappInPreferences: vi.fn(), +})); + import { validateProjectId, initProject, @@ -57,12 +63,14 @@ import { } from "@/lib/code-projects"; import fs from "fs/promises"; +import { registerWebappInPreferences } from "@/lib/webapp-registry"; const mockReadFile = vi.mocked(fs.readFile); const mockReaddir = vi.mocked(fs.readdir); const mockStat = vi.mocked(fs.stat); const mockWriteFile = vi.mocked(fs.writeFile); const mockMkdir = vi.mocked(fs.mkdir); const mockRm = vi.mocked(fs.rm); +const mockRegisterWebappInPreferences = vi.mocked(registerWebappInPreferences); describe("code-projects", () => { beforeEach(() => { @@ -337,6 +345,16 @@ describe("code-projects", () => { expect(result.filesInlined).toBe(2); expect(result.html).toContain("