diff --git a/apps/android/app/src/test/java/com/steipete/clawdis/node/bridge/BridgeSessionTest.kt b/apps/android/app/src/test/java/com/steipete/clawdis/node/bridge/BridgeSessionTest.kt index c94507398..ebdc06e3a 100644 --- a/apps/android/app/src/test/java/com/steipete/clawdis/node/bridge/BridgeSessionTest.kt +++ b/apps/android/app/src/test/java/com/steipete/clawdis/node/bridge/BridgeSessionTest.kt @@ -46,7 +46,7 @@ class BridgeSessionTest { val hello = reader.readLine() assertTrue(hello.contains("\"type\":\"hello\"")) - writer.write("""{"type":"hello-ok","serverName":"Test Bridge","canvasHostUrl":"http://127.0.0.1:18793"}""") + writer.write("""{"type":"hello-ok","serverName":"Test Bridge","canvasHostUrl":"http://127.0.0.1:18789"}""") writer.write("\n") writer.flush() @@ -77,7 +77,7 @@ class BridgeSessionTest { ) connected.await() - assertEquals("http://127.0.0.1:18793", session.currentCanvasHostUrl()) + assertEquals("http://127.0.0.1:18789", session.currentCanvasHostUrl()) val payload = session.request(method = "health", paramsJson = null) assertEquals("""{"value":123}""", payload) server.await() diff --git a/apps/ios/Tests/ScreenControllerTests.swift b/apps/ios/Tests/ScreenControllerTests.swift index 75dca0ec5..028a0eae6 100644 --- a/apps/ios/Tests/ScreenControllerTests.swift +++ b/apps/ios/Tests/ScreenControllerTests.swift @@ -43,13 +43,13 @@ import WebKit @Test @MainActor func localNetworkCanvasURLsAreAllowed() { let screen = ScreenController() - #expect(screen.isLocalNetworkCanvasURL(URL(string: "http://localhost:18793/")!) == true) - #expect(screen.isLocalNetworkCanvasURL(URL(string: "http://clawd.local:18793/")!) == true) - #expect(screen.isLocalNetworkCanvasURL(URL(string: "http://peters-mac-studio-1:18793/")!) == true) - #expect(screen.isLocalNetworkCanvasURL(URL(string: "https://peters-mac-studio-1.ts.net:18793/")!) == true) - #expect(screen.isLocalNetworkCanvasURL(URL(string: "http://192.168.0.10:18793/")!) == true) - #expect(screen.isLocalNetworkCanvasURL(URL(string: "http://10.0.0.10:18793/")!) == true) - #expect(screen.isLocalNetworkCanvasURL(URL(string: "http://100.123.224.76:18793/")!) == true) // Tailscale CGNAT + #expect(screen.isLocalNetworkCanvasURL(URL(string: "http://localhost:18789/")!) == true) + #expect(screen.isLocalNetworkCanvasURL(URL(string: "http://clawd.local:18789/")!) == true) + #expect(screen.isLocalNetworkCanvasURL(URL(string: "http://peters-mac-studio-1:18789/")!) == true) + #expect(screen.isLocalNetworkCanvasURL(URL(string: "https://peters-mac-studio-1.ts.net:18789/")!) == true) + #expect(screen.isLocalNetworkCanvasURL(URL(string: "http://192.168.0.10:18789/")!) == true) + #expect(screen.isLocalNetworkCanvasURL(URL(string: "http://10.0.0.10:18789/")!) == true) + #expect(screen.isLocalNetworkCanvasURL(URL(string: "http://100.123.224.76:18789/")!) == true) // Tailscale CGNAT #expect(screen.isLocalNetworkCanvasURL(URL(string: "https://example.com/")!) == false) #expect(screen.isLocalNetworkCanvasURL(URL(string: "http://8.8.8.8/")!) == false) } diff --git a/docs/android/connect.md b/docs/android/connect.md index 65fe284da..045c25f7b 100644 --- a/docs/android/connect.md +++ b/docs/android/connect.md @@ -105,18 +105,20 @@ The Android node’s Chat sheet uses the gateway’s **primary session key** (`m If you want the node to show real HTML/CSS/JS that the agent can edit on disk, point the node at the Gateway canvas host. +Note: the Gateway must be bound to a LAN/tailnet interface (`gateway.bind=lan|tailnet`) so the node can reach `:18789`. + 1) Create `~/clawd/canvas/index.html` on the gateway host. 2) Navigate the node to it (LAN): ```bash -clawdis nodes invoke --node "" --command canvas.navigate --params '{"url":"http://.local:18793/"}' +clawdis nodes invoke --node "" --command canvas.navigate --params '{"url":"http://.local:18789/__clawdis__/canvas/"}' ``` -Tailnet (optional): if both devices are on Tailscale, use a MagicDNS name or tailnet IP instead of `.local`, e.g. `http://:18793/`. +Tailnet (optional): if both devices are on Tailscale, use a MagicDNS name or tailnet IP instead of `.local`, e.g. `http://:18789/__clawdis__/canvas/`. This server injects a live-reload client into HTML and reloads on file changes. -The A2UI host lives at `http://:18793/__clawdis__/a2ui/`. +The A2UI host lives at `http://:18789/__clawdis__/a2ui/`. Canvas commands (foreground only): - `canvas.eval`, `canvas.snapshot`, `canvas.navigate` (use `{"url":""}` or `{"url":"/"}` to return to the default scaffold). `canvas.snapshot` returns `{ format, base64 }` (default `format="jpeg"`). diff --git a/docs/architecture.md b/docs/architecture.md index ceba263c3..a26ce7abf 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -11,7 +11,7 @@ Last updated: 2025-12-09 - A single long-lived **Gateway** process owns all messaging surfaces (WhatsApp via Baileys, Telegram when enabled) and the control/event plane. - All clients (macOS app, CLI, web UI, automations) connect to the Gateway over one transport: **WebSocket on 127.0.0.1:18789** (tunnel or VPN for remote). - One Gateway per host; it is the only place that is allowed to open a WhatsApp session. All sends/agent runs go through it. -- By default: the Gateway exposes a LAN/tailnet HTTP canvas host (`canvasHost`) to serve `~/clawd/canvas` to node WebViews (live-reloads on file changes); disable via `canvasHost.enabled=false` or `CLAWDIS_SKIP_CANVAS_HOST=1`. +- By default: the Gateway exposes a Canvas host on the **same port** as the Gateway WS, serving `~/clawd/canvas` at `/__clawdis__/canvas/` with live-reload; disable via `canvasHost.enabled=false` or `CLAWDIS_SKIP_CANVAS_HOST=1`. ## Components and flows - **Gateway (daemon)** diff --git a/docs/bonjour.md b/docs/bonjour.md index b0f7e505e..486d5277a 100644 --- a/docs/bonjour.md +++ b/docs/bonjour.md @@ -93,7 +93,7 @@ The Gateway advertises small non-secret hints to make UI flows convenient: - `sshPort=` (defaults to 22 when not overridden) - `gatewayPort=` (informational; the Gateway WS is typically loopback-only) - `bridgePort=` (only when bridge is enabled) -- `canvasPort=` (only when the canvas host is running; enabled by default; default `18793`) +- `canvasPort=` (only when the canvas host is enabled + reachable; same as `gatewayPort`; serves `/__clawdis__/canvas/`) - `cliPath=` (optional; absolute path to a runnable `clawdis` entrypoint or binary) - `tailnetDns=` (optional hint; auto-detected from Tailscale when available; may be absent) diff --git a/docs/browser.md b/docs/browser.md index 7a9879333..bacd88d73 100644 --- a/docs/browser.md +++ b/docs/browser.md @@ -46,7 +46,7 @@ Clawdis already uses: For the clawd browser-control server, use "family" ports: - Browser control HTTP API: `18791` (bridge + 1) - Browser CDP/debugging port: `18792` (control + 1) -- Canvas host HTTP (optional): `18793` (next free port; see `docs/configuration.md`) +- Canvas host HTTP: **same as the Gateway port** (`18789`), mounted at `/__clawdis__/canvas/` The user usually only configures the **control URL** (port `18791`). CDP is an internal detail. diff --git a/docs/configuration.md b/docs/configuration.md index 61189b03a..6747069af 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -157,6 +157,21 @@ Example: } ``` +### `skillsInstall` (installer preference) + +Controls which installer is surfaced by the macOS Skills UI when a skill offers +multiple install options (brew vs node). Defaults to **brew when available** and +**npm** for node installs. + +```json5 +{ + skillsInstall: { + preferBrew: true, + nodeManager: "npm" // npm | pnpm | bun + } +} +``` + ### `skillsLoad` Additional skill directories to scan (lowest precedence). This is useful if you keep skills in a separate repo but want Clawdis to pick them up without copying them into the workspace. @@ -216,29 +231,33 @@ Defaults: Notes: - `clawdis gateway` refuses to start unless `gateway.mode` is set to `local` (or you pass the override flag). -### `canvasHost` (LAN/tailnet Canvas file server + live reload) +### `canvasHost` (Gateway Canvas file server + live reload) -The Gateway serves a directory of HTML/CSS/JS over HTTP so iOS/Android nodes can simply `canvas.navigate` to it. +The Gateway serves a directory of HTML/CSS/JS over HTTP so iOS/Android nodes can `canvas.navigate` to it. Default root: `~/clawd/canvas` -Default port: `18793` (chosen to avoid the clawd browser CDP port `18792`) -The server listens on `0.0.0.0` so it works on LAN **and** Tailnet (Tailscale is optional). +Port: **same as the Gateway WebSocket/HTTP port** (default `18789`) +Path: `/__clawdis__/canvas/` +Live-reload WebSocket: `/__clawdis/ws` The server: - serves files under `canvasHost.root` - injects a tiny live-reload client into served HTML -- watches the directory and broadcasts reloads over a WebSocket endpoint at `/__clawdis/ws` +- watches the directory and broadcasts reloads over `/__clawdis/ws` - auto-creates a starter `index.html` when the directory is empty (so you see something immediately) ```json5 { canvasHost: { - root: "~/clawd/canvas", - port: 18793 + root: "~/clawd/canvas" } } ``` +Notes: +- `canvasHost.port` is deprecated/ignored (the Gateway port is always used). +- The bind host follows `gateway.bind` (loopback/lan/tailnet). + Disable with: - config: `canvasHost: { enabled: false }` - env: `CLAWDIS_SKIP_CANVAS_HOST=1` diff --git a/docs/discovery.md b/docs/discovery.md index 92097231d..6e5b718c4 100644 --- a/docs/discovery.md +++ b/docs/discovery.md @@ -54,7 +54,7 @@ Troubleshooting and beacon details: `docs/bonjour.md`. - `sshPort=22` (or whatever is advertised) - `gatewayPort=18789` (loopback WS port; informational) - `bridgePort=18790` (when bridge is enabled) - - `canvasPort=18793` (when the canvas host is running; enabled by default) + - `canvasPort=18789` (same as `gatewayPort` when the canvas host is enabled + gateway bind is non-loopback; serves `/__clawdis__/canvas/`) - `cliPath=` (optional; absolute path to a runnable `clawdis` entrypoint or binary) - `tailnetDns=` (optional hint; auto-detected when Tailscale is available) diff --git a/docs/gateway.md b/docs/gateway.md index 08b4fe3bf..ac7730c49 100644 --- a/docs/gateway.md +++ b/docs/gateway.md @@ -21,7 +21,7 @@ pnpm clawdis gateway --port 18789 --verbose pnpm clawdis gateway --force ``` - Binds WebSocket control plane to `127.0.0.1:` (default 18789). -- Starts a LAN/tailnet Canvas file server by default (default `http://0.0.0.0:18793`, serves `~/clawd/canvas`). Disable with `canvasHost.enabled=false` or `CLAWDIS_SKIP_CANVAS_HOST=1`. +- Starts a Canvas file server by default on the **same port** as the Gateway (`http://:18789/__clawdis__/canvas/`, serves `~/clawd/canvas`). Disable with `canvasHost.enabled=false` or `CLAWDIS_SKIP_CANVAS_HOST=1`. - Logs to stdout; use launchd/systemd to keep it alive and rotate logs. - Pass `--verbose` to mirror debug logging (handshakes, req/res, events) from the log file into stdio when troubleshooting. - `--force` uses `lsof` to find listeners on the chosen port, sends SIGTERM, logs what it killed, then starts the gateway (fails fast if `lsof` is missing). diff --git a/docs/index.md b/docs/index.md index 61f90c66e..34424652f 100644 --- a/docs/index.md +++ b/docs/index.md @@ -35,7 +35,7 @@ WhatsApp / Telegram ┌──────────────────────────┐ │ Gateway │ ws://127.0.0.1:18789 (loopback-only) │ (single source) │ tcp://0.0.0.0:18790 (Bridge) - │ │ http://0.0.0.0:18793 (Canvas host) + │ │ http://:18789/__clawdis__/canvas/ (Canvas host) └───────────┬───────────────┘ │ ├─ Pi agent (RPC) @@ -53,7 +53,7 @@ Most operations flow through the **Gateway** (`clawdis gateway`), a single long- - **Loopback-first**: Gateway WS defaults to `ws://127.0.0.1:18789`. - For Tailnet access, run `clawdis gateway --bind tailnet --token ...` (token is required for non-loopback binds). - **Bridge for nodes**: optional LAN/tailnet-facing bridge on `tcp://0.0.0.0:18790` for paired nodes (Bonjour-discoverable). -- **Canvas host**: LAN/tailnet HTTP file server (default `18793`) for node WebViews; see `docs/configuration.md` (`canvasHost`). +- **Canvas host**: HTTP file server on the Gateway port, serving `/__clawdis__/canvas/` for node WebViews; see `docs/configuration.md` (`canvasHost`). - **Remote use**: SSH tunnel or tailnet/VPN; see `docs/remote.md` and `docs/discovery.md`. ## Features (high level) diff --git a/docs/ios/connect.md b/docs/ios/connect.md index becc6b8f0..2aab482d1 100644 --- a/docs/ios/connect.md +++ b/docs/ios/connect.md @@ -126,18 +126,20 @@ The iOS node runs a WKWebView “Canvas” scaffold which exposes: If you want the node to show real HTML/CSS/JS that the agent can edit on disk, point it at the Gateway canvas host. +Note: the Gateway must be bound to a LAN/tailnet interface (`gateway.bind=lan|tailnet`) so the node can reach `:18789`. + 1) Create `~/clawd/canvas/index.html` on the gateway host. 2) Navigate the node to it (LAN): ```bash -clawdis nodes invoke --node "iOS Node" --command canvas.navigate --params '{"url":"http://.local:18793/"}' +clawdis nodes invoke --node "iOS Node" --command canvas.navigate --params '{"url":"http://.local:18789/__clawdis__/canvas/"}' ``` Notes: - The server injects a live-reload client into HTML and reloads on file changes. -- A2UI is hosted on the same canvas host at `http://:18793/__clawdis__/a2ui/`. -- Tailnet (optional): if both devices are on Tailscale, use a MagicDNS name or tailnet IP instead of `.local`, e.g. `http://:18793/`. +- A2UI is hosted on the same canvas host at `http://:18789/__clawdis__/a2ui/`. +- Tailnet (optional): if both devices are on Tailscale, use a MagicDNS name or tailnet IP instead of `.local`, e.g. `http://:18789/__clawdis__/canvas/`. - iOS may require App Transport Security allowances to load plain `http://` URLs; if it fails to load, prefer HTTPS or adjust the iOS app’s ATS config. ### Draw with `canvas.eval` diff --git a/docs/ios/spec.md b/docs/ios/spec.md index d1331c4c2..5221c9dc4 100644 --- a/docs/ios/spec.md +++ b/docs/ios/spec.md @@ -30,7 +30,7 @@ Non-goals (v1): ## Current repo reality (constraints we respect) - The Gateway WebSocket server binds to `127.0.0.1:18789` (`src/gateway/server.ts`) with an optional `CLAWDIS_GATEWAY_TOKEN`. -- The Gateway exposes a LAN/tailnet Canvas file server (`canvasHost`) by default so nodes can `canvas.navigate` to `http://:/` and auto-reload when files change (`docs/configuration.md`). +- The Gateway exposes a Canvas file server (`canvasHost`) on the **same port** as the Gateway, so nodes can `canvas.navigate` to `http://:18789/__clawdis__/canvas/` and auto-reload on file changes (`docs/configuration.md`). - macOS “Canvas” is controlled via the Gateway node protocol (`canvas.*`), matching iOS/Android (`docs/mac/canvas.md`). - Voice wake forwards via `GatewayChannel` to Gateway `agent` (mac app: `VoiceWakeForwarder` → `GatewayConnection.sendAgent`). diff --git a/docs/mac/canvas.md b/docs/mac/canvas.md index 94d347eb0..23cc73a9c 100644 --- a/docs/mac/canvas.md +++ b/docs/mac/canvas.md @@ -99,7 +99,7 @@ Use the main `clawdis` CLI; it invokes canvas commands via `node.invoke`. Canvas A2UI is hosted by the **Gateway canvas host** at: ``` -http(s)://:/__clawdis__/a2ui/ +http(s)://:18789/__clawdis__/a2ui/ ``` The macOS app simply renders that page in the Canvas panel. The agent can drive it with JSONL **server→client protocol messages** (one JSON object per line): diff --git a/docs/refactor/canvas-a2ui.md b/docs/refactor/canvas-a2ui.md index f9e0d87f8..1a823cb07 100644 --- a/docs/refactor/canvas-a2ui.md +++ b/docs/refactor/canvas-a2ui.md @@ -16,9 +16,10 @@ Status: Implemented · Date: 2025-12-20 - A2UI renders only when the **Gateway is reachable** (acceptable failure mode). ## Decision -All A2UI HTML/JS assets are **served by the Gateway’s Canvas host** over HTTP. -Nodes (mac/iOS/Android) **navigate to the Gateway URL** before applying A2UI -messages. No local custom-scheme or bundled fallback remains. +All A2UI HTML/JS assets are **served by the Gateway HTTP server** (single port, +same as the Gateway WebSocket). Nodes (mac/iOS/Android) **navigate to the +Gateway URL** before applying A2UI messages. No local custom-scheme or bundled +fallback remains. ## Why - One source of truth (TS) for A2UI rendering. @@ -36,14 +37,16 @@ messages. No local custom-scheme or bundled fallback remains. ## Gateway changes ### Serve A2UI assets -Add A2UI HTML/JS to the Gateway Canvas host, e.g.: +Add A2UI HTML/JS to the Gateway Canvas host (same HTTP server as the Gateway +WS), e.g.: ``` /__clawdis__/a2ui/ -> index.html /__clawdis__/a2ui/a2ui.bundle.js -> bundled A2UI runtime ``` -Use the existing Canvas host server (`src/canvas-host/server.ts`) to serve these +Serve Canvas files at `/__clawdis__/canvas/` and A2UI at `/__clawdis__/a2ui/`. +Use the shared Canvas host handler (`src/canvas-host/server.ts`) to serve these assets and inject the action bridge + live reload if desired. ### Derive HTTP host from WebSocket @@ -81,6 +84,7 @@ If `canvasHostUrl` is missing or unreachable: 1) Gateway - Add A2UI assets under `src/canvas-host/`. - Serve them at `/__clawdis__/a2ui/` (align with existing naming). + - Serve Canvas files at `/__clawdis__/canvas/` on the Gateway port. - Expose `canvasHostUrl` in handshake + bridge hello payloads. 2) Node runtimes - Update `canvas.a2ui.*` to navigate to `canvasHostUrl`. diff --git a/skills/clawdis-canvas/SKILL.md b/skills/clawdis-canvas/SKILL.md index 7993a700d..2d1af1087 100644 --- a/skills/clawdis-canvas/SKILL.md +++ b/skills/clawdis-canvas/SKILL.md @@ -1,7 +1,7 @@ --- name: clawdis-canvas description: Drive the Clawdis Canvas panel (present, eval, snapshot, A2UI) via the clawdis CLI, including gateway-hosted A2UI surfaces and action bridging. -metadata: {"clawdis":{"always":true}} +metadata: {"clawdis":{"emoji":"🎨","always":true}} --- # Clawdis Canvas @@ -21,7 +21,7 @@ A2UI Notes - Keep HTML under `~/clawd/canvas` when targeting remote nodes. - Use snapshot after renders to verify UI state. -- Treat A2UI as gateway-hosted at `http(s)://:/__clawdis__/a2ui/`. +- Treat A2UI as gateway-hosted at `http(s)://:18789/__clawdis__/a2ui/`. - Rely on `canvas a2ui push/reset` to auto-navigate the Canvas to the gateway-hosted A2UI page. - Expect A2UI to fail if the Gateway does not advertise `canvasHostUrl` or is unreachable: - `A2UI_HOST_NOT_CONFIGURED` diff --git a/src/canvas-host/a2ui.ts b/src/canvas-host/a2ui.ts index e76617a4f..fcc71a131 100644 --- a/src/canvas-host/a2ui.ts +++ b/src/canvas-host/a2ui.ts @@ -6,7 +6,8 @@ import { fileURLToPath } from "node:url"; import { detectMime } from "../media/mime.js"; export const A2UI_PATH = "/__clawdis__/a2ui"; -const WS_PATH = "/__clawdis/ws"; +export const CANVAS_HOST_PATH = "/__clawdis__/canvas"; +export const CANVAS_WS_PATH = "/__clawdis/ws"; let cachedA2uiRootReal: string | null | undefined; let resolvingA2uiRoot: Promise | null = null; @@ -132,7 +133,7 @@ export function injectCanvasLiveReload(html: string): string { try { const proto = location.protocol === "https:" ? "wss" : "ws"; - const ws = new WebSocket(proto + "://" + location.host + ${JSON.stringify(WS_PATH)}); + const ws = new WebSocket(proto + "://" + location.host + ${JSON.stringify(CANVAS_WS_PATH)}); ws.onmessage = (ev) => { if (String(ev.data || "") === "reload") location.reload(); }; diff --git a/src/canvas-host/server.test.ts b/src/canvas-host/server.test.ts index 45da42e1e..0895c6d5a 100644 --- a/src/canvas-host/server.test.ts +++ b/src/canvas-host/server.test.ts @@ -1,16 +1,22 @@ +import { createServer } from "node:http"; +import { type AddressInfo } from "node:net"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; import { WebSocket } from "ws"; import { defaultRuntime } from "../runtime.js"; -import { startCanvasHost } from "./server.js"; -import { injectCanvasLiveReload } from "./a2ui.js"; +import { createCanvasHostHandler, startCanvasHost } from "./server.js"; +import { + CANVAS_HOST_PATH, + CANVAS_WS_PATH, + injectCanvasLiveReload, +} from "./a2ui.js"; describe("canvas host", () => { it("injects live reload script", () => { const out = injectCanvasLiveReload("Hello"); - expect(out).toContain("/__clawdis/ws"); + expect(out).toContain(CANVAS_WS_PATH); expect(out).toContain("location.reload"); expect(out).toContain("clawdisCanvasA2UIAction"); expect(out).toContain("clawdisSendUserAction"); @@ -33,13 +39,66 @@ describe("canvas host", () => { expect(res.status).toBe(200); expect(html).toContain("Interactive test page"); expect(html).toContain("clawdisSendUserAction"); - expect(html).toContain("/__clawdis/ws"); + expect(html).toContain(CANVAS_WS_PATH); } finally { await server.close(); await fs.rm(dir, { recursive: true, force: true }); } }); + it("serves canvas content from the mounted base path", async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-canvas-")); + await fs.writeFile( + path.join(dir, "index.html"), + "v1", + "utf8", + ); + + const handler = await createCanvasHostHandler({ + runtime: defaultRuntime, + rootDir: dir, + basePath: CANVAS_HOST_PATH, + allowInTests: true, + }); + + const server = createServer((req, res) => { + void (async () => { + if (await handler.handleHttpRequest(req, res)) return; + res.statusCode = 404; + res.setHeader("Content-Type", "text/plain; charset=utf-8"); + res.end("Not Found"); + })(); + }); + server.on("upgrade", (req, socket, head) => { + if (handler.handleUpgrade(req, socket, head)) return; + socket.destroy(); + }); + + await new Promise((resolve) => + server.listen(0, "127.0.0.1", resolve), + ); + const port = (server.address() as AddressInfo).port; + + try { + const res = await fetch( + `http://127.0.0.1:${port}${CANVAS_HOST_PATH}/`, + ); + const html = await res.text(); + expect(res.status).toBe(200); + expect(html).toContain("v1"); + expect(html).toContain(CANVAS_WS_PATH); + + const miss = await fetch(`http://127.0.0.1:${port}/`); + expect(miss.status).toBe(404); + } finally { + await handler.close(); + await new Promise((resolve, reject) => + server.close((err) => (err ? reject(err) : resolve())), + ); + await fs.rm(dir, { recursive: true, force: true }); + } + }); + it("serves HTML with injection and broadcasts reload on file changes", async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-canvas-")); const index = path.join(dir, "index.html"); @@ -58,9 +117,11 @@ describe("canvas host", () => { const html = await res.text(); expect(res.status).toBe(200); expect(html).toContain("v1"); - expect(html).toContain("/__clawdis/ws"); + expect(html).toContain(CANVAS_WS_PATH); - const ws = new WebSocket(`ws://127.0.0.1:${server.port}/__clawdis/ws`); + const ws = new WebSocket( + `ws://127.0.0.1:${server.port}${CANVAS_WS_PATH}`, + ); await new Promise((resolve, reject) => { const timer = setTimeout( () => reject(new Error("ws open timeout")), diff --git a/src/canvas-host/server.ts b/src/canvas-host/server.ts index c4a83a0f2..de75493a0 100644 --- a/src/canvas-host/server.ts +++ b/src/canvas-host/server.ts @@ -1,15 +1,24 @@ import fs from "node:fs/promises"; -import http, { type Server } from "node:http"; +import http, { + type IncomingMessage, + type Server, + type ServerResponse, +} from "node:http"; +import type { Socket } from "node:net"; import os from "node:os"; import path from "node:path"; import chokidar from "chokidar"; -import express from "express"; import { type WebSocket, WebSocketServer } from "ws"; import { detectMime } from "../media/mime.js"; import type { RuntimeEnv } from "../runtime.js"; import { ensureDir, resolveUserPath } from "../utils.js"; -import { handleA2uiHttpRequest, injectCanvasLiveReload } from "./a2ui.js"; +import { + CANVAS_HOST_PATH, + CANVAS_WS_PATH, + handleA2uiHttpRequest, + injectCanvasLiveReload, +} from "./a2ui.js"; export type CanvasHostOpts = { runtime: RuntimeEnv; @@ -25,7 +34,23 @@ export type CanvasHostServer = { close: () => Promise; }; -const WS_PATH = "/__clawdis/ws"; +export type CanvasHostHandlerOpts = { + runtime: RuntimeEnv; + rootDir?: string; + basePath?: string; + allowInTests?: boolean; +}; + +export type CanvasHostHandler = { + rootDir: string; + basePath: string; + handleHttpRequest: ( + req: IncomingMessage, + res: ServerResponse, + ) => Promise; + handleUpgrade: (req: IncomingMessage, socket: Socket, head: Buffer) => boolean; + close: () => Promise; +}; function defaultIndexHTML() { return ` @@ -153,16 +178,14 @@ function isDisabledByEnv() { return false; } -export async function startCanvasHost( - opts: CanvasHostOpts, -): Promise { - if (isDisabledByEnv() && opts.allowInTests !== true) { - return { port: 0, rootDir: "", close: async () => {} }; - } +function normalizeBasePath(rawPath: string | undefined) { + const trimmed = (rawPath ?? CANVAS_HOST_PATH).trim(); + const normalized = normalizeUrlPath(trimmed || CANVAS_HOST_PATH); + if (normalized === "/") return "/"; + return normalized.replace(/\/+$/, ""); +} - const rootDir = resolveUserPath( - opts.rootDir ?? path.join(os.homedir(), "clawd", "canvas"), - ); +async function prepareCanvasRoot(rootDir: string) { await ensureDir(rootDir); const rootReal = await fs.realpath(rootDir); try { @@ -179,58 +202,29 @@ export async function startCanvasHost( // ignore; we'll still serve the "missing file" message if needed. } } + return rootReal; +} - const bindHost = opts.listenHost?.trim() || "0.0.0.0"; - const app = express(); - app.disable("x-powered-by"); +export async function createCanvasHostHandler( + opts: CanvasHostHandlerOpts, +): Promise { + const basePath = normalizeBasePath(opts.basePath); + if (isDisabledByEnv() && opts.allowInTests !== true) { + return { + rootDir: "", + basePath, + handleHttpRequest: async () => false, + handleUpgrade: () => false, + close: async () => {}, + }; + } - app.get(/.*/, async (req, res) => { - try { - const url = new URL(req.url ?? "/", "http://localhost"); - if (url.pathname === WS_PATH) { - res.status(426).send("upgrade required"); - return; - } + const rootDir = resolveUserPath( + opts.rootDir ?? path.join(os.homedir(), "clawd", "canvas"), + ); + const rootReal = await prepareCanvasRoot(rootDir); - if (await handleA2uiHttpRequest(req, res)) return; - - const filePath = await resolveFilePath(rootReal, url.pathname); - if (!filePath) { - if (url.pathname === "/" || url.pathname.endsWith("/")) { - res - .status(404) - .type("text/html") - .send( - `Clawdis Canvas
Missing file.\nCreate ${rootDir}/index.html
`, - ); - return; - } - res.status(404).send("not found"); - return; - } - - const lower = filePath.toLowerCase(); - const mime = - lower.endsWith(".html") || lower.endsWith(".htm") - ? "text/html" - : (detectMime({ filePath }) ?? "application/octet-stream"); - - res.setHeader("Cache-Control", "no-store"); - if (mime === "text/html") { - const html = await fs.readFile(filePath, "utf8"); - res.type("text/html; charset=utf-8").send(injectCanvasLiveReload(html)); - return; - } - - res.type(mime).send(await fs.readFile(filePath)); - } catch (err) { - opts.runtime.error(`canvasHost request failed: ${String(err)}`); - res.status(500).send("error"); - } - }); - - const server: Server = http.createServer(app); - const wss = new WebSocketServer({ server, path: WS_PATH }); + const wss = new WebSocketServer({ noServer: true }); const sockets = new Set(); wss.on("connection", (ws) => { sockets.add(ws); @@ -266,6 +260,143 @@ export async function startCanvasHost( }); watcher.on("all", () => scheduleReload()); + const handleUpgrade = ( + req: IncomingMessage, + socket: Socket, + head: Buffer, + ) => { + const url = new URL(req.url ?? "/", "http://localhost"); + if (url.pathname !== CANVAS_WS_PATH) return false; + wss.handleUpgrade(req, socket, head, (ws) => { + wss.emit("connection", ws, req); + }); + return true; + }; + + const handleHttpRequest = async ( + req: IncomingMessage, + res: ServerResponse, + ) => { + const urlRaw = req.url; + if (!urlRaw) return false; + + try { + const url = new URL(urlRaw, "http://localhost"); + if (url.pathname === CANVAS_WS_PATH) { + res.statusCode = 426; + res.setHeader("Content-Type", "text/plain; charset=utf-8"); + res.end("upgrade required"); + return true; + } + + let urlPath = url.pathname; + if (basePath !== "/") { + if (urlPath === basePath) { + urlPath = "/"; + } else if (urlPath.startsWith(`${basePath}/`)) { + urlPath = urlPath.slice(basePath.length) || "/"; + } else { + return false; + } + } + + if (req.method !== "GET" && req.method !== "HEAD") { + res.statusCode = 405; + res.setHeader("Content-Type", "text/plain; charset=utf-8"); + res.end("Method Not Allowed"); + return true; + } + + const filePath = await resolveFilePath(rootReal, urlPath); + if (!filePath) { + if (urlPath === "/" || urlPath.endsWith("/")) { + res.statusCode = 404; + res.setHeader("Content-Type", "text/html; charset=utf-8"); + res.end( + `Clawdis Canvas
Missing file.\nCreate ${rootDir}/index.html
`, + ); + return true; + } + res.statusCode = 404; + res.setHeader("Content-Type", "text/plain; charset=utf-8"); + res.end("not found"); + return true; + } + + const lower = filePath.toLowerCase(); + const mime = + lower.endsWith(".html") || lower.endsWith(".htm") + ? "text/html" + : (detectMime({ filePath }) ?? "application/octet-stream"); + + res.setHeader("Cache-Control", "no-store"); + if (mime === "text/html") { + const html = await fs.readFile(filePath, "utf8"); + res.setHeader("Content-Type", "text/html; charset=utf-8"); + res.end(injectCanvasLiveReload(html)); + return true; + } + + res.setHeader("Content-Type", mime); + res.end(await fs.readFile(filePath)); + return true; + } catch (err) { + opts.runtime.error(`canvasHost request failed: ${String(err)}`); + res.statusCode = 500; + res.setHeader("Content-Type", "text/plain; charset=utf-8"); + res.end("error"); + return true; + } + }; + + return { + rootDir, + basePath, + handleHttpRequest, + handleUpgrade, + close: async () => { + if (debounce) clearTimeout(debounce); + await watcher.close().catch(() => {}); + await new Promise((resolve) => wss.close(() => resolve())); + }, + }; +} + +export async function startCanvasHost( + opts: CanvasHostOpts, +): Promise { + if (isDisabledByEnv() && opts.allowInTests !== true) { + return { port: 0, rootDir: "", close: async () => {} }; + } + + const handler = await createCanvasHostHandler({ + runtime: opts.runtime, + rootDir: opts.rootDir, + basePath: "/", + allowInTests: opts.allowInTests, + }); + + const bindHost = opts.listenHost?.trim() || "0.0.0.0"; + const server: Server = http.createServer((req, res) => { + if (String(req.headers.upgrade ?? "").toLowerCase() === "websocket") return; + void (async () => { + if (await handleA2uiHttpRequest(req, res)) return; + if (await handler.handleHttpRequest(req, res)) return; + res.statusCode = 404; + res.setHeader("Content-Type", "text/plain; charset=utf-8"); + res.end("Not Found"); + })().catch((err) => { + opts.runtime.error(`canvasHost request failed: ${String(err)}`); + res.statusCode = 500; + res.setHeader("Content-Type", "text/plain; charset=utf-8"); + res.end("error"); + }); + }); + server.on("upgrade", (req, socket, head) => { + if (handler.handleUpgrade(req, socket, head)) return; + socket.destroy(); + }); + const listenPort = typeof opts.port === "number" && Number.isFinite(opts.port) && opts.port > 0 ? opts.port @@ -287,16 +418,14 @@ export async function startCanvasHost( const addr = server.address(); const boundPort = typeof addr === "object" && addr ? addr.port : 0; opts.runtime.log( - `canvas host listening on http://${bindHost}:${boundPort} (root ${rootDir})`, + `canvas host listening on http://${bindHost}:${boundPort} (root ${handler.rootDir})`, ); return { port: boundPort, - rootDir, + rootDir: handler.rootDir, close: async () => { - if (debounce) clearTimeout(debounce); - await watcher.close().catch(() => {}); - await new Promise((resolve) => wss.close(() => resolve())); + await handler.close(); await new Promise((resolve, reject) => server.close((err) => (err ? reject(err) : resolve())), ); diff --git a/src/config/config.ts b/src/config/config.ts index 3ca35b56d..82d604b96 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -97,7 +97,7 @@ export type CanvasHostConfig = { enabled?: boolean; /** Directory to serve (default: ~/clawd/canvas). */ root?: string; - /** HTTP port to listen on (default: 18793). */ + /** HTTP port to listen on (deprecated; Gateway port is used). */ port?: number; }; @@ -135,6 +135,11 @@ export type SkillsLoadConfig = { extraDirs?: string[]; }; +export type SkillsInstallConfig = { + preferBrew?: boolean; + nodeManager?: "npm" | "pnpm" | "bun"; +}; + export type ClawdisConfig = { identity?: { name?: string; @@ -185,6 +190,7 @@ export type ClawdisConfig = { canvasHost?: CanvasHostConfig; gateway?: GatewayConfig; skills?: Record; + skillsInstall?: SkillsInstallConfig; }; // New branding path (preferred) @@ -371,6 +377,14 @@ const ClawdisSchema = z.object({ extraDirs: z.array(z.string()).optional(), }) .optional(), + skillsInstall: z + .object({ + preferBrew: z.boolean().optional(), + nodeManager: z + .union([z.literal("npm"), z.literal("pnpm"), z.literal("bun")]) + .optional(), + }) + .optional(), skills: z .record( z.string(), diff --git a/src/gateway/server.test.ts b/src/gateway/server.test.ts index 37ef27824..0b5bc739b 100644 --- a/src/gateway/server.test.ts +++ b/src/gateway/server.test.ts @@ -1564,14 +1564,17 @@ describe("gateway server", () => { await server.close(); }); - test("hello-ok prefers gateway port for A2UI when tailnet present", async () => { + test("hello-ok advertises the gateway port for canvas host", async () => { const prevToken = process.env.CLAWDIS_GATEWAY_TOKEN; process.env.CLAWDIS_GATEWAY_TOKEN = "secret"; testTailnetIPv4.value = "100.64.0.1"; testGatewayBind = "lan"; const port = await getFreePort(); - const server = await startGatewayServer(port, { bind: "lan" }); + const server = await startGatewayServer(port, { + bind: "lan", + allowCanvasHostInTests: true, + }); const ws = new WebSocket(`ws://127.0.0.1:${port}`, { headers: { Host: `100.64.0.1:${port}` }, }); diff --git a/src/gateway/server.ts b/src/gateway/server.ts index 3bff1e824..7733b531c 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -18,11 +18,11 @@ import { normalizeThinkLevel, normalizeVerboseLevel, } from "../auto-reply/thinking.js"; +import { handleA2uiHttpRequest, CANVAS_HOST_PATH } from "../canvas-host/a2ui.js"; import { - type CanvasHostServer, - startCanvasHost, + type CanvasHostHandler, + createCanvasHostHandler, } from "../canvas-host/server.js"; -import { handleA2uiHttpRequest } from "../canvas-host/a2ui.js"; import { createDefaultDeps } from "../cli/deps.js"; import { agentCommand } from "../commands/agent.js"; import { getHealthSnapshot, type HealthSummary } from "../commands/health.js"; @@ -339,6 +339,10 @@ export type GatewayServerOptions = { * Default: config `gateway.controlUi.enabled` (or true when absent). */ controlUiEnabled?: boolean; + /** + * Test-only: allow canvas host startup even when NODE_ENV/VITEST would disable it. + */ + allowCanvasHostInTests?: boolean; }; function isLoopbackAddress(ip: string | undefined): boolean { @@ -1000,31 +1004,54 @@ export async function startGatewayServer( port = 18789, opts: GatewayServerOptions = {}, ): Promise { - const cfgForServer = loadConfig(); - const bindMode = opts.bind ?? cfgForServer.gateway?.bind ?? "loopback"; + const cfgAtStart = loadConfig(); + const bindMode = opts.bind ?? cfgAtStart.gateway?.bind ?? "loopback"; const bindHost = opts.host ?? resolveGatewayBindHost(bindMode); if (!bindHost) { throw new Error( "gateway bind is tailnet, but no tailnet interface was found; refusing to start gateway", ); } - const tailnetIPv4 = pickPrimaryTailnetIPv4(); - const tailnetIPv6 = pickPrimaryTailnetIPv6(); - const hasTailnet = Boolean(tailnetIPv4 || tailnetIPv6); const controlUiEnabled = - opts.controlUiEnabled ?? cfgForServer.gateway?.controlUi?.enabled ?? true; + opts.controlUiEnabled ?? cfgAtStart.gateway?.controlUi?.enabled ?? true; + const canvasHostEnabled = + process.env.CLAWDIS_SKIP_CANVAS_HOST !== "1" && + cfgAtStart.canvasHost?.enabled !== false; if (!isLoopbackHost(bindHost) && !getGatewayToken()) { throw new Error( `refusing to bind gateway to ${bindHost}:${port} without CLAWDIS_GATEWAY_TOKEN`, ); } + let canvasHost: CanvasHostHandler | null = null; + if (canvasHostEnabled) { + try { + const handler = await createCanvasHostHandler({ + runtime: defaultRuntime, + rootDir: cfgAtStart.canvasHost?.root, + basePath: CANVAS_HOST_PATH, + allowInTests: opts.allowCanvasHostInTests, + }); + if (handler.rootDir) { + canvasHost = handler; + defaultRuntime.log( + `canvas host mounted at http://${bindHost}:${port}${CANVAS_HOST_PATH}/ (root ${handler.rootDir})`, + ); + } + } catch (err) { + logWarn(`gateway: canvas host failed to start: ${String(err)}`); + } + } + const httpServer: HttpServer = createHttpServer((req, res) => { // Don't interfere with WebSocket upgrades; ws handles the 'upgrade' event. if (String(req.headers.upgrade ?? "").toLowerCase() === "websocket") return; void (async () => { - if (await handleA2uiHttpRequest(req, res)) return; + if (canvasHost) { + if (await handleA2uiHttpRequest(req, res)) return; + if (await canvasHost.handleHttpRequest(req, res)) return; + } if (controlUiEnabled) { if (handleControlUiHttpRequest(req, res)) return; } @@ -1040,7 +1067,6 @@ export async function startGatewayServer( }); let bonjourStop: (() => Promise) | null = null; let bridge: Awaited> | null = null; - let canvasHost: CanvasHostServer | null = null; const bridgeNodeSubscriptions = new Map>(); const bridgeSessionSubscribers = new Map>(); try { @@ -1072,9 +1098,15 @@ export async function startGatewayServer( } const wss = new WebSocketServer({ - server: httpServer, + noServer: true, maxPayload: MAX_PAYLOAD_BYTES, }); + httpServer.on("upgrade", (req, socket, head) => { + if (canvasHost?.handleUpgrade(req, socket, head)) return; + wss.handleUpgrade(req, socket, head, (ws) => { + wss.emit("connection", ws, req); + }); + }); const providerAbort = new AbortController(); const providerTasks: Array> = []; const clients = new Set(); @@ -1093,27 +1125,8 @@ export async function startGatewayServer( string, { controller: AbortController; sessionId: string; sessionKey: string } >(); - const cfgAtStart = loadConfig(); setCommandLaneConcurrency("cron", cfgAtStart.cron?.maxConcurrentRuns ?? 1); - const canvasHostEnabled = - process.env.CLAWDIS_SKIP_CANVAS_HOST !== "1" && - cfgAtStart.canvasHost?.enabled !== false; - const preferGatewayA2uiHost = hasTailnet && !isLoopbackHost(bindHost); - - if (canvasHostEnabled) { - try { - const server = await startCanvasHost({ - runtime: defaultRuntime, - rootDir: cfgAtStart.canvasHost?.root, - port: cfgAtStart.canvasHost?.port ?? 18793, - }); - if (server.port > 0) canvasHost = server; - } catch (err) { - logWarn(`gateway: canvas host failed to start: ${String(err)}`); - } - } - const cronStorePath = resolveCronStorePath(cfgAtStart.cron?.store); const cronLogger = getChildLogger({ module: "cron", @@ -2065,6 +2078,11 @@ export async function startGatewayServer( }; const machineDisplayName = await getMachineDisplayName(); + const bridgeHostIsLoopback = bridgeHost ? isLoopbackHost(bridgeHost) : false; + const canvasHostPortForBridge = + canvasHost && (!isLoopbackHost(bindHost) || bridgeHostIsLoopback) + ? port + : undefined; if (bridgeEnabled && bridgePort > 0 && bridgeHost) { try { @@ -2072,7 +2090,7 @@ export async function startGatewayServer( host: bridgeHost, port: bridgePort, serverName: machineDisplayName, - canvasHostPort: preferGatewayA2uiHost ? port : canvasHost?.port, + canvasHostPort: canvasHostPortForBridge, onRequest: (nodeId, req) => handleBridgeRequest(nodeId, req), onAuthenticated: async (node) => { const host = node.displayName?.trim() || node.nodeId; @@ -2188,7 +2206,7 @@ export async function startGatewayServer( instanceName: formatBonjourInstanceName(machineDisplayName), gatewayPort: port, bridgePort: bridge?.port, - canvasPort: canvasHost?.port, + canvasPort: canvasHostPortForBridge, sshPort, tailnetDns, cliPath: resolveBonjourCliPath(), @@ -2362,10 +2380,7 @@ export async function startGatewayServer( const remoteAddr = ( socket as WebSocket & { _socket?: { remoteAddress?: string } } )._socket?.remoteAddress; - const canvasHostUrl = deriveCanvasHostUrl( - req, - preferGatewayA2uiHost ? port : canvasHost?.port, - ); + const canvasHostUrl = deriveCanvasHostUrl(req, canvasHost ? port : undefined); logWs("in", "open", { connId, remoteAddr }); const isWebchatConnect = (params: ConnectParams | null | undefined) => params?.client?.mode === "webchat" || @@ -3255,6 +3270,7 @@ export async function startGatewayServer( skillName: p.name, installId: p.installId, timeoutMs: p.timeoutMs, + config: cfg, }); respond( result.ok,