diff --git a/src/commands/onboard-non-interactive.lan-auto-token.test.ts b/src/commands/onboard-non-interactive.lan-auto-token.test.ts new file mode 100644 index 000000000..3750dc2ae --- /dev/null +++ b/src/commands/onboard-non-interactive.lan-auto-token.test.ts @@ -0,0 +1,216 @@ +import fs from "node:fs/promises"; +import { createServer } from "node:net"; +import os from "node:os"; +import path from "node:path"; + +import { describe, expect, it } from "vitest"; +import { WebSocket } from "ws"; + +import { PROTOCOL_VERSION } from "../gateway/protocol/index.js"; +import { rawDataToString } from "../infra/ws.js"; + +async function getFreePort(): Promise { + return await new Promise((resolve, reject) => { + const srv = createServer(); + srv.on("error", reject); + srv.listen(0, "127.0.0.1", () => { + const addr = srv.address(); + if (!addr || typeof addr === "string") { + srv.close(); + reject(new Error("failed to acquire free port")); + return; + } + const port = addr.port; + srv.close((err) => { + if (err) reject(err); + else resolve(port); + }); + }); + }); +} + +async function isPortFree(port: number): Promise { + if (!Number.isFinite(port) || port <= 0 || port > 65535) return false; + return await new Promise((resolve) => { + const srv = createServer(); + srv.once("error", () => resolve(false)); + srv.listen(port, "127.0.0.1", () => { + srv.close(() => resolve(true)); + }); + }); +} + +async function getFreeGatewayPort(): Promise { + // Gateway uses derived ports (bridge/browser/canvas). Avoid flaky collisions by + // ensuring the common derived offsets are free too. + for (let attempt = 0; attempt < 25; attempt += 1) { + const port = await getFreePort(); + const candidates = [port, port + 1, port + 2, port + 4]; + const ok = ( + await Promise.all(candidates.map((candidate) => isPortFree(candidate))) + ).every(Boolean); + if (ok) return port; + } + throw new Error("failed to acquire a free gateway port block"); +} + +async function onceMessage( + ws: WebSocket, + filter: (obj: unknown) => boolean, + timeoutMs = 5000, +): Promise { + return await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error("timeout")), timeoutMs); + const closeHandler = (code: number, reason: Buffer) => { + clearTimeout(timer); + ws.off("message", handler); + reject(new Error(`closed ${code}: ${rawDataToString(reason)}`)); + }; + const handler = (data: WebSocket.RawData) => { + const obj = JSON.parse(rawDataToString(data)); + if (!filter(obj)) return; + clearTimeout(timer); + ws.off("message", handler); + ws.off("close", closeHandler); + resolve(obj as T); + }; + ws.on("message", handler); + ws.once("close", closeHandler); + }); +} + +async function connectReq(params: { url: string; token?: string }) { + const ws = new WebSocket(params.url); + await new Promise((resolve) => ws.once("open", resolve)); + ws.send( + JSON.stringify({ + type: "req", + id: "c1", + method: "connect", + params: { + minProtocol: PROTOCOL_VERSION, + maxProtocol: PROTOCOL_VERSION, + client: { + name: "vitest", + version: "dev", + platform: process.platform, + mode: "test", + }, + caps: [], + auth: params.token ? { token: params.token } : undefined, + }, + }), + ); + const res = await onceMessage<{ + type: "res"; + id: string; + ok: boolean; + error?: { message?: string }; + }>(ws, (o) => { + const obj = o as { type?: unknown; id?: unknown } | undefined; + return obj?.type === "res" && obj?.id === "c1"; + }); + ws.close(); + return res; +} + +describe("onboard (non-interactive): lan bind auto-token", () => { + it("auto-enables token auth when binding LAN and persists the token", async () => { + const prev = { + home: process.env.HOME, + stateDir: process.env.CLAWDBOT_STATE_DIR, + configPath: process.env.CLAWDBOT_CONFIG_PATH, + skipProviders: process.env.CLAWDBOT_SKIP_PROVIDERS, + skipGmail: process.env.CLAWDBOT_SKIP_GMAIL_WATCHER, + skipCron: process.env.CLAWDBOT_SKIP_CRON, + skipCanvas: process.env.CLAWDBOT_SKIP_CANVAS_HOST, + token: process.env.CLAWDBOT_GATEWAY_TOKEN, + }; + + process.env.CLAWDBOT_SKIP_PROVIDERS = "1"; + process.env.CLAWDBOT_SKIP_GMAIL_WATCHER = "1"; + process.env.CLAWDBOT_SKIP_CRON = "1"; + process.env.CLAWDBOT_SKIP_CANVAS_HOST = "1"; + delete process.env.CLAWDBOT_GATEWAY_TOKEN; + + const tempHome = await fs.mkdtemp( + path.join(os.tmpdir(), "clawdbot-onboard-lan-"), + ); + process.env.HOME = tempHome; + delete process.env.CLAWDBOT_STATE_DIR; + delete process.env.CLAWDBOT_CONFIG_PATH; + + const port = await getFreeGatewayPort(); + const workspace = path.join(tempHome, "clawd"); + + const runtime = { + log: () => {}, + error: (msg: string) => { + throw new Error(msg); + }, + exit: (code: number) => { + throw new Error(`exit:${code}`); + }, + }; + + const { runNonInteractiveOnboarding } = await import( + "./onboard-non-interactive.js" + ); + await runNonInteractiveOnboarding( + { + nonInteractive: true, + mode: "local", + workspace, + authChoice: "skip", + skipSkills: true, + skipHealth: true, + installDaemon: false, + gatewayPort: port, + gatewayBind: "lan", + gatewayAuth: "off", + }, + runtime, + ); + + const { CONFIG_PATH_CLAWDBOT } = await import("../config/config.js"); + const cfg = JSON.parse(await fs.readFile(CONFIG_PATH_CLAWDBOT, "utf8")) as { + gateway?: { + bind?: string; + port?: number; + auth?: { mode?: string; token?: string }; + }; + }; + + expect(cfg.gateway?.bind).toBe("lan"); + expect(cfg.gateway?.port).toBe(port); + expect(cfg.gateway?.auth?.mode).toBe("token"); + const token = cfg.gateway?.auth?.token ?? ""; + expect(token.length).toBeGreaterThan(8); + + const { startGatewayServer } = await import("../gateway/server.js"); + const server = await startGatewayServer(port, { controlUiEnabled: false }); + try { + const resNoToken = await connectReq({ url: `ws://127.0.0.1:${port}` }); + expect(resNoToken.ok).toBe(false); + expect(resNoToken.error?.message ?? "").toContain("unauthorized"); + + const resToken = await connectReq({ + url: `ws://127.0.0.1:${port}`, + token, + }); + expect(resToken.ok).toBe(true); + } finally { + await server.close({ reason: "lan auto-token test complete" }); + } + + await fs.rm(tempHome, { recursive: true, force: true }); + process.env.HOME = prev.home; + process.env.CLAWDBOT_STATE_DIR = prev.stateDir; + process.env.CLAWDBOT_CONFIG_PATH = prev.configPath; + process.env.CLAWDBOT_SKIP_PROVIDERS = prev.skipProviders; + process.env.CLAWDBOT_SKIP_GMAIL_WATCHER = prev.skipGmail; + process.env.CLAWDBOT_SKIP_CRON = prev.skipCron; + process.env.CLAWDBOT_SKIP_CANVAS_HOST = prev.skipCanvas; + process.env.CLAWDBOT_GATEWAY_TOKEN = prev.token; + }, 60_000); +}); diff --git a/src/commands/onboard-non-interactive.remote.test.ts b/src/commands/onboard-non-interactive.remote.test.ts new file mode 100644 index 000000000..c932393d2 --- /dev/null +++ b/src/commands/onboard-non-interactive.remote.test.ts @@ -0,0 +1,119 @@ +import fs from "node:fs/promises"; +import { createServer } from "node:net"; +import os from "node:os"; +import path from "node:path"; + +import { describe, expect, it } from "vitest"; + +async function getFreePort(): Promise { + return await new Promise((resolve, reject) => { + const srv = createServer(); + srv.on("error", reject); + srv.listen(0, "127.0.0.1", () => { + const addr = srv.address(); + if (!addr || typeof addr === "string") { + srv.close(); + reject(new Error("failed to acquire free port")); + return; + } + const port = addr.port; + srv.close((err) => { + if (err) reject(err); + else resolve(port); + }); + }); + }); +} + +describe("onboard (non-interactive): remote gateway config", () => { + it("writes gateway.remote url/token and callGateway uses them", async () => { + const prev = { + home: process.env.HOME, + stateDir: process.env.CLAWDBOT_STATE_DIR, + configPath: process.env.CLAWDBOT_CONFIG_PATH, + skipProviders: process.env.CLAWDBOT_SKIP_PROVIDERS, + skipGmail: process.env.CLAWDBOT_SKIP_GMAIL_WATCHER, + skipCron: process.env.CLAWDBOT_SKIP_CRON, + skipCanvas: process.env.CLAWDBOT_SKIP_CANVAS_HOST, + token: process.env.CLAWDBOT_GATEWAY_TOKEN, + password: process.env.CLAWDBOT_GATEWAY_PASSWORD, + }; + + process.env.CLAWDBOT_SKIP_PROVIDERS = "1"; + process.env.CLAWDBOT_SKIP_GMAIL_WATCHER = "1"; + process.env.CLAWDBOT_SKIP_CRON = "1"; + process.env.CLAWDBOT_SKIP_CANVAS_HOST = "1"; + delete process.env.CLAWDBOT_GATEWAY_TOKEN; + delete process.env.CLAWDBOT_GATEWAY_PASSWORD; + + const tempHome = await fs.mkdtemp( + path.join(os.tmpdir(), "clawdbot-onboard-remote-"), + ); + process.env.HOME = tempHome; + delete process.env.CLAWDBOT_STATE_DIR; + delete process.env.CLAWDBOT_CONFIG_PATH; + + const port = await getFreePort(); + const token = "tok_remote_123"; + const { startGatewayServer } = await import("../gateway/server.js"); + const server = await startGatewayServer(port, { + bind: "loopback", + auth: { mode: "token", token }, + controlUiEnabled: false, + }); + + const runtime = { + log: () => {}, + error: (msg: string) => { + throw new Error(msg); + }, + exit: (code: number) => { + throw new Error(`exit:${code}`); + }, + }; + + try { + const { runNonInteractiveOnboarding } = await import( + "./onboard-non-interactive.js" + ); + await runNonInteractiveOnboarding( + { + nonInteractive: true, + mode: "remote", + remoteUrl: `ws://127.0.0.1:${port}`, + remoteToken: token, + authChoice: "skip", + json: true, + }, + runtime, + ); + + const { CONFIG_PATH_CLAWDBOT } = await import("../config/config.js"); + const cfg = JSON.parse( + await fs.readFile(CONFIG_PATH_CLAWDBOT, "utf8"), + ) as { + gateway?: { mode?: string; remote?: { url?: string; token?: string } }; + }; + + expect(cfg.gateway?.mode).toBe("remote"); + expect(cfg.gateway?.remote?.url).toBe(`ws://127.0.0.1:${port}`); + expect(cfg.gateway?.remote?.token).toBe(token); + + const { callGateway } = await import("../gateway/call.js"); + const health = await callGateway<{ ok?: boolean }>({ method: "health" }); + expect(health?.ok).toBe(true); + } finally { + await server.close({ reason: "non-interactive remote test complete" }); + await fs.rm(tempHome, { recursive: true, force: true }); + process.env.HOME = prev.home; + process.env.CLAWDBOT_STATE_DIR = prev.stateDir; + process.env.CLAWDBOT_CONFIG_PATH = prev.configPath; + process.env.CLAWDBOT_SKIP_PROVIDERS = prev.skipProviders; + process.env.CLAWDBOT_SKIP_GMAIL_WATCHER = prev.skipGmail; + process.env.CLAWDBOT_SKIP_CRON = prev.skipCron; + process.env.CLAWDBOT_SKIP_CANVAS_HOST = prev.skipCanvas; + process.env.CLAWDBOT_GATEWAY_TOKEN = prev.token; + process.env.CLAWDBOT_GATEWAY_PASSWORD = prev.password; + } + }, 60_000); +});