import { randomUUID } from "node:crypto"; import fs from "node:fs/promises"; import { type AddressInfo, createServer } from "node:net"; import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { WebSocket } from "ws"; import { agentCommand } from "../commands/agent.js"; import { CONFIG_PATH_CLAWDIS, readConfigFileSnapshot, STATE_DIR_CLAWDIS, writeConfigFile, } from "../config/config.js"; import { emitAgentEvent, registerAgentRunContext, resetAgentRunContextForTest, } from "../infra/agent-events.js"; import { GatewayLockError } from "../infra/gateway-lock.js"; import { emitHeartbeatEvent } from "../infra/heartbeat-events.js"; import { drainSystemEvents, peekSystemEvents } from "../infra/system-events.js"; import { rawDataToString } from "../infra/ws.js"; import { PROTOCOL_VERSION } from "./protocol/index.js"; import { __resetModelCatalogCacheForTest, startGatewayServer, } from "./server.js"; type BridgeClientInfo = { nodeId: string; displayName?: string; platform?: string; version?: string; remoteIp?: string; deviceFamily?: string; modelIdentifier?: string; caps?: string[]; commands?: string[]; }; type BridgeStartOpts = { onAuthenticated?: (node: BridgeClientInfo) => Promise | void; onDisconnected?: (node: BridgeClientInfo) => Promise | void; onPairRequested?: (request: unknown) => Promise | void; onEvent?: ( nodeId: string, evt: { event: string; payloadJSON?: string | null }, ) => Promise | void; onRequest?: ( nodeId: string, req: { id: string; method: string; paramsJSON?: string | null }, ) => Promise< | { ok: true; payloadJSON?: string | null } | { ok: false; error: { code: string; message: string; details?: unknown } } >; }; const bridgeStartCalls = vi.hoisted(() => [] as BridgeStartOpts[]); const bridgeInvoke = vi.hoisted(() => vi.fn(async () => ({ type: "invoke-res", id: "1", ok: true, payloadJSON: JSON.stringify({ ok: true }), error: null, })), ); const bridgeListConnected = vi.hoisted(() => vi.fn(() => [] as BridgeClientInfo[]), ); const bridgeSendEvent = vi.hoisted(() => vi.fn()); const testTailnetIPv4 = vi.hoisted(() => ({ value: undefined as string | undefined, })); const piSdkMock = vi.hoisted(() => ({ enabled: false, discoverCalls: 0, models: [] as Array<{ id: string; name?: string; provider: string; contextWindow?: number; }>, })); const cronIsolatedRun = vi.hoisted(() => vi.fn(async () => ({ status: "ok", summary: "ok" })), ); vi.mock("@mariozechner/pi-coding-agent", async () => { const actual = await vi.importActual< typeof import("@mariozechner/pi-coding-agent") >("@mariozechner/pi-coding-agent"); return { ...actual, discoverModels: () => { if (!piSdkMock.enabled) return actual.discoverModels(); piSdkMock.discoverCalls += 1; return piSdkMock.models; }, }; }); vi.mock("../infra/bridge/server.js", () => ({ startNodeBridgeServer: vi.fn(async (opts: BridgeStartOpts) => { bridgeStartCalls.push(opts); return { port: 18790, close: async () => {}, listConnected: bridgeListConnected, invoke: bridgeInvoke, sendEvent: bridgeSendEvent, }; }), })); vi.mock("../cron/isolated-agent.js", () => ({ runCronIsolatedAgentTurn: (...args: unknown[]) => cronIsolatedRun(...args), })); vi.mock("../infra/tailnet.js", () => ({ pickPrimaryTailnetIPv4: () => testTailnetIPv4.value, pickPrimaryTailnetIPv6: () => undefined, })); let testSessionStorePath: string | undefined; let testAllowFrom: string[] | undefined; let testCronStorePath: string | undefined; let testCronEnabled: boolean | undefined = false; let testGatewayBind: "auto" | "lan" | "tailnet" | "loopback" | undefined; let testGatewayAuth: Record | undefined; let testHooksConfig: Record | undefined; let testCanvasHostPort: number | undefined; let testLegacyIssues: Array<{ path: string; message: string }> = []; let testLegacyParsed: Record = {}; let testMigrationConfig: Record | null = null; let testMigrationChanges: string[] = []; let testIsNixMode = false; const sessionStoreSaveDelayMs = vi.hoisted(() => ({ value: 0 })); vi.mock("../config/sessions.js", async () => { const actual = await vi.importActual( "../config/sessions.js", ); return { ...actual, saveSessionStore: vi.fn(async (storePath: string, store: unknown) => { const delay = sessionStoreSaveDelayMs.value; if (delay > 0) { await new Promise((resolve) => setTimeout(resolve, delay)); } return actual.saveSessionStore(storePath, store as never); }), }; }); vi.mock("../config/config.js", () => { const resolveConfigPath = () => path.join(os.homedir(), ".clawdis", "clawdis.json"); const readConfigFileSnapshot = async () => { if (testLegacyIssues.length > 0) { return { path: resolveConfigPath(), exists: true, raw: JSON.stringify(testLegacyParsed ?? {}), parsed: testLegacyParsed ?? {}, valid: false, config: {}, issues: testLegacyIssues.map((issue) => ({ path: issue.path, message: issue.message, })), legacyIssues: testLegacyIssues, }; } const configPath = resolveConfigPath(); try { await fs.access(configPath); } catch { return { path: configPath, exists: false, raw: null, parsed: {}, valid: true, config: {}, issues: [], legacyIssues: [], }; } try { const raw = await fs.readFile(configPath, "utf-8"); const parsed = JSON.parse(raw) as Record; return { path: configPath, exists: true, raw, parsed, valid: true, config: parsed, issues: [], legacyIssues: [], }; } catch (err) { return { path: configPath, exists: true, raw: null, parsed: {}, valid: false, config: {}, issues: [{ path: "", message: `read failed: ${String(err)}` }], legacyIssues: [], }; } }; const writeConfigFile = vi.fn(async (cfg: Record) => { const configPath = resolveConfigPath(); await fs.mkdir(path.dirname(configPath), { recursive: true }); const raw = JSON.stringify(cfg, null, 2).trimEnd().concat("\n"); await fs.writeFile(configPath, raw, "utf-8"); }); return { CONFIG_PATH_CLAWDIS: resolveConfigPath(), STATE_DIR_CLAWDIS: path.dirname(resolveConfigPath()), isNixMode: testIsNixMode, migrateLegacyConfig: (raw: unknown) => ({ config: testMigrationConfig ?? (raw as Record), changes: testMigrationChanges, }), loadConfig: () => ({ agent: { model: "anthropic/claude-opus-4-5", workspace: path.join(os.tmpdir(), "clawd-gateway-test"), }, whatsapp: { allowFrom: testAllowFrom, }, session: { mainKey: "main", store: testSessionStorePath }, gateway: (() => { const gateway: Record = {}; if (testGatewayBind) gateway.bind = testGatewayBind; if (testGatewayAuth) gateway.auth = testGatewayAuth; return Object.keys(gateway).length > 0 ? gateway : undefined; })(), canvasHost: (() => { const canvasHost: Record = {}; if (typeof testCanvasHostPort === "number") canvasHost.port = testCanvasHostPort; return Object.keys(canvasHost).length > 0 ? canvasHost : undefined; })(), hooks: testHooksConfig, cron: (() => { const cron: Record = {}; if (typeof testCronEnabled === "boolean") cron.enabled = testCronEnabled; if (typeof testCronStorePath === "string") cron.store = testCronStorePath; return Object.keys(cron).length > 0 ? cron : undefined; })(), }), parseConfigJson5: (raw: string) => { try { return { ok: true, parsed: JSON.parse(raw) as unknown }; } catch (err) { return { ok: false, error: String(err) }; } }, validateConfigObject: (parsed: unknown) => ({ ok: true, config: parsed as Record, issues: [], }), readConfigFileSnapshot, writeConfigFile, }; }); vi.mock("../commands/health.js", () => ({ getHealthSnapshot: vi.fn().mockResolvedValue({ ok: true, stub: true }), })); vi.mock("../commands/status.js", () => ({ getStatusSummary: vi.fn().mockResolvedValue({ ok: true }), })); vi.mock("../web/outbound.js", () => ({ sendMessageWhatsApp: vi .fn() .mockResolvedValue({ messageId: "msg-1", toJid: "jid-1" }), })); vi.mock("../commands/agent.js", () => ({ agentCommand: vi.fn().mockResolvedValue(undefined), })); process.env.CLAWDIS_SKIP_PROVIDERS = "1"; let previousHome: string | undefined; let tempHome: string | undefined; beforeEach(async () => { previousHome = process.env.HOME; tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-gateway-home-")); process.env.HOME = tempHome; sessionStoreSaveDelayMs.value = 0; testTailnetIPv4.value = undefined; testGatewayBind = undefined; testGatewayAuth = undefined; testHooksConfig = undefined; testCanvasHostPort = undefined; testLegacyIssues = []; testLegacyParsed = {}; testMigrationConfig = null; testMigrationChanges = []; testIsNixMode = false; cronIsolatedRun.mockClear(); drainSystemEvents(); resetAgentRunContextForTest(); __resetModelCatalogCacheForTest(); piSdkMock.enabled = false; piSdkMock.discoverCalls = 0; piSdkMock.models = []; }); afterEach(async () => { process.env.HOME = previousHome; if (tempHome) { await fs.rm(tempHome, { recursive: true, force: true }); tempHome = undefined; } }); async function getFreePort(): Promise { return await new Promise((resolve, reject) => { const server = createServer(); server.listen(0, "127.0.0.1", () => { const port = (server.address() as AddressInfo).port; server.close((err) => (err ? reject(err) : resolve(port))); }); }); } async function occupyPort(): Promise<{ server: ReturnType; port: number; }> { return await new Promise((resolve, reject) => { const server = createServer(); server.once("error", reject); server.listen(0, "127.0.0.1", () => { const port = (server.address() as AddressInfo).port; resolve({ server, port }); }); }); } function onceMessage( ws: WebSocket, filter: (obj: unknown) => boolean, timeoutMs = 3000, ): Promise { return 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}: ${reason.toString()}`)); }; const handler = (data: WebSocket.RawData) => { const obj = JSON.parse(rawDataToString(data)); if (filter(obj)) { clearTimeout(timer); ws.off("message", handler); ws.off("close", closeHandler); resolve(obj as T); } }; ws.on("message", handler); ws.once("close", closeHandler); }); } async function startServerWithClient(token?: string) { const port = await getFreePort(); const prev = process.env.CLAWDIS_GATEWAY_TOKEN; if (token === undefined) { delete process.env.CLAWDIS_GATEWAY_TOKEN; } else { process.env.CLAWDIS_GATEWAY_TOKEN = token; } const server = await startGatewayServer(port); const ws = new WebSocket(`ws://127.0.0.1:${port}`); await new Promise((resolve) => ws.once("open", resolve)); return { server, ws, port, prevToken: prev }; } type ConnectResponse = { type: "res"; id: string; ok: boolean; payload?: unknown; error?: { message?: string }; }; async function connectReq( ws: WebSocket, opts?: { token?: string; password?: string; minProtocol?: number; maxProtocol?: number; client?: { name: string; version: string; platform: string; mode: string; instanceId?: string; }; }, ): Promise { const id = randomUUID(); ws.send( JSON.stringify({ type: "req", id, method: "connect", params: { minProtocol: opts?.minProtocol ?? PROTOCOL_VERSION, maxProtocol: opts?.maxProtocol ?? PROTOCOL_VERSION, client: opts?.client ?? { name: "test", version: "1.0.0", platform: "test", mode: "test", }, caps: [], auth: opts?.token || opts?.password ? { token: opts?.token, password: opts?.password, } : undefined, }, }), ); return await onceMessage( ws, (o) => o.type === "res" && o.id === id, ); } async function connectOk( ws: WebSocket, opts?: Parameters[1], ) { const res = await connectReq(ws, opts); expect(res.ok).toBe(true); expect((res.payload as { type?: unknown } | undefined)?.type).toBe( "hello-ok", ); return res.payload as { type: "hello-ok" }; } async function rpcReq( ws: WebSocket, method: string, params?: unknown, ) { const id = randomUUID(); ws.send(JSON.stringify({ type: "req", id, method, params })); return await onceMessage<{ type: "res"; id: string; ok: boolean; payload?: T; error?: { message?: string }; }>(ws, (o) => o.type === "res" && o.id === id); } async function waitForSystemEvent(timeoutMs = 2000) { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { const events = peekSystemEvents(); if (events.length > 0) return events; await new Promise((resolve) => setTimeout(resolve, 10)); } throw new Error("timeout waiting for system event"); } describe("gateway server", () => { test( "voicewake.get returns defaults and voicewake.set broadcasts", { timeout: 15_000 }, async () => { const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-home-")); const prevHome = process.env.HOME; process.env.HOME = homeDir; const { server, ws } = await startServerWithClient(); await connectOk(ws); const initial = await rpcReq<{ triggers: string[] }>(ws, "voicewake.get"); expect(initial.ok).toBe(true); expect(initial.payload?.triggers).toEqual([ "clawd", "claude", "computer", ]); const changedP = onceMessage<{ type: "event"; event: string; payload?: unknown; }>(ws, (o) => o.type === "event" && o.event === "voicewake.changed"); const setRes = await rpcReq<{ triggers: string[] }>(ws, "voicewake.set", { triggers: [" hi ", "", "there"], }); expect(setRes.ok).toBe(true); expect(setRes.payload?.triggers).toEqual(["hi", "there"]); const changed = await changedP; expect(changed.event).toBe("voicewake.changed"); expect( (changed.payload as { triggers?: unknown } | undefined)?.triggers, ).toEqual(["hi", "there"]); const after = await rpcReq<{ triggers: string[] }>(ws, "voicewake.get"); expect(after.ok).toBe(true); expect(after.payload?.triggers).toEqual(["hi", "there"]); const onDisk = JSON.parse( await fs.readFile( path.join(homeDir, ".clawdis", "settings", "voicewake.json"), "utf8", ), ) as { triggers?: unknown; updatedAtMs?: unknown }; expect(onDisk.triggers).toEqual(["hi", "there"]); expect(typeof onDisk.updatedAtMs).toBe("number"); ws.close(); await server.close(); if (prevHome === undefined) { delete process.env.HOME; } else { process.env.HOME = prevHome; } }, ); test("auto-migrates legacy config on startup", async () => { (writeConfigFile as unknown as { mockClear?: () => void })?.mockClear?.(); testLegacyIssues = [ { path: "routing.allowFrom", message: "legacy", }, ]; testLegacyParsed = { routing: { allowFrom: ["+15555550123"] } }; testMigrationConfig = { whatsapp: { allowFrom: ["+15555550123"] } }; testMigrationChanges = ["Moved routing.allowFrom → whatsapp.allowFrom."]; const port = await getFreePort(); const server = await startGatewayServer(port); expect(writeConfigFile).toHaveBeenCalledWith(testMigrationConfig); await server.close(); }); test("fails in Nix mode when legacy config is present", async () => { testLegacyIssues = [ { path: "routing.allowFrom", message: "legacy", }, ]; testLegacyParsed = { routing: { allowFrom: ["+15555550123"] } }; testIsNixMode = true; const port = await getFreePort(); await expect(startGatewayServer(port)).rejects.toThrow( "Legacy config entries detected while running in Nix mode", ); }); test("models.list returns model catalog", async () => { piSdkMock.enabled = true; piSdkMock.models = [ { id: "gpt-test-z", provider: "openai", contextWindow: 0 }, { id: "gpt-test-a", name: "A-Model", provider: "openai", contextWindow: 8000, }, { id: "claude-test-b", name: "B-Model", provider: "anthropic", contextWindow: 1000, }, { id: "claude-test-a", name: "A-Model", provider: "anthropic", contextWindow: 200_000, }, ]; const { server, ws } = await startServerWithClient(); await connectOk(ws); const res1 = await rpcReq<{ models: Array<{ id: string; name: string; provider: string; contextWindow?: number; }>; }>(ws, "models.list"); const res2 = await rpcReq<{ models: Array<{ id: string; name: string; provider: string; contextWindow?: number; }>; }>(ws, "models.list"); expect(res1.ok).toBe(true); expect(res2.ok).toBe(true); const models = res1.payload?.models ?? []; expect(models).toEqual([ { id: "claude-test-a", name: "A-Model", provider: "anthropic", contextWindow: 200_000, }, { id: "claude-test-b", name: "B-Model", provider: "anthropic", contextWindow: 1000, }, { id: "gpt-test-a", name: "A-Model", provider: "openai", contextWindow: 8000, }, { id: "gpt-test-z", name: "gpt-test-z", provider: "openai", }, ]); // Cached across requests: should only call discoverModels once. expect(piSdkMock.discoverCalls).toBe(1); ws.close(); await server.close(); }); test("models.list rejects unknown params", async () => { piSdkMock.enabled = true; piSdkMock.models = [{ id: "gpt-test-a", name: "A", provider: "openai" }]; const { server, ws } = await startServerWithClient(); await connectOk(ws); const res = await rpcReq(ws, "models.list", { extra: true }); expect(res.ok).toBe(false); expect(res.error?.message ?? "").toMatch(/invalid models\.list params/i); ws.close(); await server.close(); }); test("bridge RPC supports models.list and validates params", async () => { piSdkMock.enabled = true; piSdkMock.models = [{ id: "gpt-test-a", name: "A", provider: "openai" }]; const { server, ws } = await startServerWithClient(); await connectOk(ws); const startCall = bridgeStartCalls.at(-1); expect(startCall).toBeTruthy(); const okRes = await startCall?.onRequest?.("n1", { id: "1", method: "models.list", paramsJSON: "{}", }); expect(okRes?.ok).toBe(true); const okPayload = JSON.parse(String(okRes?.payloadJSON ?? "{}")) as { models?: unknown; }; expect(Array.isArray(okPayload.models)).toBe(true); const badRes = await startCall?.onRequest?.("n1", { id: "2", method: "models.list", paramsJSON: JSON.stringify({ extra: true }), }); expect(badRes?.ok).toBe(false); expect(badRes && "error" in badRes ? badRes.error.code : "").toBe( "INVALID_REQUEST", ); ws.close(); await server.close(); }); test("pushes voicewake.changed to nodes on connect and on updates", async () => { const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-home-")); const prevHome = process.env.HOME; process.env.HOME = homeDir; bridgeSendEvent.mockClear(); bridgeListConnected.mockReturnValue([{ nodeId: "n1" }]); const { server, ws } = await startServerWithClient(); await connectOk(ws); const startCall = bridgeStartCalls.at(-1); expect(startCall).toBeTruthy(); await startCall?.onAuthenticated?.({ nodeId: "n1" }); const first = bridgeSendEvent.mock.calls.find( (c) => c[0]?.event === "voicewake.changed" && c[0]?.nodeId === "n1", )?.[0] as { payloadJSON?: string | null } | undefined; expect(first?.payloadJSON).toBeTruthy(); const firstPayload = JSON.parse(String(first?.payloadJSON)) as { triggers?: unknown; }; expect(firstPayload.triggers).toEqual(["clawd", "claude", "computer"]); bridgeSendEvent.mockClear(); const setRes = await rpcReq<{ triggers: string[] }>(ws, "voicewake.set", { triggers: ["clawd", "computer"], }); expect(setRes.ok).toBe(true); const broadcast = bridgeSendEvent.mock.calls.find( (c) => c[0]?.event === "voicewake.changed" && c[0]?.nodeId === "n1", )?.[0] as { payloadJSON?: string | null } | undefined; expect(broadcast?.payloadJSON).toBeTruthy(); const broadcastPayload = JSON.parse(String(broadcast?.payloadJSON)) as { triggers?: unknown; }; expect(broadcastPayload.triggers).toEqual(["clawd", "computer"]); ws.close(); await server.close(); if (prevHome === undefined) { delete process.env.HOME; } else { process.env.HOME = prevHome; } }); test("supports gateway-owned node pairing methods and events", async () => { const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-home-")); const prevHome = process.env.HOME; process.env.HOME = homeDir; const { server, ws } = await startServerWithClient(); await connectOk(ws); const requestedP = onceMessage<{ type: "event"; event: string; payload?: unknown; }>(ws, (o) => o.type === "event" && o.event === "node.pair.requested"); ws.send( JSON.stringify({ type: "req", id: "pair-req-1", method: "node.pair.request", params: { nodeId: "n1", displayName: "Node" }, }), ); const res1 = await onceMessage<{ type: "res"; ok: boolean; payload?: unknown; }>(ws, (o) => o.type === "res" && o.id === "pair-req-1"); expect(res1.ok).toBe(true); const req1 = (res1.payload as { request?: { requestId?: unknown } } | null) ?.request; const requestId = typeof req1?.requestId === "string" ? req1.requestId : ""; expect(requestId.length).toBeGreaterThan(0); const evt1 = await requestedP; expect(evt1.event).toBe("node.pair.requested"); expect((evt1.payload as { requestId?: unknown } | null)?.requestId).toBe( requestId, ); // Second request for same node should return the existing pending request // without emitting a second requested event. ws.send( JSON.stringify({ type: "req", id: "pair-req-2", method: "node.pair.request", params: { nodeId: "n1", displayName: "Node" }, }), ); const res2 = await onceMessage<{ type: "res"; ok: boolean; payload?: unknown; }>(ws, (o) => o.type === "res" && o.id === "pair-req-2"); expect(res2.ok).toBe(true); await expect( onceMessage( ws, (o) => o.type === "event" && o.event === "node.pair.requested", 200, ), ).rejects.toThrow(); const resolvedP = onceMessage<{ type: "event"; event: string; payload?: unknown; }>(ws, (o) => o.type === "event" && o.event === "node.pair.resolved"); ws.send( JSON.stringify({ type: "req", id: "pair-approve-1", method: "node.pair.approve", params: { requestId }, }), ); const approveRes = await onceMessage<{ type: "res"; ok: boolean; payload?: unknown; }>(ws, (o) => o.type === "res" && o.id === "pair-approve-1"); expect(approveRes.ok).toBe(true); const tokenValue = ( approveRes.payload as { node?: { token?: unknown } } | null )?.node?.token; const token = typeof tokenValue === "string" ? tokenValue : ""; expect(token.length).toBeGreaterThan(0); const evt2 = await resolvedP; expect((evt2.payload as { requestId?: unknown } | null)?.requestId).toBe( requestId, ); expect((evt2.payload as { decision?: unknown } | null)?.decision).toBe( "approved", ); ws.send( JSON.stringify({ type: "req", id: "pair-verify-1", method: "node.pair.verify", params: { nodeId: "n1", token }, }), ); const verifyRes = await onceMessage<{ type: "res"; ok: boolean; payload?: unknown; }>(ws, (o) => o.type === "res" && o.id === "pair-verify-1"); expect(verifyRes.ok).toBe(true); expect((verifyRes.payload as { ok?: unknown } | null)?.ok).toBe(true); ws.send( JSON.stringify({ type: "req", id: "pair-list-1", method: "node.pair.list", params: {}, }), ); const listRes = await onceMessage<{ type: "res"; ok: boolean; payload?: unknown; }>(ws, (o) => o.type === "res" && o.id === "pair-list-1"); expect(listRes.ok).toBe(true); const paired = (listRes.payload as { paired?: unknown } | null)?.paired; expect(Array.isArray(paired)).toBe(true); expect( (paired as Array<{ nodeId?: unknown }>).some((n) => n.nodeId === "n1"), ).toBe(true); ws.close(); await server.close(); await fs.rm(homeDir, { recursive: true, force: true }); if (prevHome === undefined) { delete process.env.HOME; } else { process.env.HOME = prevHome; } }); test("routes node.invoke to the node bridge", async () => { const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-home-")); const prevHome = process.env.HOME; process.env.HOME = homeDir; try { bridgeInvoke.mockResolvedValueOnce({ type: "invoke-res", id: "inv-1", ok: true, payloadJSON: JSON.stringify({ result: "4" }), error: null, }); const { server, ws } = await startServerWithClient(); try { await connectOk(ws); const res = await rpcReq(ws, "node.invoke", { nodeId: "ios-node", command: "canvas.eval", params: { javaScript: "2+2" }, timeoutMs: 123, idempotencyKey: "idem-1", }); expect(res.ok).toBe(true); expect(bridgeInvoke).toHaveBeenCalledWith( expect.objectContaining({ nodeId: "ios-node", command: "canvas.eval", paramsJSON: JSON.stringify({ javaScript: "2+2" }), timeoutMs: 123, }), ); } finally { ws.close(); await server.close(); } } finally { await fs.rm(homeDir, { recursive: true, force: true }); if (prevHome === undefined) { delete process.env.HOME; } else { process.env.HOME = prevHome; } } }); test("node.describe returns supported invoke commands for paired nodes", async () => { const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-home-")); const prevHome = process.env.HOME; process.env.HOME = homeDir; try { const { server, ws } = await startServerWithClient(); try { await connectOk(ws); const reqRes = await rpcReq<{ status?: string; request?: { requestId?: string }; }>(ws, "node.pair.request", { nodeId: "n1", displayName: "iPad", platform: "iPadOS", version: "dev", deviceFamily: "iPad", modelIdentifier: "iPad16,6", caps: ["canvas", "camera"], commands: ["canvas.eval", "canvas.snapshot", "camera.snap"], remoteIp: "10.0.0.10", }); expect(reqRes.ok).toBe(true); const requestId = reqRes.payload?.request?.requestId; expect(typeof requestId).toBe("string"); const approveRes = await rpcReq(ws, "node.pair.approve", { requestId, }); expect(approveRes.ok).toBe(true); const describeRes = await rpcReq<{ commands?: string[] }>( ws, "node.describe", { nodeId: "n1" }, ); expect(describeRes.ok).toBe(true); expect(describeRes.payload?.commands).toEqual([ "camera.snap", "canvas.eval", "canvas.snapshot", ]); } finally { ws.close(); await server.close(); } } finally { await fs.rm(homeDir, { recursive: true, force: true }); if (prevHome === undefined) { delete process.env.HOME; } else { process.env.HOME = prevHome; } } }); test("node.describe works for connected unpaired nodes (caps + commands)", async () => { const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-home-")); const prevHome = process.env.HOME; process.env.HOME = homeDir; try { const { server, ws } = await startServerWithClient(); try { await connectOk(ws); bridgeListConnected.mockReturnValueOnce([ { nodeId: "u1", displayName: "Unpaired Live", platform: "Android", version: "dev-live", remoteIp: "10.0.0.12", deviceFamily: "Android", modelIdentifier: "samsung SM-X926B", caps: ["canvas", "camera", "canvas"], commands: ["canvas.eval", "camera.snap", "canvas.eval"], }, ]); const describeRes = await rpcReq<{ paired?: boolean; connected?: boolean; caps?: string[]; commands?: string[]; deviceFamily?: string; modelIdentifier?: string; remoteIp?: string; }>(ws, "node.describe", { nodeId: "u1" }); expect(describeRes.ok).toBe(true); expect(describeRes.payload).toMatchObject({ paired: false, connected: true, deviceFamily: "Android", modelIdentifier: "samsung SM-X926B", remoteIp: "10.0.0.12", }); expect(describeRes.payload?.caps).toEqual(["camera", "canvas"]); expect(describeRes.payload?.commands).toEqual([ "camera.snap", "canvas.eval", ]); } finally { ws.close(); await server.close(); } } finally { await fs.rm(homeDir, { recursive: true, force: true }); if (prevHome === undefined) { delete process.env.HOME; } else { process.env.HOME = prevHome; } } }); test("node.list includes connected unpaired nodes with capabilities + commands", async () => { const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-home-")); const prevHome = process.env.HOME; process.env.HOME = homeDir; try { const { server, ws } = await startServerWithClient(); try { await connectOk(ws); const reqRes = await rpcReq<{ status?: string; request?: { requestId?: string }; }>(ws, "node.pair.request", { nodeId: "p1", displayName: "Paired", platform: "iPadOS", version: "dev", deviceFamily: "iPad", modelIdentifier: "iPad16,6", caps: ["canvas"], commands: ["canvas.eval"], remoteIp: "10.0.0.10", }); expect(reqRes.ok).toBe(true); const requestId = reqRes.payload?.request?.requestId; expect(typeof requestId).toBe("string"); const approveRes = await rpcReq(ws, "node.pair.approve", { requestId }); expect(approveRes.ok).toBe(true); bridgeListConnected.mockReturnValueOnce([ { nodeId: "p1", displayName: "Paired Live", platform: "iPadOS", version: "dev-live", remoteIp: "10.0.0.11", deviceFamily: "iPad", modelIdentifier: "iPad16,6", caps: ["canvas", "camera"], commands: ["canvas.snapshot", "canvas.eval"], }, { nodeId: "u1", displayName: "Unpaired Live", platform: "Android", version: "dev", remoteIp: "10.0.0.12", deviceFamily: "Android", modelIdentifier: "samsung SM-X926B", caps: ["canvas"], commands: ["canvas.eval"], }, ]); const listRes = await rpcReq<{ nodes?: Array<{ nodeId: string; paired?: boolean; connected?: boolean; caps?: string[]; commands?: string[]; displayName?: string; remoteIp?: string; }>; }>(ws, "node.list", {}); expect(listRes.ok).toBe(true); const nodes = listRes.payload?.nodes ?? []; const pairedNode = nodes.find((n) => n.nodeId === "p1"); expect(pairedNode).toMatchObject({ nodeId: "p1", paired: true, connected: true, displayName: "Paired Live", remoteIp: "10.0.0.11", }); expect(pairedNode?.caps?.slice().sort()).toEqual(["camera", "canvas"]); expect(pairedNode?.commands?.slice().sort()).toEqual([ "canvas.eval", "canvas.snapshot", ]); const unpairedNode = nodes.find((n) => n.nodeId === "u1"); expect(unpairedNode).toMatchObject({ nodeId: "u1", paired: false, connected: true, displayName: "Unpaired Live", }); expect(unpairedNode?.caps).toEqual(["canvas"]); expect(unpairedNode?.commands).toEqual(["canvas.eval"]); } finally { ws.close(); await server.close(); } } finally { await fs.rm(homeDir, { recursive: true, force: true }); if (prevHome === undefined) { delete process.env.HOME; } else { process.env.HOME = prevHome; } } }); test("emits presence updates for bridge connect/disconnect", async () => { const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-home-")); const prevHome = process.env.HOME; process.env.HOME = homeDir; try { const before = bridgeStartCalls.length; const { server, ws } = await startServerWithClient(); try { await connectOk(ws); const bridgeCall = bridgeStartCalls[before]; expect(bridgeCall).toBeTruthy(); const waitPresenceReason = async (reason: string) => { await onceMessage( ws, (o) => { if (o.type !== "event" || o.event !== "presence") return false; const payload = o.payload as { presence?: unknown } | null; const list = payload?.presence; if (!Array.isArray(list)) return false; return list.some( (p) => typeof p === "object" && p !== null && (p as { instanceId?: unknown }).instanceId === "node-1" && (p as { reason?: unknown }).reason === reason, ); }, 3000, ); }; const presenceConnectedP = waitPresenceReason("node-connected"); await bridgeCall?.onAuthenticated?.({ nodeId: "node-1", displayName: "Node", platform: "ios", version: "1.0", remoteIp: "10.0.0.10", }); await presenceConnectedP; const presenceDisconnectedP = waitPresenceReason("node-disconnected"); await bridgeCall?.onDisconnected?.({ nodeId: "node-1", displayName: "Node", platform: "ios", version: "1.0", remoteIp: "10.0.0.10", }); await presenceDisconnectedP; } finally { try { ws.close(); } catch { /* ignore */ } await server.close(); await fs.rm(homeDir, { recursive: true, force: true }); } } finally { if (prevHome === undefined) { delete process.env.HOME; } else { process.env.HOME = prevHome; } } }); test("supports cron.add and cron.list", async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-gw-cron-")); testCronStorePath = path.join(dir, "cron", "jobs.json"); await fs.mkdir(path.dirname(testCronStorePath), { recursive: true }); await fs.writeFile( testCronStorePath, JSON.stringify({ version: 1, jobs: [] }), ); const { server, ws } = await startServerWithClient(); await connectOk(ws); ws.send( JSON.stringify({ type: "req", id: "cron-add-1", method: "cron.add", params: { name: "daily", enabled: true, schedule: { kind: "every", everyMs: 60_000 }, sessionTarget: "main", wakeMode: "next-heartbeat", payload: { kind: "systemEvent", text: "hello" }, }, }), ); const addRes = await onceMessage<{ type: "res"; ok: boolean; payload?: unknown; }>(ws, (o) => o.type === "res" && o.id === "cron-add-1"); expect(addRes.ok).toBe(true); expect(typeof (addRes.payload as { id?: unknown } | null)?.id).toBe( "string", ); ws.send( JSON.stringify({ type: "req", id: "cron-list-1", method: "cron.list", params: { includeDisabled: true }, }), ); const listRes = await onceMessage<{ type: "res"; ok: boolean; payload?: unknown; }>(ws, (o) => o.type === "res" && o.id === "cron-list-1"); expect(listRes.ok).toBe(true); const jobs = (listRes.payload as { jobs?: unknown } | null)?.jobs; expect(Array.isArray(jobs)).toBe(true); expect((jobs as unknown[]).length).toBe(1); expect(((jobs as Array<{ name?: unknown }>)[0]?.name as string) ?? "").toBe( "daily", ); ws.close(); await server.close(); await fs.rm(dir, { recursive: true, force: true }); testCronStorePath = undefined; }); test("writes cron run history to runs/.jsonl", async () => { const dir = await fs.mkdtemp( path.join(os.tmpdir(), "clawdis-gw-cron-log-"), ); testCronStorePath = path.join(dir, "cron", "jobs.json"); await fs.mkdir(path.dirname(testCronStorePath), { recursive: true }); await fs.writeFile( testCronStorePath, JSON.stringify({ version: 1, jobs: [] }), ); const { server, ws } = await startServerWithClient(); await connectOk(ws); const atMs = Date.now() - 1; ws.send( JSON.stringify({ type: "req", id: "cron-add-log-1", method: "cron.add", params: { name: "log test", enabled: true, schedule: { kind: "at", atMs }, sessionTarget: "main", wakeMode: "next-heartbeat", payload: { kind: "systemEvent", text: "hello" }, }, }), ); const addRes = await onceMessage<{ type: "res"; ok: boolean; payload?: unknown; }>(ws, (o) => o.type === "res" && o.id === "cron-add-log-1"); expect(addRes.ok).toBe(true); const jobIdValue = (addRes.payload as { id?: unknown } | null)?.id; const jobId = typeof jobIdValue === "string" ? jobIdValue : ""; expect(jobId.length > 0).toBe(true); ws.send( JSON.stringify({ type: "req", id: "cron-run-log-1", method: "cron.run", params: { id: jobId, mode: "force" }, }), ); const runRes = await onceMessage<{ type: "res"; ok: boolean }>( ws, (o) => o.type === "res" && o.id === "cron-run-log-1", 8000, ); expect(runRes.ok).toBe(true); const logPath = path.join(dir, "cron", "runs", `${jobId}.jsonl`); const waitForLog = async () => { for (let i = 0; i < 200; i++) { const raw = await fs.readFile(logPath, "utf-8").catch(() => ""); if (raw.trim().length > 0) return raw; await new Promise((r) => setTimeout(r, 10)); } throw new Error("timeout waiting for cron run log"); }; const raw = await waitForLog(); const line = raw .split("\n") .map((l) => l.trim()) .filter(Boolean) .at(-1); const last = JSON.parse(line ?? "{}") as { jobId?: unknown; action?: unknown; status?: unknown; summary?: unknown; }; expect(last.action).toBe("finished"); expect(last.jobId).toBe(jobId); expect(last.status).toBe("ok"); expect(last.summary).toBe("hello"); ws.send( JSON.stringify({ type: "req", id: "cron-runs-1", method: "cron.runs", params: { id: jobId, limit: 50 }, }), ); const runsRes = await onceMessage<{ type: "res"; ok: boolean; payload?: unknown; }>(ws, (o) => o.type === "res" && o.id === "cron-runs-1", 8000); expect(runsRes.ok).toBe(true); const entries = (runsRes.payload as { entries?: unknown } | null)?.entries; expect(Array.isArray(entries)).toBe(true); expect((entries as Array<{ jobId?: unknown }>).at(-1)?.jobId).toBe(jobId); expect((entries as Array<{ summary?: unknown }>).at(-1)?.summary).toBe( "hello", ); ws.close(); await server.close(); await fs.rm(dir, { recursive: true, force: true }); testCronStorePath = undefined; }); test("writes cron run history to per-job runs/ when store is jobs.json", async () => { const dir = await fs.mkdtemp( path.join(os.tmpdir(), "clawdis-gw-cron-log-jobs-"), ); const cronDir = path.join(dir, "cron"); testCronStorePath = path.join(cronDir, "jobs.json"); await fs.mkdir(cronDir, { recursive: true }); await fs.writeFile( testCronStorePath, JSON.stringify({ version: 1, jobs: [] }), ); const { server, ws } = await startServerWithClient(); await connectOk(ws); const atMs = Date.now() - 1; ws.send( JSON.stringify({ type: "req", id: "cron-add-log-2", method: "cron.add", params: { name: "log test (jobs.json)", enabled: true, schedule: { kind: "at", atMs }, sessionTarget: "main", wakeMode: "next-heartbeat", payload: { kind: "systemEvent", text: "hello" }, }, }), ); const addRes = await onceMessage<{ type: "res"; ok: boolean; payload?: unknown; }>(ws, (o) => o.type === "res" && o.id === "cron-add-log-2"); expect(addRes.ok).toBe(true); const jobIdValue = (addRes.payload as { id?: unknown } | null)?.id; const jobId = typeof jobIdValue === "string" ? jobIdValue : ""; expect(jobId.length > 0).toBe(true); ws.send( JSON.stringify({ type: "req", id: "cron-run-log-2", method: "cron.run", params: { id: jobId, mode: "force" }, }), ); const runRes = await onceMessage<{ type: "res"; ok: boolean }>( ws, (o) => o.type === "res" && o.id === "cron-run-log-2", 8000, ); expect(runRes.ok).toBe(true); const logPath = path.join(cronDir, "runs", `${jobId}.jsonl`); const waitForLog = async () => { for (let i = 0; i < 200; i++) { const raw = await fs.readFile(logPath, "utf-8").catch(() => ""); if (raw.trim().length > 0) return raw; await new Promise((r) => setTimeout(r, 10)); } throw new Error("timeout waiting for per-job cron run log"); }; const raw = await waitForLog(); const line = raw .split("\n") .map((l) => l.trim()) .filter(Boolean) .at(-1); const last = JSON.parse(line ?? "{}") as { jobId?: unknown; action?: unknown; summary?: unknown; }; expect(last.action).toBe("finished"); expect(last.jobId).toBe(jobId); expect(last.summary).toBe("hello"); ws.send( JSON.stringify({ type: "req", id: "cron-runs-2", method: "cron.runs", params: { id: jobId, limit: 20 }, }), ); const runsRes = await onceMessage<{ type: "res"; ok: boolean; payload?: unknown; }>(ws, (o) => o.type === "res" && o.id === "cron-runs-2", 8000); expect(runsRes.ok).toBe(true); const entries = (runsRes.payload as { entries?: unknown } | null)?.entries; expect(Array.isArray(entries)).toBe(true); expect((entries as Array<{ jobId?: unknown }>).at(-1)?.jobId).toBe(jobId); expect((entries as Array<{ summary?: unknown }>).at(-1)?.summary).toBe( "hello", ); ws.close(); await server.close(); await fs.rm(dir, { recursive: true, force: true }); testCronStorePath = undefined; }); test("enables cron scheduler by default and runs due jobs automatically", async () => { const dir = await fs.mkdtemp( path.join(os.tmpdir(), "clawdis-gw-cron-default-on-"), ); testCronStorePath = path.join(dir, "cron", "jobs.json"); testCronEnabled = undefined; // omitted config => enabled by default try { await fs.mkdir(path.dirname(testCronStorePath), { recursive: true }); await fs.writeFile( testCronStorePath, JSON.stringify({ version: 1, jobs: [] }), ); const { server, ws } = await startServerWithClient(); await connectOk(ws); ws.send( JSON.stringify({ type: "req", id: "cron-status-1", method: "cron.status", params: {}, }), ); const statusRes = await onceMessage<{ type: "res"; id: string; ok: boolean; payload?: unknown; }>(ws, (o) => o.type === "res" && o.id === "cron-status-1"); expect(statusRes.ok).toBe(true); const statusPayload = statusRes.payload as | { enabled?: unknown; storePath?: unknown } | undefined; expect(statusPayload?.enabled).toBe(true); const storePath = typeof statusPayload?.storePath === "string" ? statusPayload.storePath : ""; expect(storePath).toContain("jobs.json"); const atMs = Date.now() + 80; ws.send( JSON.stringify({ type: "req", id: "cron-add-auto-1", method: "cron.add", params: { name: "auto run test", enabled: true, schedule: { kind: "at", atMs }, sessionTarget: "main", wakeMode: "next-heartbeat", payload: { kind: "systemEvent", text: "auto" }, }, }), ); const addRes = await onceMessage<{ type: "res"; ok: boolean; payload?: unknown; }>(ws, (o) => o.type === "res" && o.id === "cron-add-auto-1"); expect(addRes.ok).toBe(true); const jobIdValue = (addRes.payload as { id?: unknown } | null)?.id; const jobId = typeof jobIdValue === "string" ? jobIdValue : ""; expect(jobId.length > 0).toBe(true); const finishedEvt = await onceMessage<{ type: "event"; event: string; payload?: { jobId?: string; action?: string; status?: string } | null; }>( ws, (o) => o.type === "event" && o.event === "cron" && (o.payload as { jobId?: unknown } | null)?.jobId === jobId && (o.payload as { action?: unknown } | null)?.action === "finished", 8000, ); expect(finishedEvt.payload?.status).toBe("ok"); const waitForRuns = async () => { for (let i = 0; i < 200; i++) { ws.send( JSON.stringify({ type: "req", id: "cron-runs-auto-1", method: "cron.runs", params: { id: jobId, limit: 10 }, }), ); const runsRes = await onceMessage<{ type: "res"; ok: boolean; payload?: unknown; }>(ws, (o) => o.type === "res" && o.id === "cron-runs-auto-1", 8000); expect(runsRes.ok).toBe(true); const entries = (runsRes.payload as { entries?: unknown } | null) ?.entries; if (Array.isArray(entries) && entries.length > 0) return entries; await new Promise((r) => setTimeout(r, 10)); } throw new Error("timeout waiting for cron.runs entries"); }; const entries = (await waitForRuns()) as Array<{ jobId?: unknown }>; expect(entries.at(-1)?.jobId).toBe(jobId); ws.close(); await server.close(); } finally { testCronEnabled = false; testCronStorePath = undefined; await fs.rm(dir, { recursive: true, force: true }); } }); test("broadcasts heartbeat events and serves last-heartbeat", async () => { type HeartbeatPayload = { ts: number; status: string; to?: string; preview?: string; durationMs?: number; hasMedia?: boolean; reason?: string; }; type EventFrame = { type: "event"; event: string; payload?: HeartbeatPayload | null; }; type ResFrame = { type: "res"; id: string; ok: boolean; payload?: unknown; }; const { server, ws } = await startServerWithClient(); await connectOk(ws); const waitHeartbeat = onceMessage( ws, (o) => o.type === "event" && o.event === "heartbeat", ); emitHeartbeatEvent({ status: "sent", to: "+123", preview: "ping" }); const evt = await waitHeartbeat; expect(evt.payload?.status).toBe("sent"); expect(typeof evt.payload?.ts).toBe("number"); ws.send( JSON.stringify({ type: "req", id: "hb-last", method: "last-heartbeat", }), ); const last = await onceMessage( ws, (o) => o.type === "res" && o.id === "hb-last", ); expect(last.ok).toBe(true); const lastPayload = last.payload as HeartbeatPayload | null | undefined; expect(lastPayload?.status).toBe("sent"); expect(lastPayload?.ts).toBe(evt.payload?.ts); ws.send( JSON.stringify({ type: "req", id: "hb-toggle-off", method: "set-heartbeats", params: { enabled: false }, }), ); const toggle = await onceMessage( ws, (o) => o.type === "res" && o.id === "hb-toggle-off", ); expect(toggle.ok).toBe(true); expect((toggle.payload as { enabled?: boolean } | undefined)?.enabled).toBe( false, ); ws.close(); await server.close(); }); test("agent falls back to allowFrom when lastTo is stale", async () => { testAllowFrom = ["+436769770569"]; const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-gw-")); testSessionStorePath = path.join(dir, "sessions.json"); await fs.writeFile( testSessionStorePath, JSON.stringify( { main: { sessionId: "sess-main-stale", updatedAt: Date.now(), lastChannel: "whatsapp", lastTo: "+1555", }, }, null, 2, ), "utf-8", ); const { server, ws } = await startServerWithClient(); await connectOk(ws); ws.send( JSON.stringify({ type: "req", id: "agent-last-stale", method: "agent", params: { message: "hi", sessionKey: "main", channel: "last", deliver: true, idempotencyKey: "idem-agent-last-stale", }, }), ); await onceMessage( ws, (o) => o.type === "res" && o.id === "agent-last-stale", ); const spy = vi.mocked(agentCommand); expect(spy).toHaveBeenCalled(); const call = spy.mock.calls.at(-1)?.[0] as Record; expect(call.provider).toBe("whatsapp"); expect(call.to).toBe("+436769770569"); expect(call.sessionId).toBe("sess-main-stale"); ws.close(); await server.close(); testAllowFrom = undefined; }); test("agent routes main last-channel whatsapp", async () => { testAllowFrom = undefined; const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-gw-")); testSessionStorePath = path.join(dir, "sessions.json"); await fs.writeFile( testSessionStorePath, JSON.stringify( { main: { sessionId: "sess-main-whatsapp", updatedAt: Date.now(), lastChannel: "whatsapp", lastTo: "+1555", }, }, null, 2, ), "utf-8", ); const { server, ws } = await startServerWithClient(); await connectOk(ws); ws.send( JSON.stringify({ type: "req", id: "agent-last-whatsapp", method: "agent", params: { message: "hi", sessionKey: "main", channel: "last", deliver: true, idempotencyKey: "idem-agent-last-whatsapp", }, }), ); await onceMessage( ws, (o) => o.type === "res" && o.id === "agent-last-whatsapp", ); const spy = vi.mocked(agentCommand); expect(spy).toHaveBeenCalled(); const call = spy.mock.calls.at(-1)?.[0] as Record; expect(call.provider).toBe("whatsapp"); expect(call.to).toBe("+1555"); expect(call.deliver).toBe(true); expect(call.bestEffortDeliver).toBe(true); expect(call.sessionId).toBe("sess-main-whatsapp"); ws.close(); await server.close(); }); test("agent routes main last-channel telegram", async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-gw-")); testSessionStorePath = path.join(dir, "sessions.json"); await fs.writeFile( testSessionStorePath, JSON.stringify( { main: { sessionId: "sess-main", updatedAt: Date.now(), lastChannel: "telegram", lastTo: "123", }, }, null, 2, ), "utf-8", ); const { server, ws } = await startServerWithClient(); await connectOk(ws); ws.send( JSON.stringify({ type: "req", id: "agent-last", method: "agent", params: { message: "hi", sessionKey: "main", channel: "last", deliver: true, idempotencyKey: "idem-agent-last", }, }), ); await onceMessage(ws, (o) => o.type === "res" && o.id === "agent-last"); const spy = vi.mocked(agentCommand); expect(spy).toHaveBeenCalled(); const call = spy.mock.calls.at(-1)?.[0] as Record; expect(call.provider).toBe("telegram"); expect(call.to).toBe("123"); expect(call.deliver).toBe(true); expect(call.bestEffortDeliver).toBe(true); expect(call.sessionId).toBe("sess-main"); ws.close(); await server.close(); }); test("agent routes main last-channel discord", async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-gw-")); testSessionStorePath = path.join(dir, "sessions.json"); await fs.writeFile( testSessionStorePath, JSON.stringify( { main: { sessionId: "sess-discord", updatedAt: Date.now(), lastChannel: "discord", lastTo: "channel:discord-123", }, }, null, 2, ), "utf-8", ); const { server, ws } = await startServerWithClient(); await connectOk(ws); ws.send( JSON.stringify({ type: "req", id: "agent-last-discord", method: "agent", params: { message: "hi", sessionKey: "main", channel: "last", deliver: true, idempotencyKey: "idem-agent-last-discord", }, }), ); await onceMessage( ws, (o) => o.type === "res" && o.id === "agent-last-discord", ); const spy = vi.mocked(agentCommand); expect(spy).toHaveBeenCalled(); const call = spy.mock.calls.at(-1)?.[0] as Record; expect(call.provider).toBe("discord"); expect(call.to).toBe("channel:discord-123"); expect(call.deliver).toBe(true); expect(call.bestEffortDeliver).toBe(true); expect(call.sessionId).toBe("sess-discord"); ws.close(); await server.close(); }); test("agent routes main last-channel signal", async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-gw-")); testSessionStorePath = path.join(dir, "sessions.json"); await fs.writeFile( testSessionStorePath, JSON.stringify( { main: { sessionId: "sess-signal", updatedAt: Date.now(), lastChannel: "signal", lastTo: "+15551234567", }, }, null, 2, ), "utf-8", ); const { server, ws } = await startServerWithClient(); await connectOk(ws); ws.send( JSON.stringify({ type: "req", id: "agent-last-signal", method: "agent", params: { message: "hi", sessionKey: "main", channel: "last", deliver: true, idempotencyKey: "idem-agent-last-signal", }, }), ); await onceMessage( ws, (o) => o.type === "res" && o.id === "agent-last-signal", ); const spy = vi.mocked(agentCommand); expect(spy).toHaveBeenCalled(); const call = spy.mock.calls.at(-1)?.[0] as Record; expect(call.provider).toBe("signal"); expect(call.to).toBe("+15551234567"); expect(call.deliver).toBe(true); expect(call.bestEffortDeliver).toBe(true); expect(call.sessionId).toBe("sess-signal"); ws.close(); await server.close(); }); test("agent ignores webchat last-channel for routing", async () => { testAllowFrom = ["+1555"]; const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-gw-")); testSessionStorePath = path.join(dir, "sessions.json"); await fs.writeFile( testSessionStorePath, JSON.stringify( { main: { sessionId: "sess-main-webchat", updatedAt: Date.now(), lastChannel: "webchat", lastTo: "+1555", }, }, null, 2, ), "utf-8", ); const { server, ws } = await startServerWithClient(); await connectOk(ws); ws.send( JSON.stringify({ type: "req", id: "agent-webchat", method: "agent", params: { message: "hi", sessionKey: "main", channel: "last", deliver: true, idempotencyKey: "idem-agent-webchat", }, }), ); await onceMessage(ws, (o) => o.type === "res" && o.id === "agent-webchat"); const spy = vi.mocked(agentCommand); expect(spy).toHaveBeenCalled(); const call = spy.mock.calls.at(-1)?.[0] as Record; expect(call.provider).toBe("whatsapp"); expect(call.to).toBe("+1555"); expect(call.deliver).toBe(true); expect(call.bestEffortDeliver).toBe(true); expect(call.sessionId).toBe("sess-main-webchat"); ws.close(); await server.close(); }); 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 canvasPort = await getFreePort(); testCanvasHostPort = canvasPort; const port = await getFreePort(); 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}` }, }); await new Promise((resolve) => ws.once("open", resolve)); const hello = await connectOk(ws, { token: "secret" }); expect(hello.canvasHostUrl).toBe(`http://100.64.0.1:${canvasPort}`); ws.close(); await server.close(); if (prevToken === undefined) { delete process.env.CLAWDIS_GATEWAY_TOKEN; } else { process.env.CLAWDIS_GATEWAY_TOKEN = prevToken; } }); test("rejects protocol mismatch", async () => { const { server, ws } = await startServerWithClient(); try { const res = await connectReq(ws, { minProtocol: PROTOCOL_VERSION + 1, maxProtocol: PROTOCOL_VERSION + 2, }); expect(res.ok).toBe(false); } catch { // If the server closed before we saw the frame, that's acceptable for mismatch. } ws.close(); await server.close(); }); test("rejects invalid token", async () => { const { server, ws, prevToken } = await startServerWithClient("secret"); const res = await connectReq(ws, { token: "wrong" }); expect(res.ok).toBe(false); expect(res.error?.message ?? "").toContain("unauthorized"); ws.close(); await server.close(); process.env.CLAWDIS_GATEWAY_TOKEN = prevToken; }); test("accepts password auth when configured", async () => { testGatewayAuth = { mode: "password", password: "secret" }; const port = await getFreePort(); const server = await startGatewayServer(port); const ws = new WebSocket(`ws://127.0.0.1:${port}`); await new Promise((resolve) => ws.once("open", resolve)); const res = await connectReq(ws, { password: "secret" }); expect(res.ok).toBe(true); ws.close(); await server.close(); }); test("rejects invalid password", async () => { testGatewayAuth = { mode: "password", password: "secret" }; const port = await getFreePort(); const server = await startGatewayServer(port); const ws = new WebSocket(`ws://127.0.0.1:${port}`); await new Promise((resolve) => ws.once("open", resolve)); const res = await connectReq(ws, { password: "wrong" }); expect(res.ok).toBe(false); expect(res.error?.message ?? "").toContain("unauthorized"); ws.close(); await server.close(); }); test( "closes silent handshakes after timeout", { timeout: 15_000 }, async () => { const { server, ws } = await startServerWithClient(); const closed = await new Promise((resolve) => { const timer = setTimeout(() => resolve(false), 12_000); ws.once("close", () => { clearTimeout(timer); resolve(true); }); }); expect(closed).toBe(true); await server.close(); }, ); test("connect (req) handshake returns hello-ok payload", async () => { const { server, ws } = await startServerWithClient(); const id = randomUUID(); ws.send( JSON.stringify({ type: "req", id, method: "connect", params: { minProtocol: PROTOCOL_VERSION, maxProtocol: PROTOCOL_VERSION, client: { name: "test", version: "1.0.0", platform: "test", mode: "test", }, caps: [], }, }), ); const res = await onceMessage<{ ok: boolean; payload?: unknown }>( ws, (o) => o.type === "res" && o.id === id, ); expect(res.ok).toBe(true); const payload = res.payload as | { type?: unknown; snapshot?: { configPath?: string; stateDir?: string }; } | undefined; expect(payload?.type).toBe("hello-ok"); expect(payload?.snapshot?.configPath).toBe(CONFIG_PATH_CLAWDIS); expect(payload?.snapshot?.stateDir).toBe(STATE_DIR_CLAWDIS); ws.close(); await server.close(); }); test("rejects non-connect first request", async () => { const { server, ws } = await startServerWithClient(); ws.send(JSON.stringify({ type: "req", id: "h1", method: "health" })); const res = await onceMessage<{ ok: boolean; error?: unknown }>( ws, (o) => o.type === "res" && o.id === "h1", ); expect(res.ok).toBe(false); await new Promise((resolve) => ws.once("close", () => resolve())); await server.close(); }); test( "connect + health + presence + status succeed", { timeout: 8000 }, async () => { const { server, ws } = await startServerWithClient(); await connectOk(ws); const healthP = onceMessage( ws, (o) => o.type === "res" && o.id === "health1", ); const statusP = onceMessage( ws, (o) => o.type === "res" && o.id === "status1", ); const presenceP = onceMessage( ws, (o) => o.type === "res" && o.id === "presence1", ); const providersP = onceMessage( ws, (o) => o.type === "res" && o.id === "providers1", ); const sendReq = (id: string, method: string) => ws.send(JSON.stringify({ type: "req", id, method })); sendReq("health1", "health"); sendReq("status1", "status"); sendReq("presence1", "system-presence"); sendReq("providers1", "providers.status"); const health = await healthP; const status = await statusP; const presence = await presenceP; const providers = await providersP; expect(health.ok).toBe(true); expect(status.ok).toBe(true); expect(presence.ok).toBe(true); expect(providers.ok).toBe(true); expect(Array.isArray(presence.payload)).toBe(true); ws.close(); await server.close(); }, ); test("providers.status returns snapshot without probe", async () => { const prevToken = process.env.TELEGRAM_BOT_TOKEN; delete process.env.TELEGRAM_BOT_TOKEN; const { server, ws } = await startServerWithClient(); await connectOk(ws); const res = await rpcReq<{ whatsapp?: { linked?: boolean }; telegram?: { configured?: boolean; tokenSource?: string; probe?: unknown; lastProbeAt?: unknown; }; signal?: { configured?: boolean; probe?: unknown; lastProbeAt?: unknown; }; }>(ws, "providers.status", { probe: false, timeoutMs: 2000 }); expect(res.ok).toBe(true); expect(res.payload?.whatsapp).toBeTruthy(); expect(res.payload?.telegram?.configured).toBe(false); expect(res.payload?.telegram?.tokenSource).toBe("none"); expect(res.payload?.telegram?.probe).toBeUndefined(); expect(res.payload?.telegram?.lastProbeAt).toBeNull(); expect(res.payload?.signal?.configured).toBe(false); expect(res.payload?.signal?.probe).toBeUndefined(); expect(res.payload?.signal?.lastProbeAt).toBeNull(); ws.close(); await server.close(); if (prevToken === undefined) { delete process.env.TELEGRAM_BOT_TOKEN; } else { process.env.TELEGRAM_BOT_TOKEN = prevToken; } }); test("web.logout reports no session when missing", async () => { const { server, ws } = await startServerWithClient(); await connectOk(ws); const res = await rpcReq<{ cleared?: boolean }>(ws, "web.logout"); expect(res.ok).toBe(true); expect(res.payload?.cleared).toBe(false); ws.close(); await server.close(); }); test("telegram.logout clears bot token from config", async () => { const prevToken = process.env.TELEGRAM_BOT_TOKEN; delete process.env.TELEGRAM_BOT_TOKEN; await writeConfigFile({ telegram: { botToken: "123:abc", requireMention: false }, }); const { server, ws } = await startServerWithClient(); await connectOk(ws); const res = await rpcReq<{ cleared?: boolean; envToken?: boolean }>( ws, "telegram.logout", ); expect(res.ok).toBe(true); expect(res.payload?.cleared).toBe(true); expect(res.payload?.envToken).toBe(false); const snap = await readConfigFileSnapshot(); expect(snap.valid).toBe(true); expect(snap.config?.telegram?.botToken).toBeUndefined(); expect(snap.config?.telegram?.requireMention).toBe(false); ws.close(); await server.close(); if (prevToken === undefined) { delete process.env.TELEGRAM_BOT_TOKEN; } else { process.env.TELEGRAM_BOT_TOKEN = prevToken; } }); test( "presence events carry seq + stateVersion", { timeout: 8000 }, async () => { const { server, ws } = await startServerWithClient(); await connectOk(ws); const presenceEventP = onceMessage( ws, (o) => o.type === "event" && o.event === "presence", ); ws.send( JSON.stringify({ type: "req", id: "evt-1", method: "system-event", params: { text: "note from test" }, }), ); const evt = await presenceEventP; expect(typeof evt.seq).toBe("number"); expect(evt.stateVersion?.presence).toBeGreaterThan(0); expect(Array.isArray(evt.payload?.presence)).toBe(true); ws.close(); await server.close(); }, ); test("agent events stream with seq", { timeout: 8000 }, async () => { const { server, ws } = await startServerWithClient(); await connectOk(ws); // Emit a fake agent event directly through the shared emitter. const runId = randomUUID(); const evtPromise = onceMessage( ws, (o) => o.type === "event" && o.event === "agent" && o.payload?.runId === runId && o.payload?.stream === "job", ); emitAgentEvent({ runId, stream: "job", data: { msg: "hi" } }); const evt = await evtPromise; expect(evt.payload.runId).toBe(runId); expect(typeof evt.seq).toBe("number"); expect(evt.payload.data.msg).toBe("hi"); ws.close(); await server.close(); }); test( "agent ack response then final response", { timeout: 8000 }, async () => { const { server, ws } = await startServerWithClient(); await connectOk(ws); const ackP = onceMessage( ws, (o) => o.type === "res" && o.id === "ag1" && o.payload?.status === "accepted", ); const finalP = onceMessage( ws, (o) => o.type === "res" && o.id === "ag1" && o.payload?.status !== "accepted", ); ws.send( JSON.stringify({ type: "req", id: "ag1", method: "agent", params: { message: "hi", idempotencyKey: "idem-ag" }, }), ); const ack = await ackP; const final = await finalP; expect(ack.payload.runId).toBeDefined(); expect(final.payload.runId).toBe(ack.payload.runId); expect(final.payload.status).toBe("ok"); ws.close(); await server.close(); }, ); test( "agent dedupes by idempotencyKey after completion", { timeout: 8000 }, async () => { const { server, ws } = await startServerWithClient(); await connectOk(ws); const firstFinalP = onceMessage( ws, (o) => o.type === "res" && o.id === "ag1" && o.payload?.status !== "accepted", ); ws.send( JSON.stringify({ type: "req", id: "ag1", method: "agent", params: { message: "hi", idempotencyKey: "same-agent" }, }), ); const firstFinal = await firstFinalP; const secondP = onceMessage( ws, (o) => o.type === "res" && o.id === "ag2", ); ws.send( JSON.stringify({ type: "req", id: "ag2", method: "agent", params: { message: "hi again", idempotencyKey: "same-agent" }, }), ); const second = await secondP; expect(second.payload).toEqual(firstFinal.payload); ws.close(); await server.close(); }, ); test("shutdown event is broadcast on close", { timeout: 8000 }, async () => { const { server, ws } = await startServerWithClient(); await connectOk(ws); const shutdownP = onceMessage( ws, (o) => o.type === "event" && o.event === "shutdown", 5000, ); await server.close(); const evt = await shutdownP; expect(evt.payload?.reason).toBeDefined(); }); test( "presence broadcast reaches multiple clients", { timeout: 8000 }, async () => { const port = await getFreePort(); const server = await startGatewayServer(port); const mkClient = async () => { const c = new WebSocket(`ws://127.0.0.1:${port}`); await new Promise((resolve) => c.once("open", resolve)); await connectOk(c); return c; }; const clients = await Promise.all([mkClient(), mkClient(), mkClient()]); const waits = clients.map((c) => onceMessage(c, (o) => o.type === "event" && o.event === "presence"), ); clients[0].send( JSON.stringify({ type: "req", id: "broadcast", method: "system-event", params: { text: "fanout" }, }), ); const events = await Promise.all(waits); for (const evt of events) { expect(evt.payload?.presence?.length).toBeGreaterThan(0); expect(typeof evt.seq).toBe("number"); } for (const c of clients) c.close(); await server.close(); }, ); test("send dedupes by idempotencyKey", { timeout: 8000 }, async () => { const { server, ws } = await startServerWithClient(); await connectOk(ws); const idem = "same-key"; const res1P = onceMessage(ws, (o) => o.type === "res" && o.id === "a1"); const res2P = onceMessage(ws, (o) => o.type === "res" && o.id === "a2"); const sendReq = (id: string) => ws.send( JSON.stringify({ type: "req", id, method: "send", params: { to: "+15550000000", message: "hi", idempotencyKey: idem }, }), ); sendReq("a1"); sendReq("a2"); const res1 = await res1P; const res2 = await res2P; expect(res1.ok).toBe(true); expect(res2.ok).toBe(true); expect(res1.payload).toEqual(res2.payload); ws.close(); await server.close(); }); test("agent dedupe survives reconnect", { timeout: 15000 }, async () => { const port = await getFreePort(); const server = await startGatewayServer(port); const dial = async () => { const ws = new WebSocket(`ws://127.0.0.1:${port}`); await new Promise((resolve) => ws.once("open", resolve)); await connectOk(ws); return ws; }; const idem = "reconnect-agent"; const ws1 = await dial(); const final1P = onceMessage( ws1, (o) => o.type === "res" && o.id === "ag1" && o.payload?.status !== "accepted", 6000, ); ws1.send( JSON.stringify({ type: "req", id: "ag1", method: "agent", params: { message: "hi", idempotencyKey: idem }, }), ); const final1 = await final1P; ws1.close(); const ws2 = await dial(); const final2P = onceMessage( ws2, (o) => o.type === "res" && o.id === "ag2" && o.payload?.status !== "accepted", 6000, ); ws2.send( JSON.stringify({ type: "req", id: "ag2", method: "agent", params: { message: "hi again", idempotencyKey: idem }, }), ); const res = await final2P; expect(res.payload).toEqual(final1.payload); ws2.close(); await server.close(); }); test("chat.send accepts image attachment", { timeout: 12000 }, async () => { const { server, ws } = await startServerWithClient(); await connectOk(ws); const reqId = "chat-img"; ws.send( JSON.stringify({ type: "req", id: reqId, method: "chat.send", params: { sessionKey: "main", message: "see image", idempotencyKey: "idem-img", attachments: [ { type: "image", mimeType: "image/png", fileName: "dot.png", content: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/woAAn8B9FD5fHAAAAAASUVORK5CYII=", }, ], }, }), ); const res = await onceMessage( ws, (o) => o.type === "res" && o.id === reqId, 8000, ); expect(res.ok).toBe(true); expect(res.payload?.runId).toBeDefined(); ws.close(); await server.close(); }); test("chat.history caps large histories and honors limit", async () => { const firstContentText = (msg: unknown): string | undefined => { if (!msg || typeof msg !== "object") return undefined; const content = (msg as { content?: unknown }).content; if (!Array.isArray(content) || content.length === 0) return undefined; const first = content[0]; if (!first || typeof first !== "object") return undefined; const text = (first as { text?: unknown }).text; return typeof text === "string" ? text : undefined; }; const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-gw-")); testSessionStorePath = path.join(dir, "sessions.json"); await fs.writeFile( testSessionStorePath, JSON.stringify( { main: { sessionId: "sess-main", updatedAt: Date.now(), }, }, null, 2, ), "utf-8", ); const lines: string[] = []; for (let i = 0; i < 300; i += 1) { lines.push( JSON.stringify({ message: { role: "user", content: [{ type: "text", text: `m${i}` }], timestamp: Date.now() + i, }, }), ); } await fs.writeFile( path.join(dir, "sess-main.jsonl"), lines.join("\n"), "utf-8", ); const { server, ws } = await startServerWithClient(); await connectOk(ws); const defaultRes = await rpcReq<{ messages?: unknown[] }>( ws, "chat.history", { sessionKey: "main", }, ); expect(defaultRes.ok).toBe(true); const defaultMsgs = defaultRes.payload?.messages ?? []; expect(defaultMsgs.length).toBe(200); expect(firstContentText(defaultMsgs[0])).toBe("m100"); const limitedRes = await rpcReq<{ messages?: unknown[] }>( ws, "chat.history", { sessionKey: "main", limit: 5, }, ); expect(limitedRes.ok).toBe(true); const limitedMsgs = limitedRes.payload?.messages ?? []; expect(limitedMsgs.length).toBe(5); expect(firstContentText(limitedMsgs[0])).toBe("m295"); const largeLines: string[] = []; for (let i = 0; i < 1500; i += 1) { largeLines.push( JSON.stringify({ message: { role: "user", content: [{ type: "text", text: `b${i}` }], timestamp: Date.now() + i, }, }), ); } await fs.writeFile( path.join(dir, "sess-main.jsonl"), largeLines.join("\n"), "utf-8", ); const cappedRes = await rpcReq<{ messages?: unknown[] }>( ws, "chat.history", { sessionKey: "main", }, ); expect(cappedRes.ok).toBe(true); const cappedMsgs = cappedRes.payload?.messages ?? []; expect(cappedMsgs.length).toBe(200); expect(firstContentText(cappedMsgs[0])).toBe("b1300"); const maxRes = await rpcReq<{ messages?: unknown[] }>(ws, "chat.history", { sessionKey: "main", limit: 1000, }); expect(maxRes.ok).toBe(true); const maxMsgs = maxRes.payload?.messages ?? []; expect(maxMsgs.length).toBe(1000); expect(firstContentText(maxMsgs[0])).toBe("b500"); ws.close(); await server.close(); }); test("chat.history caps payload bytes", { timeout: 15_000 }, async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-gw-")); testSessionStorePath = path.join(dir, "sessions.json"); await fs.writeFile( testSessionStorePath, JSON.stringify( { main: { sessionId: "sess-main", updatedAt: Date.now(), }, }, null, 2, ), "utf-8", ); const { server, ws } = await startServerWithClient(); await connectOk(ws); const bigText = "x".repeat(200_000); const largeLines: string[] = []; for (let i = 0; i < 40; i += 1) { largeLines.push( JSON.stringify({ message: { role: "user", content: [{ type: "text", text: `${i}:${bigText}` }], timestamp: Date.now() + i, }, }), ); } await fs.writeFile( path.join(dir, "sess-main.jsonl"), largeLines.join("\n"), "utf-8", ); const cappedRes = await rpcReq<{ messages?: unknown[] }>( ws, "chat.history", { sessionKey: "main", limit: 1000 }, ); expect(cappedRes.ok).toBe(true); const cappedMsgs = cappedRes.payload?.messages ?? []; const bytes = Buffer.byteLength(JSON.stringify(cappedMsgs), "utf8"); expect(bytes).toBeLessThanOrEqual(6 * 1024 * 1024); expect(cappedMsgs.length).toBeLessThan(60); ws.close(); await server.close(); }); test("chat.send does not overwrite last delivery route", async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-gw-")); testSessionStorePath = path.join(dir, "sessions.json"); await fs.writeFile( testSessionStorePath, JSON.stringify( { main: { sessionId: "sess-main", updatedAt: Date.now(), lastChannel: "whatsapp", lastTo: "+1555", }, }, null, 2, ), "utf-8", ); const { server, ws } = await startServerWithClient(); await connectOk(ws); const reqId = "chat-route"; ws.send( JSON.stringify({ type: "req", id: reqId, method: "chat.send", params: { sessionKey: "main", message: "hello", idempotencyKey: "idem-route", }, }), ); const res = await onceMessage( ws, (o) => o.type === "res" && o.id === reqId, ); expect(res.ok).toBe(true); const stored = JSON.parse( await fs.readFile(testSessionStorePath, "utf-8"), ) as { main?: { lastChannel?: string; lastTo?: string }; }; expect(stored.main?.lastChannel).toBe("whatsapp"); expect(stored.main?.lastTo).toBe("+1555"); ws.close(); await server.close(); }); test( "chat.abort cancels an in-flight chat.send", { timeout: 15000 }, async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-gw-")); testSessionStorePath = path.join(dir, "sessions.json"); await fs.writeFile( testSessionStorePath, JSON.stringify( { main: { sessionId: "sess-main", updatedAt: Date.now(), }, }, null, 2, ), "utf-8", ); const { server, ws } = await startServerWithClient(); let inFlight: Promise | undefined; try { await connectOk(ws); const spy = vi.mocked(agentCommand); const callsBefore = spy.mock.calls.length; spy.mockImplementationOnce(async (opts) => { const signal = (opts as { abortSignal?: AbortSignal }).abortSignal; await new Promise((resolve) => { if (!signal) return resolve(); if (signal.aborted) return resolve(); signal.addEventListener("abort", () => resolve(), { once: true }); }); }); const sendResP = onceMessage( ws, (o) => o.type === "res" && o.id === "send-abort-1", 8000, ); const abortResP = onceMessage( ws, (o) => o.type === "res" && o.id === "abort-1", 8000, ); const abortedEventP = onceMessage( ws, (o) => o.type === "event" && o.event === "chat" && o.payload?.state === "aborted", 8000, ); inFlight = Promise.allSettled([sendResP, abortResP, abortedEventP]); ws.send( JSON.stringify({ type: "req", id: "send-abort-1", method: "chat.send", params: { sessionKey: "main", message: "hello", idempotencyKey: "idem-abort-1", timeoutMs: 30_000, }, }), ); await new Promise((resolve, reject) => { const deadline = Date.now() + 1000; const tick = () => { if (spy.mock.calls.length > callsBefore) return resolve(); if (Date.now() > deadline) return reject(new Error("timeout waiting for agentCommand")); setTimeout(tick, 5); }; tick(); }); ws.send( JSON.stringify({ type: "req", id: "abort-1", method: "chat.abort", params: { sessionKey: "main", runId: "idem-abort-1" }, }), ); const abortRes = await abortResP; expect(abortRes.ok).toBe(true); const sendRes = await sendResP; expect(sendRes.ok).toBe(true); const evt = await abortedEventP; expect(evt.payload?.runId).toBe("idem-abort-1"); expect(evt.payload?.sessionKey).toBe("main"); } finally { ws.close(); await inFlight; await server.close(); } }, ); test("chat.abort cancels while saving the session store", async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-gw-")); testSessionStorePath = path.join(dir, "sessions.json"); await fs.writeFile( testSessionStorePath, JSON.stringify( { main: { sessionId: "sess-main", updatedAt: Date.now(), }, }, null, 2, ), "utf-8", ); sessionStoreSaveDelayMs.value = 120; const { server, ws } = await startServerWithClient(); await connectOk(ws); const spy = vi.mocked(agentCommand); spy.mockImplementationOnce(async (opts) => { const signal = (opts as { abortSignal?: AbortSignal }).abortSignal; await new Promise((resolve) => { if (!signal) return resolve(); if (signal.aborted) return resolve(); signal.addEventListener("abort", () => resolve(), { once: true }); }); }); const abortedEventP = onceMessage( ws, (o) => o.type === "event" && o.event === "chat" && o.payload?.state === "aborted", ); const sendResP = onceMessage( ws, (o) => o.type === "res" && o.id === "send-abort-save-1", ); ws.send( JSON.stringify({ type: "req", id: "send-abort-save-1", method: "chat.send", params: { sessionKey: "main", message: "hello", idempotencyKey: "idem-abort-save-1", timeoutMs: 30_000, }, }), ); const abortResP = onceMessage( ws, (o) => o.type === "res" && o.id === "abort-save-1", ); ws.send( JSON.stringify({ type: "req", id: "abort-save-1", method: "chat.abort", params: { sessionKey: "main", runId: "idem-abort-save-1" }, }), ); const abortRes = await abortResP; expect(abortRes.ok).toBe(true); const sendRes = await sendResP; expect(sendRes.ok).toBe(true); const evt = await abortedEventP; expect(evt.payload?.runId).toBe("idem-abort-save-1"); expect(evt.payload?.sessionKey).toBe("main"); ws.close(); await server.close(); }); test("chat.abort returns aborted=false for unknown runId", async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-gw-")); testSessionStorePath = path.join(dir, "sessions.json"); await fs.writeFile( testSessionStorePath, JSON.stringify({}, null, 2), "utf-8", ); const { server, ws } = await startServerWithClient(); await connectOk(ws); ws.send( JSON.stringify({ type: "req", id: "abort-unknown-1", method: "chat.abort", params: { sessionKey: "main", runId: "missing-run" }, }), ); const abortRes = await onceMessage<{ type: "res"; id: string; ok: boolean; payload?: { ok?: boolean; aborted?: boolean }; }>(ws, (o) => o.type === "res" && o.id === "abort-unknown-1"); expect(abortRes.ok).toBe(true); expect(abortRes.payload?.aborted).toBe(false); ws.close(); await server.close(); }); test("chat.abort rejects mismatched sessionKey", async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-gw-")); testSessionStorePath = path.join(dir, "sessions.json"); await fs.writeFile( testSessionStorePath, JSON.stringify( { main: { sessionId: "sess-main", updatedAt: Date.now(), }, }, null, 2, ), "utf-8", ); const { server, ws } = await startServerWithClient(); await connectOk(ws); const spy = vi.mocked(agentCommand); let agentStartedResolve: (() => void) | undefined; const agentStartedP = new Promise((resolve) => { agentStartedResolve = resolve; }); spy.mockImplementationOnce(async (opts) => { agentStartedResolve?.(); const signal = (opts as { abortSignal?: AbortSignal }).abortSignal; await new Promise((resolve) => { if (!signal) return resolve(); if (signal.aborted) return resolve(); signal.addEventListener("abort", () => resolve(), { once: true }); }); }); const sendResP = onceMessage( ws, (o) => o.type === "res" && o.id === "send-mismatch-1", 10_000, ); ws.send( JSON.stringify({ type: "req", id: "send-mismatch-1", method: "chat.send", params: { sessionKey: "main", message: "hello", idempotencyKey: "idem-mismatch-1", timeoutMs: 30_000, }, }), ); await agentStartedP; const abortResP = onceMessage( ws, (o) => o.type === "res" && o.id === "abort-mismatch-1", 10_000, ); ws.send( JSON.stringify({ type: "req", id: "abort-mismatch-1", method: "chat.abort", params: { sessionKey: "other", runId: "idem-mismatch-1" }, }), ); const abortRes = await abortResP; expect(abortRes.ok).toBe(false); expect(abortRes.error?.code).toBe("INVALID_REQUEST"); const abortRes2P = onceMessage( ws, (o) => o.type === "res" && o.id === "abort-mismatch-2", 10_000, ); ws.send( JSON.stringify({ type: "req", id: "abort-mismatch-2", method: "chat.abort", params: { sessionKey: "main", runId: "idem-mismatch-1" }, }), ); const abortRes2 = await abortRes2P; expect(abortRes2.ok).toBe(true); const sendRes = await sendResP; expect(sendRes.ok).toBe(true); ws.close(); await server.close(); }, 15_000); test("chat.abort is a no-op after chat.send completes", async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-gw-")); testSessionStorePath = path.join(dir, "sessions.json"); await fs.writeFile( testSessionStorePath, JSON.stringify( { main: { sessionId: "sess-main", updatedAt: Date.now(), }, }, null, 2, ), "utf-8", ); const { server, ws } = await startServerWithClient(); await connectOk(ws); const spy = vi.mocked(agentCommand); spy.mockResolvedValueOnce(undefined); ws.send( JSON.stringify({ type: "req", id: "send-complete-1", method: "chat.send", params: { sessionKey: "main", message: "hello", idempotencyKey: "idem-complete-1", timeoutMs: 30_000, }, }), ); const sendRes = await onceMessage( ws, (o) => o.type === "res" && o.id === "send-complete-1", ); expect(sendRes.ok).toBe(true); ws.send( JSON.stringify({ type: "req", id: "abort-complete-1", method: "chat.abort", params: { sessionKey: "main", runId: "idem-complete-1" }, }), ); const abortRes = await onceMessage( ws, (o) => o.type === "res" && o.id === "abort-complete-1", ); expect(abortRes.ok).toBe(true); expect(abortRes.payload?.aborted).toBe(false); ws.close(); await server.close(); }); test("chat.send preserves run ordering for queued runs", async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-gw-")); testSessionStorePath = path.join(dir, "sessions.json"); await fs.writeFile( testSessionStorePath, JSON.stringify( { main: { sessionId: "sess-main", updatedAt: Date.now(), }, }, null, 2, ), "utf-8", ); const { server, ws } = await startServerWithClient(); await connectOk(ws); ws.send( JSON.stringify({ type: "req", id: "chat-1", method: "chat.send", params: { sessionKey: "main", message: "first", idempotencyKey: "idem-1", }, }), ); const res1 = await onceMessage( ws, (o) => o.type === "res" && o.id === "chat-1", ); expect(res1.ok).toBe(true); ws.send( JSON.stringify({ type: "req", id: "chat-2", method: "chat.send", params: { sessionKey: "main", message: "second", idempotencyKey: "idem-2", }, }), ); const res2 = await onceMessage( ws, (o) => o.type === "res" && o.id === "chat-2", ); expect(res2.ok).toBe(true); const final1P = onceMessage<{ type: "event"; event: string; payload?: unknown; }>( ws, (o) => { if (o.type !== "event" || o.event !== "chat") return false; const payload = o.payload as { state?: unknown } | undefined; return payload?.state === "final"; }, 8000, ); emitAgentEvent({ runId: "sess-main", stream: "job", data: { state: "done" }, }); const final1 = await final1P; const run1 = final1.payload && typeof final1.payload === "object" ? (final1.payload as { runId?: string }).runId : undefined; expect(run1).toBe("idem-1"); const final2P = onceMessage<{ type: "event"; event: string; payload?: unknown; }>( ws, (o) => { if (o.type !== "event" || o.event !== "chat") return false; const payload = o.payload as { state?: unknown } | undefined; return payload?.state === "final"; }, 8000, ); emitAgentEvent({ runId: "sess-main", stream: "job", data: { state: "done" }, }); const final2 = await final2P; const run2 = final2.payload && typeof final2.payload === "object" ? (final2.payload as { runId?: string }).runId : undefined; expect(run2).toBe("idem-2"); ws.close(); await server.close(); }); test("bridge RPC chat.history returns session messages", async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-gw-")); testSessionStorePath = path.join(dir, "sessions.json"); await fs.writeFile( testSessionStorePath, JSON.stringify( { main: { sessionId: "sess-main", updatedAt: Date.now(), }, }, null, 2, ), "utf-8", ); await fs.writeFile( path.join(dir, "sess-main.jsonl"), [ JSON.stringify({ message: { role: "user", content: [{ type: "text", text: "hi" }], timestamp: Date.now(), }, }), ].join("\n"), "utf-8", ); const port = await getFreePort(); const server = await startGatewayServer(port); const bridgeCall = bridgeStartCalls.at(-1); expect(bridgeCall?.onRequest).toBeDefined(); const res = await bridgeCall?.onRequest?.("ios-node", { id: "r1", method: "chat.history", paramsJSON: JSON.stringify({ sessionKey: "main" }), }); expect(res?.ok).toBe(true); const payload = JSON.parse( String((res as { payloadJSON?: string }).payloadJSON ?? "{}"), ) as { sessionKey?: string; sessionId?: string; messages?: unknown[]; }; expect(payload.sessionKey).toBe("main"); expect(payload.sessionId).toBe("sess-main"); expect(Array.isArray(payload.messages)).toBe(true); expect(payload.messages?.length).toBeGreaterThan(0); await server.close(); }); test("bridge RPC sessions.list returns session rows", async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-gw-")); testSessionStorePath = path.join(dir, "sessions.json"); await fs.writeFile( testSessionStorePath, JSON.stringify( { main: { sessionId: "sess-main", updatedAt: Date.now(), }, }, null, 2, ), "utf-8", ); const port = await getFreePort(); const server = await startGatewayServer(port); const bridgeCall = bridgeStartCalls.at(-1); expect(bridgeCall?.onRequest).toBeDefined(); const res = await bridgeCall?.onRequest?.("ios-node", { id: "r1", method: "sessions.list", paramsJSON: JSON.stringify({ includeGlobal: true, includeUnknown: false, limit: 50, }), }); expect(res?.ok).toBe(true); const payload = JSON.parse( String((res as { payloadJSON?: string }).payloadJSON ?? "{}"), ) as { sessions?: unknown[]; count?: number; path?: string; }; expect(Array.isArray(payload.sessions)).toBe(true); expect(typeof payload.count).toBe("number"); expect(typeof payload.path).toBe("string"); await server.close(); }); test("bridge chat events are pushed to subscribed nodes", async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-gw-")); testSessionStorePath = path.join(dir, "sessions.json"); await fs.writeFile( testSessionStorePath, JSON.stringify( { main: { sessionId: "sess-main", updatedAt: Date.now(), }, }, null, 2, ), "utf-8", ); const port = await getFreePort(); const server = await startGatewayServer(port); const bridgeCall = bridgeStartCalls.at(-1); expect(bridgeCall?.onEvent).toBeDefined(); expect(bridgeCall?.onRequest).toBeDefined(); // Subscribe the node to chat events for main. await bridgeCall?.onEvent?.("ios-node", { event: "chat.subscribe", payloadJSON: JSON.stringify({ sessionKey: "main" }), }); bridgeSendEvent.mockClear(); // Trigger a chat.send, then simulate agent bus completion for the sessionId. const reqRes = await bridgeCall?.onRequest?.("ios-node", { id: "s1", method: "chat.send", paramsJSON: JSON.stringify({ sessionKey: "main", message: "hello", idempotencyKey: "idem-bridge-chat", timeoutMs: 30_000, }), }); expect(reqRes?.ok).toBe(true); emitAgentEvent({ runId: "sess-main", seq: 1, ts: Date.now(), stream: "assistant", data: { text: "hi from agent" }, }); emitAgentEvent({ runId: "sess-main", seq: 2, ts: Date.now(), stream: "job", data: { state: "done" }, }); // Wait a tick for the bridge send to happen. await new Promise((r) => setTimeout(r, 25)); expect(bridgeSendEvent).toHaveBeenCalledWith( expect.objectContaining({ nodeId: "ios-node", event: "agent", }), ); expect(bridgeSendEvent).toHaveBeenCalledWith( expect.objectContaining({ nodeId: "ios-node", event: "chat", }), ); await server.close(); }); test("bridge voice transcript defaults to main session", async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-gw-")); testSessionStorePath = path.join(dir, "sessions.json"); await fs.writeFile( testSessionStorePath, JSON.stringify( { main: { sessionId: "sess-main", updatedAt: Date.now(), lastChannel: "whatsapp", lastTo: "+1555", }, }, null, 2, ), "utf-8", ); const port = await getFreePort(); const server = await startGatewayServer(port); const bridgeCall = bridgeStartCalls.at(-1); expect(bridgeCall?.onEvent).toBeDefined(); const spy = vi.mocked(agentCommand); const beforeCalls = spy.mock.calls.length; await bridgeCall?.onEvent?.("ios-node", { event: "voice.transcript", payloadJSON: JSON.stringify({ text: "hello" }), }); expect(spy.mock.calls.length).toBe(beforeCalls + 1); const call = spy.mock.calls.at(-1)?.[0] as Record; expect(call.sessionId).toBe("sess-main"); expect(call.deliver).toBe(false); expect(call.surface).toBe("Node"); const stored = JSON.parse( await fs.readFile(testSessionStorePath, "utf-8"), ) as Record; expect(stored.main?.sessionId).toBe("sess-main"); expect(stored["node-ios-node"]).toBeUndefined(); await server.close(); }); test("bridge voice transcript triggers chat events for webchat clients", async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-gw-")); testSessionStorePath = path.join(dir, "sessions.json"); await fs.writeFile( testSessionStorePath, JSON.stringify( { main: { sessionId: "sess-main", updatedAt: Date.now(), }, }, null, 2, ), "utf-8", ); const { server, ws } = await startServerWithClient(); await connectOk(ws, { client: { name: "webchat", version: "1.0.0", platform: "test", mode: "webchat", }, }); const bridgeCall = bridgeStartCalls.at(-1); expect(bridgeCall?.onEvent).toBeDefined(); const isVoiceFinalChatEvent = (o: unknown) => { if (!o || typeof o !== "object") return false; const rec = o as Record; if (rec.type !== "event" || rec.event !== "chat") return false; if (!rec.payload || typeof rec.payload !== "object") return false; const payload = rec.payload as Record; const runId = typeof payload.runId === "string" ? payload.runId : ""; const state = typeof payload.state === "string" ? payload.state : ""; return runId.startsWith("voice-") && state === "final"; }; const finalChatP = onceMessage<{ type: "event"; event: string; payload?: unknown; }>(ws, isVoiceFinalChatEvent, 8000); await bridgeCall?.onEvent?.("ios-node", { event: "voice.transcript", payloadJSON: JSON.stringify({ text: "hello", sessionKey: "main" }), }); emitAgentEvent({ runId: "sess-main", seq: 1, ts: Date.now(), stream: "assistant", data: { text: "hi from agent" }, }); emitAgentEvent({ runId: "sess-main", seq: 2, ts: Date.now(), stream: "job", data: { state: "done" }, }); const evt = await finalChatP; const payload = evt.payload && typeof evt.payload === "object" ? (evt.payload as Record) : {}; expect(payload.sessionKey).toBe("main"); const message = payload.message && typeof payload.message === "object" ? (payload.message as Record) : {}; expect(message.role).toBe("assistant"); ws.close(); await server.close(); }); test("agent events stream to webchat clients when run context is registered", async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-gw-")); testSessionStorePath = path.join(dir, "sessions.json"); await fs.writeFile( testSessionStorePath, JSON.stringify( { main: { sessionId: "sess-main", updatedAt: Date.now(), }, }, null, 2, ), "utf-8", ); const { server, ws } = await startServerWithClient(); await connectOk(ws, { client: { name: "webchat", version: "1.0.0", platform: "test", mode: "webchat", }, }); registerAgentRunContext("run-auto-1", { sessionKey: "main" }); const finalChatP = onceMessage<{ type: "event"; event: string; payload?: unknown; }>( ws, (o) => { if (o.type !== "event" || o.event !== "chat") return false; const payload = o.payload as | { state?: unknown; runId?: unknown } | undefined; return payload?.state === "final" && payload.runId === "run-auto-1"; }, 8000, ); emitAgentEvent({ runId: "run-auto-1", stream: "assistant", data: { text: "hi from agent" }, }); emitAgentEvent({ runId: "run-auto-1", stream: "job", data: { state: "done" }, }); const evt = await finalChatP; const payload = evt.payload && typeof evt.payload === "object" ? (evt.payload as Record) : {}; expect(payload.sessionKey).toBe("main"); expect(payload.runId).toBe("run-auto-1"); ws.close(); await server.close(); }); test("bridge chat.abort cancels while saving the session store", async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-gw-")); testSessionStorePath = path.join(dir, "sessions.json"); await fs.writeFile( testSessionStorePath, JSON.stringify( { main: { sessionId: "sess-main", updatedAt: Date.now(), }, }, null, 2, ), "utf-8", ); sessionStoreSaveDelayMs.value = 120; const port = await getFreePort(); const server = await startGatewayServer(port); const bridgeCall = bridgeStartCalls.at(-1); expect(bridgeCall?.onRequest).toBeDefined(); const spy = vi.mocked(agentCommand); spy.mockImplementationOnce(async (opts) => { const signal = (opts as { abortSignal?: AbortSignal }).abortSignal; await new Promise((resolve) => { if (!signal) return resolve(); if (signal.aborted) return resolve(); signal.addEventListener("abort", () => resolve(), { once: true }); }); }); const sendP = bridgeCall?.onRequest?.("ios-node", { id: "send-abort-save-bridge-1", method: "chat.send", paramsJSON: JSON.stringify({ sessionKey: "main", message: "hello", idempotencyKey: "idem-abort-save-bridge-1", timeoutMs: 30_000, }), }); const abortRes = await bridgeCall?.onRequest?.("ios-node", { id: "abort-save-bridge-1", method: "chat.abort", paramsJSON: JSON.stringify({ sessionKey: "main", runId: "idem-abort-save-bridge-1", }), }); expect(abortRes?.ok).toBe(true); const sendRes = await sendP; expect(sendRes?.ok).toBe(true); await server.close(); }); test("presence includes client fingerprint", async () => { const { server, ws } = await startServerWithClient(); await connectOk(ws, { client: { name: "fingerprint", version: "9.9.9", platform: "test", deviceFamily: "iPad", modelIdentifier: "iPad16,6", mode: "ui", instanceId: "abc", }, }); const presenceP = onceMessage( ws, (o) => o.type === "res" && o.id === "fingerprint", 4000, ); ws.send( JSON.stringify({ type: "req", id: "fingerprint", method: "system-presence", }), ); const presenceRes = await presenceP; const entries = presenceRes.payload as Array>; const clientEntry = entries.find((e) => e.instanceId === "abc"); expect(clientEntry?.host).toBe("fingerprint"); expect(clientEntry?.version).toBe("9.9.9"); expect(clientEntry?.mode).toBe("ui"); expect(clientEntry?.deviceFamily).toBe("iPad"); expect(clientEntry?.modelIdentifier).toBe("iPad16,6"); ws.close(); await server.close(); }); test("cli connections are not tracked as instances", async () => { const { server, ws } = await startServerWithClient(); const cliId = `cli-${randomUUID()}`; await connectOk(ws, { client: { name: "cli", version: "dev", platform: "test", mode: "cli", instanceId: cliId, }, }); const presenceP = onceMessage( ws, (o) => o.type === "res" && o.id === "cli-presence", 4000, ); ws.send( JSON.stringify({ type: "req", id: "cli-presence", method: "system-presence", }), ); const presenceRes = await presenceP; const entries = presenceRes.payload as Array>; expect(entries.some((e) => e.instanceId === cliId)).toBe(false); ws.close(); await server.close(); }); test("lists and patches session store via sessions.* RPC", async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-sessions-")); const storePath = path.join(dir, "sessions.json"); const now = Date.now(); testSessionStorePath = storePath; await fs.writeFile( path.join(dir, "sess-main.jsonl"), `${Array.from({ length: 10 }) .map((_, idx) => JSON.stringify({ role: "user", content: `line ${idx}` }), ) .join("\n")}\n`, "utf-8", ); await fs.writeFile( path.join(dir, "sess-group.jsonl"), `${JSON.stringify({ role: "user", content: "group line 0" })}\n`, "utf-8", ); await fs.writeFile( storePath, JSON.stringify( { main: { sessionId: "sess-main", updatedAt: now - 30_000, inputTokens: 10, outputTokens: 20, thinkingLevel: "low", verboseLevel: "on", }, "discord:group:dev": { sessionId: "sess-group", updatedAt: now - 120_000, totalTokens: 50, }, global: { sessionId: "sess-global", updatedAt: now - 10_000, }, }, null, 2, ), "utf-8", ); const { server, ws } = await startServerWithClient(); const hello = await connectOk(ws); expect( (hello as unknown as { features?: { methods?: string[] } }).features ?.methods, ).toEqual( expect.arrayContaining([ "sessions.list", "sessions.patch", "sessions.reset", "sessions.delete", "sessions.compact", ]), ); const list1 = await rpcReq<{ path: string; sessions: Array<{ key: string; totalTokens?: number; thinkingLevel?: string; verboseLevel?: string; }>; }>(ws, "sessions.list", { includeGlobal: false, includeUnknown: false }); expect(list1.ok).toBe(true); expect(list1.payload?.path).toBe(storePath); expect(list1.payload?.sessions.some((s) => s.key === "global")).toBe(false); const main = list1.payload?.sessions.find((s) => s.key === "main"); expect(main?.totalTokens).toBe(30); expect(main?.thinkingLevel).toBe("low"); expect(main?.verboseLevel).toBe("on"); const active = await rpcReq<{ sessions: Array<{ key: string }>; }>(ws, "sessions.list", { includeGlobal: false, includeUnknown: false, activeMinutes: 1, }); expect(active.ok).toBe(true); expect(active.payload?.sessions.map((s) => s.key)).toEqual(["main"]); const limited = await rpcReq<{ sessions: Array<{ key: string }>; }>(ws, "sessions.list", { includeGlobal: true, includeUnknown: false, limit: 1, }); expect(limited.ok).toBe(true); expect(limited.payload?.sessions).toHaveLength(1); expect(limited.payload?.sessions[0]?.key).toBe("global"); const patched = await rpcReq<{ ok: true; key: string }>( ws, "sessions.patch", { key: "main", thinkingLevel: "medium", verboseLevel: null }, ); expect(patched.ok).toBe(true); expect(patched.payload?.ok).toBe(true); expect(patched.payload?.key).toBe("main"); const list2 = await rpcReq<{ sessions: Array<{ key: string; thinkingLevel?: string; verboseLevel?: string; }>; }>(ws, "sessions.list", {}); expect(list2.ok).toBe(true); const main2 = list2.payload?.sessions.find((s) => s.key === "main"); expect(main2?.thinkingLevel).toBe("medium"); expect(main2?.verboseLevel).toBeUndefined(); const compacted = await rpcReq<{ ok: true; compacted: boolean }>( ws, "sessions.compact", { key: "main", maxLines: 3 }, ); expect(compacted.ok).toBe(true); expect(compacted.payload?.compacted).toBe(true); const compactedLines = ( await fs.readFile(path.join(dir, "sess-main.jsonl"), "utf-8") ) .split(/\r?\n/) .filter((l) => l.trim().length > 0); expect(compactedLines).toHaveLength(3); const filesAfterCompact = await fs.readdir(dir); expect( filesAfterCompact.some((f) => f.startsWith("sess-main.jsonl.bak.")), ).toBe(true); const deleted = await rpcReq<{ ok: true; deleted: boolean }>( ws, "sessions.delete", { key: "discord:group:dev" }, ); expect(deleted.ok).toBe(true); expect(deleted.payload?.deleted).toBe(true); const listAfterDelete = await rpcReq<{ sessions: Array<{ key: string }>; }>(ws, "sessions.list", {}); expect(listAfterDelete.ok).toBe(true); expect( listAfterDelete.payload?.sessions.some( (s) => s.key === "discord:group:dev", ), ).toBe(false); const filesAfterDelete = await fs.readdir(dir); expect( filesAfterDelete.some((f) => f.startsWith("sess-group.jsonl.deleted.")), ).toBe(true); const reset = await rpcReq<{ ok: true; key: string; entry: { sessionId: string }; }>(ws, "sessions.reset", { key: "main" }); expect(reset.ok).toBe(true); expect(reset.payload?.key).toBe("main"); expect(reset.payload?.entry.sessionId).not.toBe("sess-main"); const badThinking = await rpcReq(ws, "sessions.patch", { key: "main", thinkingLevel: "banana", }); expect(badThinking.ok).toBe(false); expect( (badThinking.error as { message?: unknown } | undefined)?.message ?? "", ).toMatch(/invalid thinkinglevel/i); ws.close(); await server.close(); }); test("refuses to start when port already bound", async () => { const { server: blocker, port } = await occupyPort(); await expect(startGatewayServer(port)).rejects.toBeInstanceOf( GatewayLockError, ); await expect(startGatewayServer(port)).rejects.toThrow( /already listening/i, ); blocker.close(); }); test("releases port after close", async () => { const port = await getFreePort(); const server = await startGatewayServer(port); await server.close(); // If the port was released, another listener can bind immediately. const probe = createServer(); await new Promise((resolve, reject) => { probe.once("error", reject); probe.listen(port, "127.0.0.1", () => resolve()); }); await new Promise((resolve, reject) => probe.close((err) => (err ? reject(err) : resolve())), ); }); test("hooks wake requires auth", async () => { testHooksConfig = { enabled: true, token: "hook-secret" }; const port = await getFreePort(); const server = await startGatewayServer(port); const res = await fetch(`http://127.0.0.1:${port}/hooks/wake`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ text: "Ping" }), }); expect(res.status).toBe(401); await server.close(); }); test("hooks wake enqueues system event", async () => { testHooksConfig = { enabled: true, token: "hook-secret" }; const port = await getFreePort(); const server = await startGatewayServer(port); const res = await fetch(`http://127.0.0.1:${port}/hooks/wake`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: "Bearer hook-secret", }, body: JSON.stringify({ text: "Ping", mode: "next-heartbeat" }), }); expect(res.status).toBe(200); const events = await waitForSystemEvent(); expect(events.some((e) => e.includes("Ping"))).toBe(true); drainSystemEvents(); await server.close(); }); test("hooks agent posts summary to main", async () => { testHooksConfig = { enabled: true, token: "hook-secret" }; cronIsolatedRun.mockResolvedValueOnce({ status: "ok", summary: "done", }); const port = await getFreePort(); const server = await startGatewayServer(port); const res = await fetch(`http://127.0.0.1:${port}/hooks/agent`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: "Bearer hook-secret", }, body: JSON.stringify({ message: "Do it", name: "Email" }), }); expect(res.status).toBe(202); const events = await waitForSystemEvent(); expect(events.some((e) => e.includes("Hook Email: done"))).toBe(true); drainSystemEvents(); await server.close(); }); test("hooks wake accepts query token", async () => { testHooksConfig = { enabled: true, token: "hook-secret" }; const port = await getFreePort(); const server = await startGatewayServer(port); const res = await fetch( `http://127.0.0.1:${port}/hooks/wake?token=hook-secret`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ text: "Query auth" }), }, ); expect(res.status).toBe(200); const events = await waitForSystemEvent(); expect(events.some((e) => e.includes("Query auth"))).toBe(true); drainSystemEvents(); await server.close(); }); test("hooks agent rejects invalid channel", async () => { testHooksConfig = { enabled: true, token: "hook-secret" }; const port = await getFreePort(); const server = await startGatewayServer(port); const res = await fetch(`http://127.0.0.1:${port}/hooks/agent`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: "Bearer hook-secret", }, body: JSON.stringify({ message: "Nope", channel: "sms" }), }); expect(res.status).toBe(400); expect(peekSystemEvents().length).toBe(0); await server.close(); }); test("hooks wake accepts x-clawdis-token header", async () => { testHooksConfig = { enabled: true, token: "hook-secret" }; const port = await getFreePort(); const server = await startGatewayServer(port); const res = await fetch(`http://127.0.0.1:${port}/hooks/wake`, { method: "POST", headers: { "Content-Type": "application/json", "x-clawdis-token": "hook-secret", }, body: JSON.stringify({ text: "Header auth" }), }); expect(res.status).toBe(200); const events = await waitForSystemEvent(); expect(events.some((e) => e.includes("Header auth"))).toBe(true); drainSystemEvents(); await server.close(); }); test("hooks rejects non-post", async () => { testHooksConfig = { enabled: true, token: "hook-secret" }; const port = await getFreePort(); const server = await startGatewayServer(port); const res = await fetch(`http://127.0.0.1:${port}/hooks/wake`, { method: "GET", headers: { Authorization: "Bearer hook-secret" }, }); expect(res.status).toBe(405); await server.close(); }); test("hooks wake requires text", async () => { testHooksConfig = { enabled: true, token: "hook-secret" }; const port = await getFreePort(); const server = await startGatewayServer(port); const res = await fetch(`http://127.0.0.1:${port}/hooks/wake`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: "Bearer hook-secret", }, body: JSON.stringify({ text: " " }), }); expect(res.status).toBe(400); await server.close(); }); test("hooks agent requires message", async () => { testHooksConfig = { enabled: true, token: "hook-secret" }; const port = await getFreePort(); const server = await startGatewayServer(port); const res = await fetch(`http://127.0.0.1:${port}/hooks/agent`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: "Bearer hook-secret", }, body: JSON.stringify({ message: " " }), }); expect(res.status).toBe(400); await server.close(); }); test("hooks rejects invalid json", async () => { testHooksConfig = { enabled: true, token: "hook-secret" }; const port = await getFreePort(); const server = await startGatewayServer(port); const res = await fetch(`http://127.0.0.1:${port}/hooks/wake`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: "Bearer hook-secret", }, body: "{", }); expect(res.status).toBe(400); await server.close(); }); });