From ac50a14b6a38cfda65849cb1e5cb830190afa42e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 18 Dec 2025 23:32:22 +0100 Subject: [PATCH] Gateway: enable canvas host + inject action bridge --- src/canvas-host/server.test.ts | 28 ++++- src/canvas-host/server.ts | 148 ++++++++++++++++++++++---- src/config/config.ts | 18 ---- src/gateway/client.maxpayload.test.ts | 32 ++++++ src/gateway/server.ts | 14 ++- 5 files changed, 198 insertions(+), 42 deletions(-) create mode 100644 src/gateway/client.maxpayload.test.ts diff --git a/src/canvas-host/server.test.ts b/src/canvas-host/server.test.ts index a159ca376..f2b462edf 100644 --- a/src/canvas-host/server.test.ts +++ b/src/canvas-host/server.test.ts @@ -11,6 +11,32 @@ describe("canvas host", () => { const out = injectCanvasLiveReload("Hello"); expect(out).toContain("/__clawdis/ws"); expect(out).toContain("location.reload"); + expect(out).toContain("clawdisCanvasA2UIAction"); + expect(out).toContain("clawdisSendUserAction"); + }); + + it("creates a default index.html when missing", async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-canvas-")); + + const server = await startCanvasHost({ + runtime: defaultRuntime, + rootDir: dir, + port: 0, + listenHost: "127.0.0.1", + allowInTests: true, + }); + + try { + const res = await fetch(`http://127.0.0.1:${server.port}/`); + const html = await res.text(); + expect(res.status).toBe(200); + expect(html).toContain("Interactive test page"); + expect(html).toContain("clawdisSendUserAction"); + expect(html).toContain("/__clawdis/ws"); + } finally { + await server.close(); + await fs.rm(dir, { recursive: true, force: true }); + } }); it("serves HTML with injection and broadcasts reload on file changes", async () => { @@ -22,7 +48,7 @@ describe("canvas host", () => { runtime: defaultRuntime, rootDir: dir, port: 0, - bind: "loopback", + listenHost: "127.0.0.1", allowInTests: true, }); diff --git a/src/canvas-host/server.ts b/src/canvas-host/server.ts index 2a8582b16..f54903d3f 100644 --- a/src/canvas-host/server.ts +++ b/src/canvas-host/server.ts @@ -6,8 +6,6 @@ import path from "node:path"; import chokidar from "chokidar"; import express from "express"; import { type WebSocket, WebSocketServer } from "ws"; -import type { BridgeBindMode } from "../config/config.js"; -import { pickPrimaryTailnetIPv4 } from "../infra/tailnet.js"; import { detectMime } from "../media/mime.js"; import type { RuntimeEnv } from "../runtime.js"; import { ensureDir, resolveUserPath } from "../utils.js"; @@ -16,7 +14,7 @@ export type CanvasHostOpts = { runtime: RuntimeEnv; rootDir?: string; port?: number; - bind?: BridgeBindMode; + listenHost?: string; allowInTests?: boolean; }; @@ -32,6 +30,40 @@ export function injectCanvasLiveReload(html: string): string { const snippet = ` +`; +} + function normalizeUrlPath(rawPath: string): string { const decoded = decodeURIComponent(rawPath || "/"); const normalized = path.posix.normalize(decoded); @@ -89,15 +201,6 @@ async function resolveFilePath(rootReal: string, urlPath: string) { } } -function resolveBindHost(bind: BridgeBindMode | undefined): string | null { - const mode = bind ?? "lan"; - if (mode === "loopback") return "127.0.0.1"; - if (mode === "lan") return "0.0.0.0"; - if (mode === "auto") return "0.0.0.0"; - if (mode === "tailnet") return pickPrimaryTailnetIPv4() ?? null; - return "0.0.0.0"; -} - function isDisabledByEnv() { if (process.env.CLAWDIS_SKIP_CANVAS_HOST === "1") return true; if (process.env.NODE_ENV === "test") return true; @@ -117,14 +220,23 @@ export async function startCanvasHost( ); await ensureDir(rootDir); const rootReal = await fs.realpath(rootDir); - - const bindHost = resolveBindHost(opts.bind); - if (!bindHost) { - throw new Error( - "canvasHost.bind is tailnet, but no tailnet interface was found; refusing to start canvas host", - ); + try { + const indexPath = path.join(rootReal, "index.html"); + await fs.stat(indexPath); + } catch { + try { + await fs.writeFile( + path.join(rootReal, "index.html"), + defaultIndexHTML(), + "utf8", + ); + } catch { + // ignore; we'll still serve the "missing file" message if needed. + } } + const bindHost = opts.listenHost?.trim() || "0.0.0.0"; + const app = express(); app.disable("x-powered-by"); diff --git a/src/config/config.ts b/src/config/config.ts index 2ac35e1e0..0ab587981 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -99,16 +99,6 @@ export type CanvasHostConfig = { root?: string; /** HTTP port to listen on (default: 18793). */ port?: number; - /** - * Bind address policy for the canvas host HTTP server. - * - auto: listen on all interfaces (LAN + tailnet) - * - lan: 0.0.0.0 (reachable on local network + any forwarded interfaces) - * - tailnet: bind only to the Tailscale interface IP (100.64.0.0/10) - * - loopback: 127.0.0.1 - * - * Recommended: lan (works on LAN + tailnet when present). - */ - bind?: BridgeBindMode; }; export type ClawdisConfig = { @@ -319,14 +309,6 @@ const ClawdisSchema = z.object({ enabled: z.boolean().optional(), root: z.string().optional(), port: z.number().int().positive().optional(), - bind: z - .union([ - z.literal("auto"), - z.literal("lan"), - z.literal("tailnet"), - z.literal("loopback"), - ]) - .optional(), }) .optional(), }); diff --git a/src/gateway/client.maxpayload.test.ts b/src/gateway/client.maxpayload.test.ts new file mode 100644 index 000000000..d7efb2fe7 --- /dev/null +++ b/src/gateway/client.maxpayload.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, test, vi } from "vitest"; + +describe("GatewayClient", () => { + test("uses a large maxPayload for node snapshots", async () => { + vi.resetModules(); + + class MockWebSocket { + static last: { url: unknown; opts: unknown } | null = null; + + on = vi.fn(); + close = vi.fn(); + send = vi.fn(); + + constructor(url: unknown, opts: unknown) { + MockWebSocket.last = { url, opts }; + } + } + + vi.doMock("ws", () => ({ + WebSocket: MockWebSocket, + })); + + const { GatewayClient } = await import("./client.js"); + const client = new GatewayClient({ url: "ws://127.0.0.1:1" }); + client.start(); + + expect(MockWebSocket.last?.url).toBe("ws://127.0.0.1:1"); + expect(MockWebSocket.last?.opts).toEqual( + expect.objectContaining({ maxPayload: 25 * 1024 * 1024 }), + ); + }); +}); diff --git a/src/gateway/server.ts b/src/gateway/server.ts index ca2ad7470..df00274ae 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -834,14 +834,18 @@ export async function startGatewayServer(port = 18789): Promise { const cfgAtStart = loadConfig(); setCommandLaneConcurrency("cron", cfgAtStart.cron?.maxConcurrentRuns ?? 1); - if (cfgAtStart.canvasHost?.enabled === true) { + const canvasHostEnabled = + process.env.CLAWDIS_SKIP_CANVAS_HOST !== "1" && + cfgAtStart.canvasHost?.enabled !== false; + + if (canvasHostEnabled) { try { - canvasHost = await startCanvasHost({ + const server = await startCanvasHost({ runtime: defaultRuntime, - rootDir: cfgAtStart.canvasHost.root, - port: cfgAtStart.canvasHost.port ?? 18793, - bind: cfgAtStart.canvasHost.bind ?? "lan", + 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)}`); }