From 6ae51ae3debf2c478c22b4c569c8ab5ea2c9f27b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 3 Jan 2026 17:34:52 +0100 Subject: [PATCH] refactor: split gateway server helpers and tests --- src/gateway/hooks.test.ts | 83 + src/gateway/hooks.ts | 219 + src/gateway/net.ts | 25 + src/gateway/server.agent.test.ts | 481 ++ src/gateway/server.auth.test.ts | 126 + src/gateway/server.chat.test.ts | 758 ++++ src/gateway/server.cron.test.ts | 296 ++ src/gateway/server.health.test.ts | 310 ++ src/gateway/server.hooks.test.ts | 184 + src/gateway/server.misc.test.ts | 103 + src/gateway/server.models-voicewake.test.ts | 262 ++ src/gateway/server.node-bridge.test.ts | 913 ++++ src/gateway/server.providers.test.ts | 98 + src/gateway/server.sessions.test.ts | 211 + src/gateway/server.test.ts | 4507 ------------------- src/gateway/server.ts | 946 +--- src/gateway/session-utils.test.ts | 34 + src/gateway/session-utils.ts | 300 ++ src/gateway/test-helpers.ts | 511 +++ src/gateway/ws-log.test.ts | 53 + src/gateway/ws-log.ts | 392 ++ 21 files changed, 5385 insertions(+), 5427 deletions(-) create mode 100644 src/gateway/hooks.test.ts create mode 100644 src/gateway/hooks.ts create mode 100644 src/gateway/net.ts create mode 100644 src/gateway/server.agent.test.ts create mode 100644 src/gateway/server.auth.test.ts create mode 100644 src/gateway/server.chat.test.ts create mode 100644 src/gateway/server.cron.test.ts create mode 100644 src/gateway/server.health.test.ts create mode 100644 src/gateway/server.hooks.test.ts create mode 100644 src/gateway/server.misc.test.ts create mode 100644 src/gateway/server.models-voicewake.test.ts create mode 100644 src/gateway/server.node-bridge.test.ts create mode 100644 src/gateway/server.providers.test.ts create mode 100644 src/gateway/server.sessions.test.ts delete mode 100644 src/gateway/server.test.ts create mode 100644 src/gateway/session-utils.test.ts create mode 100644 src/gateway/session-utils.ts create mode 100644 src/gateway/test-helpers.ts create mode 100644 src/gateway/ws-log.test.ts create mode 100644 src/gateway/ws-log.ts diff --git a/src/gateway/hooks.test.ts b/src/gateway/hooks.test.ts new file mode 100644 index 000000000..bc7c4fda5 --- /dev/null +++ b/src/gateway/hooks.test.ts @@ -0,0 +1,83 @@ +import type { IncomingMessage } from "node:http"; +import { describe, expect, test } from "vitest"; +import type { ClawdisConfig } from "../config/config.js"; +import { + extractHookToken, + normalizeAgentPayload, + normalizeWakePayload, + resolveHooksConfig, +} from "./hooks.js"; + +describe("gateway hooks helpers", () => { + test("resolveHooksConfig normalizes paths + requires token", () => { + const base = { + hooks: { + enabled: true, + token: "secret", + path: "hooks///", + }, + } as ClawdisConfig; + const resolved = resolveHooksConfig(base); + expect(resolved?.basePath).toBe("/hooks"); + expect(resolved?.token).toBe("secret"); + }); + + test("resolveHooksConfig rejects root path", () => { + const cfg = { + hooks: { enabled: true, token: "x", path: "/" }, + } as ClawdisConfig; + expect(() => resolveHooksConfig(cfg)).toThrow("hooks.path may not be '/'"); + }); + + test("extractHookToken prefers bearer > header > query", () => { + const req = { + headers: { + authorization: "Bearer top", + "x-clawdis-token": "header", + }, + } as unknown as IncomingMessage; + const url = new URL("http://localhost/hooks/wake?token=query"); + expect(extractHookToken(req, url)).toBe("top"); + + const req2 = { + headers: { "x-clawdis-token": "header" }, + } as unknown as IncomingMessage; + expect(extractHookToken(req2, url)).toBe("header"); + + const req3 = { headers: {} } as unknown as IncomingMessage; + expect(extractHookToken(req3, url)).toBe("query"); + }); + + test("normalizeWakePayload trims + validates", () => { + expect(normalizeWakePayload({ text: " hi " })).toEqual({ + ok: true, + value: { text: "hi", mode: "now" }, + }); + expect(normalizeWakePayload({ text: " ", mode: "now" }).ok).toBe(false); + }); + + test("normalizeAgentPayload defaults + validates channel", () => { + const ok = normalizeAgentPayload( + { message: "hello" }, + { idFactory: () => "fixed" }, + ); + expect(ok.ok).toBe(true); + if (ok.ok) { + expect(ok.value.sessionKey).toBe("hook:fixed"); + expect(ok.value.channel).toBe("last"); + expect(ok.value.name).toBe("Hook"); + } + + const imsg = normalizeAgentPayload( + { message: "yo", channel: "imsg" }, + { idFactory: () => "x" }, + ); + expect(imsg.ok).toBe(true); + if (imsg.ok) { + expect(imsg.value.channel).toBe("imessage"); + } + + const bad = normalizeAgentPayload({ message: "yo", channel: "sms" }); + expect(bad.ok).toBe(false); + }); +}); diff --git a/src/gateway/hooks.ts b/src/gateway/hooks.ts new file mode 100644 index 000000000..a0c038479 --- /dev/null +++ b/src/gateway/hooks.ts @@ -0,0 +1,219 @@ +import { randomUUID } from "node:crypto"; +import type { IncomingMessage } from "node:http"; +import type { ClawdisConfig } from "../config/config.js"; +import { + type HookMappingResolved, + resolveHookMappings, +} from "./hooks-mapping.js"; + +const DEFAULT_HOOKS_PATH = "/hooks"; +const DEFAULT_HOOKS_MAX_BODY_BYTES = 256 * 1024; + +export type HooksConfigResolved = { + basePath: string; + token: string; + maxBodyBytes: number; + mappings: HookMappingResolved[]; +}; + +export function resolveHooksConfig( + cfg: ClawdisConfig, +): HooksConfigResolved | null { + if (cfg.hooks?.enabled !== true) return null; + const token = cfg.hooks?.token?.trim(); + if (!token) { + throw new Error("hooks.enabled requires hooks.token"); + } + const rawPath = cfg.hooks?.path?.trim() || DEFAULT_HOOKS_PATH; + const withSlash = rawPath.startsWith("/") ? rawPath : `/${rawPath}`; + const trimmed = + withSlash.length > 1 ? withSlash.replace(/\/+$/, "") : withSlash; + if (trimmed === "/") { + throw new Error("hooks.path may not be '/'"); + } + const maxBodyBytes = + cfg.hooks?.maxBodyBytes && cfg.hooks.maxBodyBytes > 0 + ? cfg.hooks.maxBodyBytes + : DEFAULT_HOOKS_MAX_BODY_BYTES; + const mappings = resolveHookMappings(cfg.hooks); + return { + basePath: trimmed, + token, + maxBodyBytes, + mappings, + }; +} + +export function extractHookToken( + req: IncomingMessage, + url: URL, +): string | undefined { + const auth = + typeof req.headers.authorization === "string" + ? req.headers.authorization.trim() + : ""; + if (auth.toLowerCase().startsWith("bearer ")) { + const token = auth.slice(7).trim(); + if (token) return token; + } + const headerToken = + typeof req.headers["x-clawdis-token"] === "string" + ? req.headers["x-clawdis-token"].trim() + : ""; + if (headerToken) return headerToken; + const queryToken = url.searchParams.get("token"); + if (queryToken) return queryToken.trim(); + return undefined; +} + +export async function readJsonBody( + req: IncomingMessage, + maxBytes: number, +): Promise<{ ok: true; value: unknown } | { ok: false; error: string }> { + return await new Promise((resolve) => { + let done = false; + let total = 0; + const chunks: Buffer[] = []; + req.on("data", (chunk: Buffer) => { + if (done) return; + total += chunk.length; + if (total > maxBytes) { + done = true; + resolve({ ok: false, error: "payload too large" }); + req.destroy(); + return; + } + chunks.push(chunk); + }); + req.on("end", () => { + if (done) return; + done = true; + const raw = Buffer.concat(chunks).toString("utf-8").trim(); + if (!raw) { + resolve({ ok: true, value: {} }); + return; + } + try { + const parsed = JSON.parse(raw) as unknown; + resolve({ ok: true, value: parsed }); + } catch (err) { + resolve({ ok: false, error: String(err) }); + } + }); + req.on("error", (err) => { + if (done) return; + done = true; + resolve({ ok: false, error: String(err) }); + }); + }); +} + +export function normalizeHookHeaders(req: IncomingMessage) { + const headers: Record = {}; + for (const [key, value] of Object.entries(req.headers)) { + if (typeof value === "string") { + headers[key.toLowerCase()] = value; + } else if (Array.isArray(value) && value.length > 0) { + headers[key.toLowerCase()] = value.join(", "); + } + } + return headers; +} + +export function normalizeWakePayload( + payload: Record, +): + | { ok: true; value: { text: string; mode: "now" | "next-heartbeat" } } + | { ok: false; error: string } { + const text = typeof payload.text === "string" ? payload.text.trim() : ""; + if (!text) return { ok: false, error: "text required" }; + const mode = payload.mode === "next-heartbeat" ? "next-heartbeat" : "now"; + return { ok: true, value: { text, mode } }; +} + +export type HookAgentPayload = { + message: string; + name: string; + wakeMode: "now" | "next-heartbeat"; + sessionKey: string; + deliver: boolean; + channel: "last" | "whatsapp" | "telegram" | "discord" | "signal" | "imessage"; + to?: string; + thinking?: string; + timeoutSeconds?: number; +}; + +export function normalizeAgentPayload( + payload: Record, + opts?: { idFactory?: () => string }, +): + | { + ok: true; + value: HookAgentPayload; + } + | { ok: false; error: string } { + const message = + typeof payload.message === "string" ? payload.message.trim() : ""; + if (!message) return { ok: false, error: "message required" }; + const nameRaw = payload.name; + const name = + typeof nameRaw === "string" && nameRaw.trim() ? nameRaw.trim() : "Hook"; + const wakeMode = + payload.wakeMode === "next-heartbeat" ? "next-heartbeat" : "now"; + const sessionKeyRaw = payload.sessionKey; + const idFactory = opts?.idFactory ?? randomUUID; + const sessionKey = + typeof sessionKeyRaw === "string" && sessionKeyRaw.trim() + ? sessionKeyRaw.trim() + : `hook:${idFactory()}`; + const channelRaw = payload.channel; + const channel = + channelRaw === "whatsapp" || + channelRaw === "telegram" || + channelRaw === "discord" || + channelRaw === "signal" || + channelRaw === "imessage" || + channelRaw === "last" + ? channelRaw + : channelRaw === "imsg" + ? "imessage" + : channelRaw === undefined + ? "last" + : null; + if (channel === null) { + return { + ok: false, + error: "channel must be last|whatsapp|telegram|discord|signal|imessage", + }; + } + const toRaw = payload.to; + const to = + typeof toRaw === "string" && toRaw.trim() ? toRaw.trim() : undefined; + const deliver = payload.deliver === true; + const thinkingRaw = payload.thinking; + const thinking = + typeof thinkingRaw === "string" && thinkingRaw.trim() + ? thinkingRaw.trim() + : undefined; + const timeoutRaw = payload.timeoutSeconds; + const timeoutSeconds = + typeof timeoutRaw === "number" && + Number.isFinite(timeoutRaw) && + timeoutRaw > 0 + ? Math.floor(timeoutRaw) + : undefined; + return { + ok: true, + value: { + message, + name, + wakeMode, + sessionKey, + deliver, + channel, + to, + thinking, + timeoutSeconds, + }, + }; +} diff --git a/src/gateway/net.ts b/src/gateway/net.ts new file mode 100644 index 000000000..88568d98d --- /dev/null +++ b/src/gateway/net.ts @@ -0,0 +1,25 @@ +import { pickPrimaryTailnetIPv4 } from "../infra/tailnet.js"; + +export function isLoopbackAddress(ip: string | undefined): boolean { + if (!ip) return false; + if (ip === "127.0.0.1") return true; + if (ip.startsWith("127.")) return true; + if (ip === "::1") return true; + if (ip.startsWith("::ffff:127.")) return true; + return false; +} + +export function resolveGatewayBindHost( + bind: import("../config/config.js").BridgeBindMode | undefined, +): string | null { + const mode = bind ?? "loopback"; + if (mode === "loopback") return "127.0.0.1"; + if (mode === "lan") return "0.0.0.0"; + if (mode === "tailnet") return pickPrimaryTailnetIPv4() ?? null; + if (mode === "auto") return pickPrimaryTailnetIPv4() ?? "0.0.0.0"; + return "127.0.0.1"; +} + +export function isLoopbackHost(host: string): boolean { + return isLoopbackAddress(host); +} diff --git a/src/gateway/server.agent.test.ts b/src/gateway/server.agent.test.ts new file mode 100644 index 000000000..f0fc2cf93 --- /dev/null +++ b/src/gateway/server.agent.test.ts @@ -0,0 +1,481 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, test, vi } from "vitest"; +import { WebSocket } from "ws"; +import { + emitAgentEvent, + registerAgentRunContext, +} from "../infra/agent-events.js"; +import { + agentCommand, + connectOk, + getFreePort, + installGatewayTestHooks, + onceMessage, + rpcReq, + startGatewayServer, + startServerWithClient, + testState, +} from "./test-helpers.js"; + +installGatewayTestHooks(); + +describe("gateway server agent", () => { + test("agent falls back to allowFrom when lastTo is stale", async () => { + testState.allowFrom = ["+436769770569"]; + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-gw-")); + testState.sessionStorePath = path.join(dir, "sessions.json"); + await fs.writeFile( + testState.sessionStorePath, + 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); + + const res = await rpcReq(ws, "agent", { + message: "hi", + sessionKey: "main", + channel: "last", + deliver: true, + idempotencyKey: "idem-agent-last-stale", + }); + expect(res.ok).toBe(true); + + const spy = vi.mocked(agentCommand); + 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(); + testState.allowFrom = undefined; + }); + + test("agent routes main last-channel whatsapp", async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-gw-")); + testState.sessionStorePath = path.join(dir, "sessions.json"); + await fs.writeFile( + testState.sessionStorePath, + 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); + + const res = await rpcReq(ws, "agent", { + message: "hi", + sessionKey: "main", + channel: "last", + deliver: true, + idempotencyKey: "idem-agent-last-whatsapp", + }); + expect(res.ok).toBe(true); + + const spy = vi.mocked(agentCommand); + 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-")); + testState.sessionStorePath = path.join(dir, "sessions.json"); + await fs.writeFile( + testState.sessionStorePath, + 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); + + const res = await rpcReq(ws, "agent", { + message: "hi", + sessionKey: "main", + channel: "last", + deliver: true, + idempotencyKey: "idem-agent-last", + }); + expect(res.ok).toBe(true); + + const spy = vi.mocked(agentCommand); + 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-")); + testState.sessionStorePath = path.join(dir, "sessions.json"); + await fs.writeFile( + testState.sessionStorePath, + 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); + + const res = await rpcReq(ws, "agent", { + message: "hi", + sessionKey: "main", + channel: "last", + deliver: true, + idempotencyKey: "idem-agent-last-discord", + }); + expect(res.ok).toBe(true); + + const spy = vi.mocked(agentCommand); + 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-")); + testState.sessionStorePath = path.join(dir, "sessions.json"); + await fs.writeFile( + testState.sessionStorePath, + 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); + + const res = await rpcReq(ws, "agent", { + message: "hi", + sessionKey: "main", + channel: "last", + deliver: true, + idempotencyKey: "idem-agent-last-signal", + }); + expect(res.ok).toBe(true); + + const spy = vi.mocked(agentCommand); + 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 () => { + testState.allowFrom = ["+1555"]; + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-gw-")); + testState.sessionStorePath = path.join(dir, "sessions.json"); + await fs.writeFile( + testState.sessionStorePath, + 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); + + const res = await rpcReq(ws, "agent", { + message: "hi", + sessionKey: "main", + channel: "last", + deliver: true, + idempotencyKey: "idem-agent-webchat", + }); + expect(res.ok).toBe(true); + + const spy = vi.mocked(agentCommand); + 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( + "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", 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("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("agent events stream to webchat clients when run context is registered", async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-gw-")); + testState.sessionStorePath = path.join(dir, "sessions.json"); + await fs.writeFile( + testState.sessionStorePath, + 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( + 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(); + }); +}); diff --git a/src/gateway/server.auth.test.ts b/src/gateway/server.auth.test.ts new file mode 100644 index 000000000..92027a540 --- /dev/null +++ b/src/gateway/server.auth.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, test } from "vitest"; +import { WebSocket } from "ws"; +import { CONFIG_PATH_CLAWDIS, STATE_DIR_CLAWDIS } from "../config/config.js"; +import { PROTOCOL_VERSION } from "./protocol/index.js"; +import { + connectReq, + getFreePort, + installGatewayTestHooks, + onceMessage, + startGatewayServer, + startServerWithClient, + testState, +} from "./test-helpers.js"; + +installGatewayTestHooks(); + +describe("gateway server auth/connect", () => { + 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 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); + 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 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. + } + 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(); + if (prevToken === undefined) { + delete process.env.CLAWDIS_GATEWAY_TOKEN; + } else { + process.env.CLAWDIS_GATEWAY_TOKEN = prevToken; + } + }); + + test("accepts password auth when configured", async () => { + testState.gatewayAuth = { 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 () => { + testState.gatewayAuth = { 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("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(); + }); +}); diff --git a/src/gateway/server.chat.test.ts b/src/gateway/server.chat.test.ts new file mode 100644 index 000000000..4ee60b2b4 --- /dev/null +++ b/src/gateway/server.chat.test.ts @@ -0,0 +1,758 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, test, vi } from "vitest"; +import { emitAgentEvent } from "../infra/agent-events.js"; +import { + agentCommand, + connectOk, + installGatewayTestHooks, + onceMessage, + piSdkMock, + rpcReq, + sessionStoreSaveDelayMs, + startServerWithClient, + testState, +} from "./test-helpers.js"; + +installGatewayTestHooks(); + +describe("gateway server chat", () => { + 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-")); + testState.sessionStorePath = path.join(dir, "sessions.json"); + await fs.writeFile( + testState.sessionStorePath, + 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 defaults thinking to low for reasoning-capable models", async () => { + piSdkMock.enabled = true; + piSdkMock.models = [ + { + id: "claude-opus-4-5", + name: "Opus 4.5", + provider: "anthropic", + reasoning: true, + }, + ]; + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-gw-")); + testState.sessionStorePath = path.join(dir, "sessions.json"); + await fs.writeFile( + testState.sessionStorePath, + 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: "hello" }], + timestamp: Date.now(), + }, + }), + "utf-8", + ); + + const { server, ws } = await startServerWithClient(); + await connectOk(ws); + + const res = await rpcReq<{ thinkingLevel?: string }>(ws, "chat.history", { + sessionKey: "main", + }); + expect(res.ok).toBe(true); + expect(res.payload?.thinkingLevel).toBe("low"); + + 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-")); + testState.sessionStorePath = path.join(dir, "sessions.json"); + await fs.writeFile( + testState.sessionStorePath, + 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-")); + testState.sessionStorePath = path.join(dir, "sessions.json"); + await fs.writeFile( + testState.sessionStorePath, + 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 res = await rpcReq(ws, "chat.send", { + sessionKey: "main", + message: "hello", + idempotencyKey: "idem-route", + }); + expect(res.ok).toBe(true); + + const stored = JSON.parse( + await fs.readFile(testState.sessionStorePath, "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-")); + testState.sessionStorePath = path.join(dir, "sessions.json"); + await fs.writeFile( + testState.sessionStorePath, + 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-")); + testState.sessionStorePath = path.join(dir, "sessions.json"); + await fs.writeFile( + testState.sessionStorePath, + 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-")); + testState.sessionStorePath = path.join(dir, "sessions.json"); + await fs.writeFile( + testState.sessionStorePath, + JSON.stringify({}, null, 2), + "utf-8", + ); + + const { server, ws } = await startServerWithClient(); + await connectOk(ws); + + const abortRes = await rpcReq<{ + ok?: boolean; + aborted?: boolean; + }>(ws, "chat.abort", { sessionKey: "main", runId: "missing-run" }); + + 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-")); + testState.sessionStorePath = path.join(dir, "sessions.json"); + await fs.writeFile( + testState.sessionStorePath, + 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 abortRes = await rpcReq(ws, "chat.abort", { + sessionKey: "other", + runId: "idem-mismatch-1", + }); + expect(abortRes.ok).toBe(false); + expect(abortRes.error?.code).toBe("INVALID_REQUEST"); + + const abortRes2 = await rpcReq(ws, "chat.abort", { + sessionKey: "main", + runId: "idem-mismatch-1", + }); + 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-")); + testState.sessionStorePath = path.join(dir, "sessions.json"); + await fs.writeFile( + testState.sessionStorePath, + 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); + + const abortRes = await rpcReq(ws, "chat.abort", { + sessionKey: "main", + runId: "idem-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-")); + testState.sessionStorePath = path.join(dir, "sessions.json"); + await fs.writeFile( + testState.sessionStorePath, + JSON.stringify( + { + main: { + sessionId: "sess-main", + updatedAt: Date.now(), + }, + }, + null, + 2, + ), + "utf-8", + ); + + const { server, ws } = await startServerWithClient(); + await connectOk(ws); + + const res1 = await rpcReq(ws, "chat.send", { + sessionKey: "main", + message: "first", + idempotencyKey: "idem-1", + }); + expect(res1.ok).toBe(true); + + const res2 = await rpcReq(ws, "chat.send", { + sessionKey: "main", + message: "second", + idempotencyKey: "idem-2", + }); + expect(res2.ok).toBe(true); + + const final1P = onceMessage( + ws, + (o) => + o.type === "event" && + o.event === "chat" && + o.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( + ws, + (o) => + o.type === "event" && + o.event === "chat" && + o.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(); + }); +}); diff --git a/src/gateway/server.cron.test.ts b/src/gateway/server.cron.test.ts new file mode 100644 index 000000000..a5c523df5 --- /dev/null +++ b/src/gateway/server.cron.test.ts @@ -0,0 +1,296 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, test } from "vitest"; +import { + connectOk, + installGatewayTestHooks, + rpcReq, + startServerWithClient, + testState, +} from "./test-helpers.js"; + +installGatewayTestHooks(); + +describe("gateway server cron", () => { + test("supports cron.add and cron.list", async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-gw-cron-")); + testState.cronStorePath = path.join(dir, "cron", "jobs.json"); + await fs.mkdir(path.dirname(testState.cronStorePath), { recursive: true }); + await fs.writeFile( + testState.cronStorePath, + JSON.stringify({ version: 1, jobs: [] }), + ); + + const { server, ws } = await startServerWithClient(); + await connectOk(ws); + + const addRes = await rpcReq(ws, "cron.add", { + name: "daily", + enabled: true, + schedule: { kind: "every", everyMs: 60_000 }, + sessionTarget: "main", + wakeMode: "next-heartbeat", + payload: { kind: "systemEvent", text: "hello" }, + }); + expect(addRes.ok).toBe(true); + expect(typeof (addRes.payload as { id?: unknown } | null)?.id).toBe( + "string", + ); + + const listRes = await rpcReq(ws, "cron.list", { + includeDisabled: true, + }); + 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 }); + testState.cronStorePath = undefined; + }); + + test("writes cron run history to runs/.jsonl", async () => { + const dir = await fs.mkdtemp( + path.join(os.tmpdir(), "clawdis-gw-cron-log-"), + ); + testState.cronStorePath = path.join(dir, "cron", "jobs.json"); + await fs.mkdir(path.dirname(testState.cronStorePath), { recursive: true }); + await fs.writeFile( + testState.cronStorePath, + JSON.stringify({ version: 1, jobs: [] }), + ); + + const { server, ws } = await startServerWithClient(); + await connectOk(ws); + + const atMs = Date.now() - 1; + const addRes = await rpcReq(ws, "cron.add", { + name: "log test", + enabled: true, + schedule: { kind: "at", atMs }, + sessionTarget: "main", + wakeMode: "next-heartbeat", + payload: { kind: "systemEvent", text: "hello" }, + }); + 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 runRes = await rpcReq(ws, "cron.run", { id: jobId, mode: "force" }); + expect(runRes.ok).toBe(true); + + const logPath = path.join(dir, "cron", "runs", `${jobId}.jsonl`); + const waitForLog = async () => { + for (let i = 0; i < 200; i += 1) { + 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"); + + const runsRes = await rpcReq(ws, "cron.runs", { id: jobId, limit: 50 }); + 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 }); + testState.cronStorePath = 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"); + testState.cronStorePath = path.join(cronDir, "jobs.json"); + await fs.mkdir(cronDir, { recursive: true }); + await fs.writeFile( + testState.cronStorePath, + JSON.stringify({ version: 1, jobs: [] }), + ); + + const { server, ws } = await startServerWithClient(); + await connectOk(ws); + + const atMs = Date.now() - 1; + const addRes = await rpcReq(ws, "cron.add", { + name: "log test (jobs.json)", + enabled: true, + schedule: { kind: "at", atMs }, + sessionTarget: "main", + wakeMode: "next-heartbeat", + payload: { kind: "systemEvent", text: "hello" }, + }); + + 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 runRes = await rpcReq(ws, "cron.run", { id: jobId, mode: "force" }); + expect(runRes.ok).toBe(true); + + const logPath = path.join(cronDir, "runs", `${jobId}.jsonl`); + const waitForLog = async () => { + for (let i = 0; i < 200; i += 1) { + 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"); + + const runsRes = await rpcReq(ws, "cron.runs", { id: jobId, limit: 20 }); + 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 }); + testState.cronStorePath = 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-"), + ); + testState.cronStorePath = path.join(dir, "cron", "jobs.json"); + testState.cronEnabled = undefined; + + try { + await fs.mkdir(path.dirname(testState.cronStorePath), { + recursive: true, + }); + await fs.writeFile( + testState.cronStorePath, + JSON.stringify({ version: 1, jobs: [] }), + ); + + const { server, ws } = await startServerWithClient(); + await connectOk(ws); + + const statusRes = await rpcReq(ws, "cron.status", {}); + 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; + const addRes = await rpcReq(ws, "cron.add", { + name: "auto run test", + enabled: true, + schedule: { kind: "at", atMs }, + sessionTarget: "main", + wakeMode: "next-heartbeat", + payload: { kind: "systemEvent", text: "auto" }, + }); + 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 new Promise<{ + type: "event"; + event: string; + payload?: { jobId?: string; action?: string; status?: string } | null; + }>((resolve) => { + const timeout = setTimeout(() => resolve(null as never), 8000); + ws.on("message", (data) => { + const obj = JSON.parse(String(data)); + if ( + obj.type === "event" && + obj.event === "cron" && + obj.payload?.jobId === jobId && + obj.payload?.action === "finished" + ) { + clearTimeout(timeout); + resolve(obj); + } + }); + }); + expect(finishedEvt.payload?.status).toBe("ok"); + + const waitForRuns = async () => { + for (let i = 0; i < 200; i += 1) { + const runsRes = await rpcReq(ws, "cron.runs", { + id: jobId, + limit: 10, + }); + 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 { + testState.cronEnabled = false; + testState.cronStorePath = undefined; + await fs.rm(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/src/gateway/server.health.test.ts b/src/gateway/server.health.test.ts new file mode 100644 index 000000000..a40b3f04f --- /dev/null +++ b/src/gateway/server.health.test.ts @@ -0,0 +1,310 @@ +import { randomUUID } from "node:crypto"; +import { describe, expect, test } from "vitest"; +import { WebSocket } from "ws"; +import { emitAgentEvent } from "../infra/agent-events.js"; +import { emitHeartbeatEvent } from "../infra/heartbeat-events.js"; +import { + connectOk, + getFreePort, + installGatewayTestHooks, + onceMessage, + startGatewayServer, + startServerWithClient, +} from "./test-helpers.js"; + +installGatewayTestHooks(); + +describe("gateway server health/presence", () => { + 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("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( + "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); + + 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("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("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(); + }); +}); diff --git a/src/gateway/server.hooks.test.ts b/src/gateway/server.hooks.test.ts new file mode 100644 index 000000000..752828b2b --- /dev/null +++ b/src/gateway/server.hooks.test.ts @@ -0,0 +1,184 @@ +import { describe, expect, test } from "vitest"; +import { drainSystemEvents, peekSystemEvents } from "../infra/system-events.js"; +import { + cronIsolatedRun, + getFreePort, + installGatewayTestHooks, + startGatewayServer, + testState, + waitForSystemEvent, +} from "./test-helpers.js"; + +installGatewayTestHooks(); + +describe("gateway server hooks", () => { + test("hooks wake requires auth", async () => { + testState.hooksConfig = { 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 () => { + testState.hooksConfig = { 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 () => { + testState.hooksConfig = { 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 () => { + testState.hooksConfig = { 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 () => { + testState.hooksConfig = { 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 () => { + testState.hooksConfig = { 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 () => { + testState.hooksConfig = { 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 () => { + testState.hooksConfig = { 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 () => { + testState.hooksConfig = { 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 () => { + testState.hooksConfig = { 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(); + }); +}); diff --git a/src/gateway/server.misc.test.ts b/src/gateway/server.misc.test.ts new file mode 100644 index 000000000..d8637a403 --- /dev/null +++ b/src/gateway/server.misc.test.ts @@ -0,0 +1,103 @@ +import { createServer } from "node:net"; +import { describe, expect, test } from "vitest"; +import { WebSocket } from "ws"; +import { GatewayLockError } from "../infra/gateway-lock.js"; +import { + connectOk, + getFreePort, + installGatewayTestHooks, + occupyPort, + onceMessage, + startGatewayServer, + startServerWithClient, + testState, + testTailnetIPv4, +} from "./test-helpers.js"; + +installGatewayTestHooks(); + +describe("gateway server misc", () => { + 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"; + testState.gatewayBind = "lan"; + const canvasPort = await getFreePort(); + testState.canvasHostPort = 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("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("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(); + + 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())), + ); + }); +}); diff --git a/src/gateway/server.models-voicewake.test.ts b/src/gateway/server.models-voicewake.test.ts new file mode 100644 index 000000000..138ee1565 --- /dev/null +++ b/src/gateway/server.models-voicewake.test.ts @@ -0,0 +1,262 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, test } from "vitest"; +import { + bridgeListConnected, + bridgeSendEvent, + bridgeStartCalls, + connectOk, + installGatewayTestHooks, + onceMessage, + piSdkMock, + rpcReq, + startServerWithClient, +} from "./test-helpers.js"; + +installGatewayTestHooks(); + +describe("gateway server models + voicewake", () => { + 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("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("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", + }, + ]); + + 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(); + }); +}); diff --git a/src/gateway/server.node-bridge.test.ts b/src/gateway/server.node-bridge.test.ts new file mode 100644 index 000000000..199eb2a17 --- /dev/null +++ b/src/gateway/server.node-bridge.test.ts @@ -0,0 +1,913 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, test, vi } from "vitest"; +import { emitAgentEvent } from "../infra/agent-events.js"; +import { + agentCommand, + bridgeInvoke, + bridgeListConnected, + bridgeSendEvent, + bridgeStartCalls, + connectOk, + getFreePort, + installGatewayTestHooks, + onceMessage, + rpcReq, + sessionStoreSaveDelayMs, + startGatewayServer, + startServerWithClient, + testState, +} from "./test-helpers.js"; + +installGatewayTestHooks(); + +describe("gateway server node/bridge", () => { + 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 = new Promise<{ + type: "event"; + event: string; + payload?: unknown; + }>((resolve) => { + ws.on("message", (data) => { + const obj = JSON.parse(String(data)) as { + type?: string; + event?: string; + payload?: unknown; + }; + if (obj.type === "event" && obj.event === "node.pair.requested") { + resolve(obj as never); + } + }); + }); + + const res1 = await rpcReq(ws, "node.pair.request", { + nodeId: "n1", + displayName: "Node", + }); + 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, + ); + + const res2 = await rpcReq(ws, "node.pair.request", { + nodeId: "n1", + displayName: "Node", + }); + expect(res2.ok).toBe(true); + await expect( + onceMessage( + ws, + (o) => o.type === "event" && o.event === "node.pair.requested", + 200, + ), + ).rejects.toThrow(); + + const resolvedP = new Promise<{ + type: "event"; + event: string; + payload?: unknown; + }>((resolve) => { + ws.on("message", (data) => { + const obj = JSON.parse(String(data)) as { + type?: string; + event?: string; + payload?: unknown; + }; + if (obj.type === "event" && obj.event === "node.pair.resolved") { + resolve(obj as never); + } + }); + }); + + const approveRes = await rpcReq(ws, "node.pair.approve", { requestId }); + 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", + ); + + const verifyRes = await rpcReq(ws, "node.pair.verify", { + nodeId: "n1", + token, + }); + expect(verifyRes.ok).toBe(true); + expect((verifyRes.payload as { ok?: unknown } | null)?.ok).toBe(true); + + const listRes = await rpcReq(ws, "node.pair.list", {}); + 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("routes camera.list 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-2", + ok: true, + payloadJSON: JSON.stringify({ devices: [] }), + error: null, + }); + + const { server, ws } = await startServerWithClient(); + try { + await connectOk(ws); + + const res = await rpcReq(ws, "node.invoke", { + nodeId: "ios-node", + command: "camera.list", + params: {}, + idempotencyKey: "idem-2", + }); + expect(res.ok).toBe(true); + + expect(bridgeInvoke).toHaveBeenCalledWith( + expect.objectContaining({ + nodeId: "ios-node", + command: "camera.list", + paramsJSON: JSON.stringify({}), + }), + ); + } 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("bridge RPC chat.history returns session messages", async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-gw-")); + testState.sessionStorePath = path.join(dir, "sessions.json"); + await fs.writeFile( + testState.sessionStorePath, + 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-")); + testState.sessionStorePath = path.join(dir, "sessions.json"); + await fs.writeFile( + testState.sessionStorePath, + 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-")); + testState.sessionStorePath = path.join(dir, "sessions.json"); + await fs.writeFile( + testState.sessionStorePath, + 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(); + + await bridgeCall?.onEvent?.("ios-node", { + event: "chat.subscribe", + payloadJSON: JSON.stringify({ sessionKey: "main" }), + }); + + bridgeSendEvent.mockClear(); + + 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" }, + }); + + 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-")); + testState.sessionStorePath = path.join(dir, "sessions.json"); + await fs.writeFile( + testState.sessionStorePath, + 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(testState.sessionStorePath, "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-")); + testState.sessionStorePath = path.join(dir, "sessions.json"); + await fs.writeFile( + testState.sessionStorePath, + 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 = new Promise<{ + type: "event"; + event: string; + payload?: unknown; + }>((resolve) => { + ws.on("message", (data) => { + const obj = JSON.parse(String(data)); + if (isVoiceFinalChatEvent(obj)) { + resolve(obj as never); + } + }); + }); + + 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("bridge chat.abort cancels while saving the session store", async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-gw-")); + testState.sessionStorePath = path.join(dir, "sessions.json"); + await fs.writeFile( + testState.sessionStorePath, + 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(); + }); +}); diff --git a/src/gateway/server.providers.test.ts b/src/gateway/server.providers.test.ts new file mode 100644 index 000000000..6e7462684 --- /dev/null +++ b/src/gateway/server.providers.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, test } from "vitest"; +import { readConfigFileSnapshot, writeConfigFile } from "../config/config.js"; +import { + connectOk, + installGatewayTestHooks, + rpcReq, + startServerWithClient, +} from "./test-helpers.js"; + +installGatewayTestHooks(); + +describe("gateway server providers", () => { + 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", + groups: { "*": { 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?.groups?.["*"]?.requireMention).toBe(false); + + ws.close(); + await server.close(); + if (prevToken === undefined) { + delete process.env.TELEGRAM_BOT_TOKEN; + } else { + process.env.TELEGRAM_BOT_TOKEN = prevToken; + } + }); +}); diff --git a/src/gateway/server.sessions.test.ts b/src/gateway/server.sessions.test.ts new file mode 100644 index 000000000..800a7e3d7 --- /dev/null +++ b/src/gateway/server.sessions.test.ts @@ -0,0 +1,211 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, test } from "vitest"; +import { + connectOk, + installGatewayTestHooks, + piSdkMock, + rpcReq, + startServerWithClient, + testState, +} from "./test-helpers.js"; + +installGatewayTestHooks(); + +describe("gateway server sessions", () => { + 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(); + testState.sessionStorePath = 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(); + + piSdkMock.enabled = true; + piSdkMock.models = [{ id: "gpt-test-a", name: "A", provider: "openai" }]; + const modelPatched = await rpcReq<{ + ok: true; + entry: { modelOverride?: string; providerOverride?: string }; + }>(ws, "sessions.patch", { key: "main", model: "openai/gpt-test-a" }); + expect(modelPatched.ok).toBe(true); + expect(modelPatched.payload?.entry.modelOverride).toBe("gpt-test-a"); + expect(modelPatched.payload?.entry.providerOverride).toBe("openai"); + + 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(); + }); +}); diff --git a/src/gateway/server.test.ts b/src/gateway/server.test.ts deleted file mode 100644 index a6ad437d3..000000000 --- a/src/gateway/server.test.ts +++ /dev/null @@ -1,4507 +0,0 @@ -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; - reasoning?: boolean; - }>, -})); -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[] = []; -const testIsNixMode = vi.hoisted(() => ({ value: 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", async () => { - const actual = await vi.importActual( - "../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 { - ...actual, - CONFIG_PATH_CLAWDIS: resolveConfigPath(), - STATE_DIR_CLAWDIS: path.dirname(resolveConfigPath()), - get isNixMode() { - return testIsNixMode.value; - }, - 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.value = 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, - opts?: Parameters[1], -) { - 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, opts); - 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.value = 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("routes camera.list 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-2", - ok: true, - payloadJSON: JSON.stringify({ devices: [] }), - error: null, - }); - - const { server, ws } = await startServerWithClient(); - try { - await connectOk(ws); - - const res = await rpcReq(ws, "node.invoke", { - nodeId: "ios-node", - command: "camera.list", - params: {}, - idempotencyKey: "idem-2", - }); - expect(res.ok).toBe(true); - - expect(bridgeInvoke).toHaveBeenCalledWith( - expect.objectContaining({ - nodeId: "ios-node", - command: "camera.list", - paramsJSON: JSON.stringify({}), - }), - ); - } 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("config.schema returns schema + hints", async () => { - const { server, ws } = await startServerWithClient(); - await connectOk(ws); - - const res = await rpcReq<{ - schema?: { properties?: { gateway?: unknown } }; - uiHints?: { gateway?: { label?: string } }; - }>(ws, "config.schema", {}); - expect(res.ok).toBe(true); - expect(res.payload?.schema?.properties?.gateway).toBeTruthy(); - expect(res.payload?.uiHints?.gateway?.label).toBe("Gateway"); - - ws.close(); - await server.close(); - }); - - test("wizard.start and wizard.next drive steps", async () => { - const { server, ws } = await startServerWithClient(undefined, { - wizardRunner: async (_opts, _runtime, prompter) => { - await prompter.note("Welcome"); - const name = await prompter.text({ message: "Name" }); - await prompter.note(`Hello ${name}`); - }, - }); - await connectOk(ws); - - const startRes = await rpcReq<{ - sessionId?: string; - step?: { id?: string; type?: string }; - }>(ws, "wizard.start", {}); - expect(startRes.ok).toBe(true); - const sessionId = startRes.payload?.sessionId ?? ""; - const firstStep = startRes.payload?.step; - expect(sessionId).not.toBe(""); - expect(firstStep?.type).toBe("note"); - - const runningRes = await rpcReq(ws, "wizard.start", {}); - expect(runningRes.ok).toBe(false); - expect(runningRes.error?.message).toMatch(/wizard already running/i); - - const nextOne = await rpcReq<{ - step?: { id?: string; type?: string }; - done?: boolean; - }>(ws, "wizard.next", { - sessionId, - answer: { stepId: firstStep?.id, value: null }, - }); - expect(nextOne.ok).toBe(true); - const textStep = nextOne.payload?.step; - expect(textStep?.type).toBe("text"); - - const nextTwo = await rpcReq<{ - step?: { id?: string; type?: string }; - done?: boolean; - }>(ws, "wizard.next", { - sessionId, - answer: { stepId: textStep?.id, value: "Peter" }, - }); - expect(nextTwo.ok).toBe(true); - const finalStep = nextTwo.payload?.step; - expect(finalStep?.type).toBe("note"); - - const done = await rpcReq<{ - done?: boolean; - status?: string; - }>(ws, "wizard.next", { - sessionId, - answer: { stepId: finalStep?.id, value: null }, - }); - expect(done.ok).toBe(true); - expect(done.payload?.done).toBe(true); - expect(done.payload?.status).toBe("done"); - - ws.close(); - await server.close(); - }); - - test("wizard.cancel ends the session", async () => { - const { server, ws } = await startServerWithClient(undefined, { - wizardRunner: async (_opts, _runtime, prompter) => { - await prompter.note("Welcome"); - await prompter.text({ message: "Name" }); - }, - }); - await connectOk(ws); - - const startRes = await rpcReq<{ - sessionId?: string; - step?: { id?: string; type?: string }; - }>(ws, "wizard.start", {}); - expect(startRes.ok).toBe(true); - const sessionId = startRes.payload?.sessionId ?? ""; - expect(sessionId).not.toBe(""); - - const cancelRes = await rpcReq<{ status?: string }>(ws, "wizard.cancel", { - sessionId, - }); - expect(cancelRes.ok).toBe(true); - expect(cancelRes.payload?.status).toBe("cancelled"); - - 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", - groups: { "*": { 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?.groups?.["*"]?.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 defaults thinking to low for reasoning-capable models", async () => { - piSdkMock.enabled = true; - piSdkMock.models = [ - { - id: "claude-opus-4-5", - name: "Opus 4.5", - provider: "anthropic", - reasoning: true, - }, - ]; - 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: "hello" }], - timestamp: Date.now(), - }, - }), - "utf-8", - ); - - const { server, ws } = await startServerWithClient(); - await connectOk(ws); - - const res = await rpcReq<{ thinkingLevel?: string }>(ws, "chat.history", { - sessionKey: "main", - }); - expect(res.ok).toBe(true); - expect(res.payload?.thinkingLevel).toBe("low"); - - 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(); - - piSdkMock.enabled = true; - piSdkMock.models = [{ id: "gpt-test-a", name: "A", provider: "openai" }]; - const modelPatched = await rpcReq<{ - ok: true; - entry: { modelOverride?: string; providerOverride?: string }; - }>(ws, "sessions.patch", { key: "main", model: "openai/gpt-test-a" }); - expect(modelPatched.ok).toBe(true); - expect(modelPatched.payload?.entry.modelOverride).toBe("gpt-test-a"); - expect(modelPatched.payload?.entry.providerOverride).toBe("openai"); - - 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(); - }); -}); diff --git a/src/gateway/server.ts b/src/gateway/server.ts index 864fc8573..49c3a3690 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -9,12 +9,7 @@ import os from "node:os"; import path from "node:path"; import chalk from "chalk"; import { type WebSocket, WebSocketServer } from "ws"; -import { lookupContextTokens } from "../agents/context.js"; -import { - DEFAULT_CONTEXT_TOKENS, - DEFAULT_MODEL, - DEFAULT_PROVIDER, -} from "../agents/defaults.js"; +import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js"; import { loadModelCatalog, type ModelCatalogEntry, @@ -64,7 +59,6 @@ import { } from "../config/config.js"; import { buildConfigSchema } from "../config/schema.js"; import { - buildGroupDisplayName, loadSessionStore, resolveStorePath, type SessionEntry, @@ -84,7 +78,7 @@ import { sendMessageDiscord, } from "../discord/index.js"; import { type DiscordProbe, probeDiscord } from "../discord/probe.js"; -import { isVerbose, shouldLogVerbose } from "../globals.js"; +import { shouldLogVerbose } from "../globals.js"; import { startGmailWatcher, stopGmailWatcher } from "../hooks/gmail-watcher.js"; import { monitorIMessageProvider, @@ -181,110 +175,33 @@ import { import { buildMessageWithAttachments } from "./chat-attachments.js"; import { handleControlUiHttpRequest } from "./control-ui.js"; import { - applyHookMappings, - type HookMappingResolved, - resolveHookMappings, -} from "./hooks-mapping.js"; + extractHookToken, + normalizeAgentPayload, + normalizeHookHeaders, + normalizeWakePayload, + readJsonBody, + resolveHooksConfig, +} from "./hooks.js"; +import { applyHookMappings } from "./hooks-mapping.js"; +import { + isLoopbackAddress, + isLoopbackHost, + resolveGatewayBindHost, +} from "./net.js"; +import { + archiveFileOnDisk, + capArrayByJsonBytes, + listSessionsFromStore, + loadSessionEntry, + readSessionMessages, + resolveSessionModelRef, + resolveSessionTranscriptCandidates, + type SessionsPatchResult, +} from "./session-utils.js"; +import { formatForLog, logWs, summarizeAgentEventForWsLog } from "./ws-log.js"; ensureClawdisCliOnPath(); -const DEFAULT_HOOKS_PATH = "/hooks"; -const DEFAULT_HOOKS_MAX_BODY_BYTES = 256 * 1024; - -type HooksConfigResolved = { - basePath: string; - token: string; - maxBodyBytes: number; - mappings: HookMappingResolved[]; -}; - -function resolveHooksConfig(cfg: ClawdisConfig): HooksConfigResolved | null { - if (cfg.hooks?.enabled !== true) return null; - const token = cfg.hooks?.token?.trim(); - if (!token) { - throw new Error("hooks.enabled requires hooks.token"); - } - const rawPath = cfg.hooks?.path?.trim() || DEFAULT_HOOKS_PATH; - const withSlash = rawPath.startsWith("/") ? rawPath : `/${rawPath}`; - const trimmed = - withSlash.length > 1 ? withSlash.replace(/\/+$/, "") : withSlash; - if (trimmed === "/") { - throw new Error("hooks.path may not be '/'"); - } - const maxBodyBytes = - cfg.hooks?.maxBodyBytes && cfg.hooks.maxBodyBytes > 0 - ? cfg.hooks.maxBodyBytes - : DEFAULT_HOOKS_MAX_BODY_BYTES; - const mappings = resolveHookMappings(cfg.hooks); - return { - basePath: trimmed, - token, - maxBodyBytes, - mappings, - }; -} - -function extractHookToken(req: IncomingMessage, url: URL): string | undefined { - const auth = - typeof req.headers.authorization === "string" - ? req.headers.authorization.trim() - : ""; - if (auth.toLowerCase().startsWith("bearer ")) { - const token = auth.slice(7).trim(); - if (token) return token; - } - const headerToken = - typeof req.headers["x-clawdis-token"] === "string" - ? req.headers["x-clawdis-token"].trim() - : ""; - if (headerToken) return headerToken; - const queryToken = url.searchParams.get("token"); - if (queryToken) return queryToken.trim(); - return undefined; -} - -async function readJsonBody( - req: IncomingMessage, - maxBytes: number, -): Promise<{ ok: true; value: unknown } | { ok: false; error: string }> { - return await new Promise((resolve) => { - let done = false; - let total = 0; - const chunks: Buffer[] = []; - req.on("data", (chunk: Buffer) => { - if (done) return; - total += chunk.length; - if (total > maxBytes) { - done = true; - resolve({ ok: false, error: "payload too large" }); - req.destroy(); - return; - } - chunks.push(chunk); - }); - req.on("end", () => { - if (done) return; - done = true; - const raw = Buffer.concat(chunks).toString("utf-8").trim(); - if (!raw) { - resolve({ ok: true, value: {} }); - return; - } - try { - const parsed = JSON.parse(raw) as unknown; - resolve({ ok: true, value: parsed }); - } catch (err) { - resolve({ ok: false, error: String(err) }); - } - }); - req.on("error", (err) => { - if (done) return; - done = true; - resolve({ ok: false, error: String(err) }); - }); - }); -} - function sendJson( res: import("node:http").ServerResponse, status: number, @@ -435,7 +352,6 @@ import { validateWizardStartParams, validateWizardStatusParams, } from "./protocol/index.js"; -import { DEFAULT_WS_SLOW_MS, getGatewayWsLogStyle } from "./ws-logging.js"; type Client = { socket: WebSocket; @@ -465,47 +381,6 @@ async function resolveTailnetDnsHint(): Promise { } } -type GatewaySessionsDefaults = { - model: string | null; - contextTokens: number | null; -}; - -type GatewaySessionRow = { - key: string; - kind: "direct" | "group" | "global" | "unknown"; - displayName?: string; - surface?: string; - subject?: string; - room?: string; - space?: string; - updatedAt: number | null; - sessionId?: string; - systemSent?: boolean; - abortedLastRun?: boolean; - thinkingLevel?: string; - verboseLevel?: string; - inputTokens?: number; - outputTokens?: number; - totalTokens?: number; - model?: string; - contextTokens?: number; -}; - -type SessionsListResult = { - ts: number; - path: string; - count: number; - defaults: GatewaySessionsDefaults; - sessions: GatewaySessionRow[]; -}; - -type SessionsPatchResult = { - ok: true; - path: string; - key: string; - entry: SessionEntry; -}; - const METHODS = [ "health", "providers.status", @@ -625,30 +500,6 @@ export type GatewayServerOptions = { ) => Promise; }; -function isLoopbackAddress(ip: string | undefined): boolean { - if (!ip) return false; - if (ip === "127.0.0.1") return true; - if (ip.startsWith("127.")) return true; - if (ip === "::1") return true; - if (ip.startsWith("::ffff:127.")) return true; - return false; -} - -function resolveGatewayBindHost( - bind: import("../config/config.js").BridgeBindMode | undefined, -): string | null { - const mode = bind ?? "loopback"; - if (mode === "loopback") return "127.0.0.1"; - if (mode === "lan") return "0.0.0.0"; - if (mode === "tailnet") return pickPrimaryTailnetIPv4() ?? null; - if (mode === "auto") return pickPrimaryTailnetIPv4() ?? "0.0.0.0"; - return "127.0.0.1"; -} - -function isLoopbackHost(host: string): boolean { - return isLoopbackAddress(host); -} - let presenceVersion = 1; let healthVersion = 1; let healthCache: HealthSummary | null = null; @@ -680,8 +531,6 @@ const TICK_INTERVAL_MS = 30_000; const HEALTH_REFRESH_INTERVAL_MS = 60_000; const DEDUPE_TTL_MS = 5 * 60_000; const DEDUPE_MAX = 1000; -const LOG_VALUE_LIMIT = 240; - type DedupeEntry = { ts: number; ok: boolean; @@ -689,121 +538,6 @@ type DedupeEntry = { error?: ErrorShape; }; -function formatForLog(value: unknown): string { - try { - if (value instanceof Error) { - const parts: string[] = []; - if (value.name) parts.push(value.name); - if (value.message) parts.push(value.message); - const code = - "code" in value && - (typeof value.code === "string" || typeof value.code === "number") - ? String(value.code) - : ""; - if (code) parts.push(`code=${code}`); - const combined = parts.filter(Boolean).join(": ").trim(); - if (combined) { - return combined.length > LOG_VALUE_LIMIT - ? `${combined.slice(0, LOG_VALUE_LIMIT)}...` - : combined; - } - } - if (value && typeof value === "object") { - const rec = value as Record; - if (typeof rec.message === "string" && rec.message.trim()) { - const name = typeof rec.name === "string" ? rec.name.trim() : ""; - const code = - typeof rec.code === "string" || typeof rec.code === "number" - ? String(rec.code) - : ""; - const parts = [name, rec.message.trim()].filter(Boolean); - if (code) parts.push(`code=${code}`); - const combined = parts.join(": ").trim(); - return combined.length > LOG_VALUE_LIMIT - ? `${combined.slice(0, LOG_VALUE_LIMIT)}...` - : combined; - } - } - const str = - typeof value === "string" || typeof value === "number" - ? String(value) - : JSON.stringify(value); - if (!str) return ""; - return str.length > LOG_VALUE_LIMIT - ? `${str.slice(0, LOG_VALUE_LIMIT)}...` - : str; - } catch { - return String(value); - } -} - -function compactPreview(input: string, maxLen = 160): string { - const oneLine = input.replace(/\s+/g, " ").trim(); - if (oneLine.length <= maxLen) return oneLine; - return `${oneLine.slice(0, Math.max(0, maxLen - 1))}…`; -} - -function summarizeAgentEventForWsLog( - payload: unknown, -): Record { - if (!payload || typeof payload !== "object") return {}; - const rec = payload as Record; - const runId = typeof rec.runId === "string" ? rec.runId : undefined; - const stream = typeof rec.stream === "string" ? rec.stream : undefined; - const seq = typeof rec.seq === "number" ? rec.seq : undefined; - const data = - rec.data && typeof rec.data === "object" - ? (rec.data as Record) - : undefined; - - const extra: Record = {}; - if (runId) extra.run = shortId(runId); - if (stream) extra.stream = stream; - if (seq !== undefined) extra.aseq = seq; - - if (!data) return extra; - - if (stream === "assistant") { - const text = typeof data.text === "string" ? data.text : undefined; - if (text?.trim()) extra.text = compactPreview(text); - const mediaUrls = Array.isArray(data.mediaUrls) - ? data.mediaUrls - : undefined; - if (mediaUrls && mediaUrls.length > 0) extra.media = mediaUrls.length; - return extra; - } - - if (stream === "tool") { - const phase = typeof data.phase === "string" ? data.phase : undefined; - const name = typeof data.name === "string" ? data.name : undefined; - if (phase || name) extra.tool = `${phase ?? "?"}:${name ?? "?"}`; - const toolCallId = - typeof data.toolCallId === "string" ? data.toolCallId : undefined; - if (toolCallId) extra.call = shortId(toolCallId); - const meta = typeof data.meta === "string" ? data.meta : undefined; - if (meta?.trim()) extra.meta = meta; - if (typeof data.isError === "boolean") extra.err = data.isError; - return extra; - } - - if (stream === "job") { - const state = typeof data.state === "string" ? data.state : undefined; - if (state) extra.state = state; - if (data.to === null) extra.to = null; - else if (typeof data.to === "string") extra.to = data.to; - if (typeof data.durationMs === "number") - extra.ms = Math.round(data.durationMs); - if (typeof data.aborted === "boolean") extra.aborted = data.aborted; - const error = typeof data.error === "string" ? data.error : undefined; - if (error?.trim()) extra.error = compactPreview(error, 120); - return extra; - } - - const reason = typeof data.reason === "string" ? data.reason : undefined; - if (reason?.trim()) extra.reason = reason; - return extra; -} - function normalizeVoiceWakeTriggers(input: unknown): string[] { const raw = Array.isArray(input) ? input : []; const cleaned = raw @@ -814,522 +548,6 @@ function normalizeVoiceWakeTriggers(input: unknown): string[] { return cleaned.length > 0 ? cleaned : defaultVoiceWakeTriggers(); } -function readSessionMessages( - sessionId: string, - storePath: string | undefined, -): unknown[] { - const candidates = resolveSessionTranscriptCandidates(sessionId, storePath); - - const filePath = candidates.find((p) => fs.existsSync(p)); - if (!filePath) return []; - - const lines = fs.readFileSync(filePath, "utf-8").split(/\r?\n/); - const messages: unknown[] = []; - for (const line of lines) { - if (!line.trim()) continue; - try { - const parsed = JSON.parse(line); - if (parsed?.message) { - messages.push(parsed.message); - } - } catch { - // ignore bad lines - } - } - return messages; -} - -function resolveSessionTranscriptCandidates( - sessionId: string, - storePath: string | undefined, -): string[] { - const candidates: string[] = []; - if (storePath) { - const dir = path.dirname(storePath); - candidates.push(path.join(dir, `${sessionId}.jsonl`)); - } - candidates.push( - path.join(os.homedir(), ".clawdis", "sessions", `${sessionId}.jsonl`), - ); - return candidates; -} - -function archiveFileOnDisk(filePath: string, reason: string): string { - const ts = new Date().toISOString().replaceAll(":", "-"); - const archived = `${filePath}.${reason}.${ts}`; - fs.renameSync(filePath, archived); - return archived; -} - -function jsonUtf8Bytes(value: unknown): number { - try { - return Buffer.byteLength(JSON.stringify(value), "utf8"); - } catch { - return Buffer.byteLength(String(value), "utf8"); - } -} - -function capArrayByJsonBytes( - items: T[], - maxBytes: number, -): { items: T[]; bytes: number } { - if (items.length === 0) return { items, bytes: 2 }; - const parts = items.map((item) => jsonUtf8Bytes(item)); - let bytes = 2 + parts.reduce((a, b) => a + b, 0) + (items.length - 1); // [] + commas - let start = 0; - while (bytes > maxBytes && start < items.length - 1) { - bytes -= parts[start] + 1; // item + comma - start += 1; - } - const next = start > 0 ? items.slice(start) : items; - return { items: next, bytes }; -} - -function loadSessionEntry(sessionKey: string) { - const cfg = loadConfig(); - const sessionCfg = cfg.session; - const storePath = sessionCfg?.store - ? resolveStorePath(sessionCfg.store) - : resolveStorePath(undefined); - const store = loadSessionStore(storePath); - const entry = store[sessionKey]; - return { cfg, storePath, store, entry }; -} - -function classifySessionKey( - key: string, - entry?: SessionEntry, -): GatewaySessionRow["kind"] { - if (key === "global") return "global"; - if (key === "unknown") return "unknown"; - if (entry?.chatType === "group" || entry?.chatType === "room") return "group"; - if ( - key.startsWith("group:") || - key.includes(":group:") || - key.includes(":channel:") - ) { - return "group"; - } - return "direct"; -} - -function parseGroupKey( - key: string, -): { surface?: string; kind?: "group" | "channel"; id?: string } | null { - if (key.startsWith("group:")) { - const raw = key.slice("group:".length); - return raw ? { id: raw } : null; - } - const parts = key.split(":").filter(Boolean); - if (parts.length >= 3) { - const [surface, kind, ...rest] = parts; - if (kind === "group" || kind === "channel") { - const id = rest.join(":"); - return { surface, kind, id }; - } - } - return null; -} - -function getSessionDefaults(cfg: ClawdisConfig): GatewaySessionsDefaults { - const resolved = resolveConfiguredModelRef({ - cfg, - defaultProvider: DEFAULT_PROVIDER, - defaultModel: DEFAULT_MODEL, - }); - const contextTokens = - cfg.agent?.contextTokens ?? - lookupContextTokens(resolved.model) ?? - DEFAULT_CONTEXT_TOKENS; - return { - model: resolved.model ?? null, - contextTokens: contextTokens ?? null, - }; -} - -function resolveSessionModelRef( - cfg: ClawdisConfig, - entry?: SessionEntry, -): { provider: string; model: string } { - const resolved = resolveConfiguredModelRef({ - cfg, - defaultProvider: DEFAULT_PROVIDER, - defaultModel: DEFAULT_MODEL, - }); - let provider = resolved.provider; - let model = resolved.model; - const storedModelOverride = entry?.modelOverride?.trim(); - if (storedModelOverride) { - provider = entry?.providerOverride?.trim() || provider; - model = storedModelOverride; - } - return { provider, model }; -} - -function listSessionsFromStore(params: { - cfg: ClawdisConfig; - storePath: string; - store: Record; - opts: SessionsListParams; -}): SessionsListResult { - const { cfg, storePath, store, opts } = params; - const now = Date.now(); - - const includeGlobal = opts.includeGlobal === true; - const includeUnknown = opts.includeUnknown === true; - const activeMinutes = - typeof opts.activeMinutes === "number" && - Number.isFinite(opts.activeMinutes) - ? Math.max(1, Math.floor(opts.activeMinutes)) - : undefined; - - let sessions = Object.entries(store) - .filter(([key]) => { - if (!includeGlobal && key === "global") return false; - if (!includeUnknown && key === "unknown") return false; - return true; - }) - .map(([key, entry]) => { - const updatedAt = entry?.updatedAt ?? null; - const input = entry?.inputTokens ?? 0; - const output = entry?.outputTokens ?? 0; - const total = entry?.totalTokens ?? input + output; - const parsed = parseGroupKey(key); - const surface = entry?.surface ?? parsed?.surface; - const subject = entry?.subject; - const room = entry?.room; - const space = entry?.space; - const id = parsed?.id; - const displayName = - entry?.displayName ?? - (surface - ? buildGroupDisplayName({ - surface, - subject, - room, - space, - id, - key, - }) - : undefined); - return { - key, - kind: classifySessionKey(key, entry), - displayName, - surface, - subject, - room, - space, - updatedAt, - sessionId: entry?.sessionId, - systemSent: entry?.systemSent, - abortedLastRun: entry?.abortedLastRun, - thinkingLevel: entry?.thinkingLevel, - verboseLevel: entry?.verboseLevel, - inputTokens: entry?.inputTokens, - outputTokens: entry?.outputTokens, - totalTokens: total, - model: entry?.model, - contextTokens: entry?.contextTokens, - } satisfies GatewaySessionRow; - }) - .sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0)); - - if (activeMinutes !== undefined) { - const cutoff = now - activeMinutes * 60_000; - sessions = sessions.filter((s) => (s.updatedAt ?? 0) >= cutoff); - } - - if (typeof opts.limit === "number" && Number.isFinite(opts.limit)) { - const limit = Math.max(1, Math.floor(opts.limit)); - sessions = sessions.slice(0, limit); - } - - return { - ts: now, - path: storePath, - count: sessions.length, - defaults: getSessionDefaults(cfg), - sessions, - }; -} - -function logWs( - direction: "in" | "out", - kind: string, - meta?: Record, -) { - const style = getGatewayWsLogStyle(); - if (!isVerbose()) { - logWsOptimized(direction, kind, meta); - return; - } - - if (style === "compact" || style === "auto") { - logWsCompact(direction, kind, meta); - return; - } - - const now = Date.now(); - const connId = typeof meta?.connId === "string" ? meta.connId : undefined; - const id = typeof meta?.id === "string" ? meta.id : undefined; - const method = typeof meta?.method === "string" ? meta.method : undefined; - const ok = typeof meta?.ok === "boolean" ? meta.ok : undefined; - const event = typeof meta?.event === "string" ? meta.event : undefined; - - const inflightKey = connId && id ? `${connId}:${id}` : undefined; - if (direction === "in" && kind === "req" && inflightKey) { - wsInflightSince.set(inflightKey, now); - } - const durationMs = - direction === "out" && kind === "res" && inflightKey - ? (() => { - const startedAt = wsInflightSince.get(inflightKey); - if (startedAt === undefined) return undefined; - wsInflightSince.delete(inflightKey); - return now - startedAt; - })() - : undefined; - - const dirArrow = direction === "in" ? "←" : "→"; - const dirColor = direction === "in" ? chalk.greenBright : chalk.cyanBright; - const prefix = `${chalk.gray("[gws]")} ${dirColor(dirArrow)} ${chalk.bold(kind)}`; - - const headline = - (kind === "req" || kind === "res") && method - ? chalk.bold(method) - : kind === "event" && event - ? chalk.bold(event) - : undefined; - - const statusToken = - kind === "res" && ok !== undefined - ? ok - ? chalk.greenBright("✓") - : chalk.redBright("✗") - : undefined; - - const durationToken = - typeof durationMs === "number" ? chalk.dim(`${durationMs}ms`) : undefined; - - const restMeta: string[] = []; - if (meta) { - for (const [key, value] of Object.entries(meta)) { - if (value === undefined) continue; - if (key === "connId" || key === "id") continue; - if (key === "method" || key === "ok") continue; - if (key === "event") continue; - restMeta.push(`${chalk.dim(key)}=${formatForLog(value)}`); - } - } - - const trailing: string[] = []; - if (connId) { - trailing.push(`${chalk.dim("conn")}=${chalk.gray(shortId(connId))}`); - } - if (id) trailing.push(`${chalk.dim("id")}=${chalk.gray(shortId(id))}`); - - const tokens = [ - prefix, - statusToken, - headline, - durationToken, - ...restMeta, - ...trailing, - ].filter((t): t is string => Boolean(t)); - - console.log(tokens.join(" ")); -} - -type WsInflightEntry = { - ts: number; - method?: string; - meta?: Record; -}; - -const wsInflightCompact = new Map(); -let wsLastCompactConnId: string | undefined; -const wsInflightOptimized = new Map(); - -function logWsOptimized( - direction: "in" | "out", - kind: string, - meta?: Record, -) { - // Keep "normal" mode quiet: only surface errors, slow calls, and parser issues. - const connId = typeof meta?.connId === "string" ? meta.connId : undefined; - const id = typeof meta?.id === "string" ? meta.id : undefined; - const ok = typeof meta?.ok === "boolean" ? meta.ok : undefined; - const method = typeof meta?.method === "string" ? meta.method : undefined; - - const inflightKey = connId && id ? `${connId}:${id}` : undefined; - - if (direction === "in" && kind === "req" && inflightKey) { - wsInflightOptimized.set(inflightKey, Date.now()); - if (wsInflightOptimized.size > 2000) wsInflightOptimized.clear(); - return; - } - - if (kind === "parse-error") { - const errorMsg = - typeof meta?.error === "string" ? formatForLog(meta.error) : undefined; - console.log( - [ - `${chalk.gray("[gws]")} ${chalk.redBright("✗")} ${chalk.bold("parse-error")}`, - errorMsg ? `${chalk.dim("error")}=${errorMsg}` : undefined, - `${chalk.dim("conn")}=${chalk.gray(shortId(connId ?? "?"))}`, - ] - .filter((t): t is string => Boolean(t)) - .join(" "), - ); - return; - } - - if (direction !== "out" || kind !== "res") return; - - const startedAt = inflightKey - ? wsInflightOptimized.get(inflightKey) - : undefined; - if (inflightKey) wsInflightOptimized.delete(inflightKey); - const durationMs = - typeof startedAt === "number" ? Date.now() - startedAt : undefined; - - const shouldLog = - ok === false || - (typeof durationMs === "number" && durationMs >= DEFAULT_WS_SLOW_MS); - if (!shouldLog) return; - - const statusToken = - ok === undefined - ? undefined - : ok - ? chalk.greenBright("✓") - : chalk.redBright("✗"); - const durationToken = - typeof durationMs === "number" ? chalk.dim(`${durationMs}ms`) : undefined; - - const restMeta: string[] = []; - if (meta) { - for (const [key, value] of Object.entries(meta)) { - if (value === undefined) continue; - if (key === "connId" || key === "id") continue; - if (key === "method" || key === "ok") continue; - restMeta.push(`${chalk.dim(key)}=${formatForLog(value)}`); - } - } - - const tokens = [ - `${chalk.gray("[gws]")} ${chalk.yellowBright("⇄")} ${chalk.bold("res")}`, - statusToken, - method ? chalk.bold(method) : undefined, - durationToken, - ...restMeta, - connId ? `${chalk.dim("conn")}=${chalk.gray(shortId(connId))}` : undefined, - id ? `${chalk.dim("id")}=${chalk.gray(shortId(id))}` : undefined, - ].filter((t): t is string => Boolean(t)); - - console.log(tokens.join(" ")); -} - -function logWsCompact( - direction: "in" | "out", - kind: string, - meta?: Record, -) { - const now = Date.now(); - const connId = typeof meta?.connId === "string" ? meta.connId : undefined; - const id = typeof meta?.id === "string" ? meta.id : undefined; - const method = typeof meta?.method === "string" ? meta.method : undefined; - const ok = typeof meta?.ok === "boolean" ? meta.ok : undefined; - const inflightKey = connId && id ? `${connId}:${id}` : undefined; - - // Pair req/res into a single line (printed on response). - if (kind === "req" && direction === "in" && inflightKey) { - wsInflightCompact.set(inflightKey, { ts: now, method, meta }); - return; - } - - const compactArrow = (() => { - if (kind === "req" || kind === "res") return "⇄"; - return direction === "in" ? "←" : "→"; - })(); - const arrowColor = - kind === "req" || kind === "res" - ? chalk.yellowBright - : direction === "in" - ? chalk.greenBright - : chalk.cyanBright; - - const prefix = `${chalk.gray("[gws]")} ${arrowColor(compactArrow)} ${chalk.bold(kind)}`; - - const statusToken = - kind === "res" && ok !== undefined - ? ok - ? chalk.greenBright("✓") - : chalk.redBright("✗") - : undefined; - - const startedAt = - kind === "res" && direction === "out" && inflightKey - ? wsInflightCompact.get(inflightKey)?.ts - : undefined; - if (kind === "res" && direction === "out" && inflightKey) { - wsInflightCompact.delete(inflightKey); - } - const durationToken = - typeof startedAt === "number" - ? chalk.dim(`${now - startedAt}ms`) - : undefined; - - const headline = - (kind === "req" || kind === "res") && method - ? chalk.bold(method) - : kind === "event" && typeof meta?.event === "string" - ? chalk.bold(meta.event) - : undefined; - - const restMeta: string[] = []; - if (meta) { - for (const [key, value] of Object.entries(meta)) { - if (value === undefined) continue; - if (key === "connId" || key === "id") continue; - if (key === "method" || key === "ok") continue; - if (key === "event") continue; - restMeta.push(`${chalk.dim(key)}=${formatForLog(value)}`); - } - } - - const trailing: string[] = []; - if (connId && connId !== wsLastCompactConnId) { - trailing.push(`${chalk.dim("conn")}=${chalk.gray(shortId(connId))}`); - wsLastCompactConnId = connId; - } - if (id) trailing.push(`${chalk.dim("id")}=${chalk.gray(shortId(id))}`); - - const tokens = [ - prefix, - statusToken, - headline, - durationToken, - ...restMeta, - ...trailing, - ].filter((t): t is string => Boolean(t)); - - console.log(tokens.join(" ")); -} - -const UUID_RE = - /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; - -function shortId(value: string): string { - const s = value.trim(); - if (UUID_RE.test(s)) return `${s.slice(0, 8)}…${s.slice(-4)}`; - if (s.length <= 24) return s; - return `${s.slice(0, 12)}…${s.slice(-4)}`; -} - -const wsInflightSince = new Map(); - function formatError(err: unknown): string { if (err instanceof Error) return err.message; if (typeof err === "string") return err; @@ -1470,118 +688,6 @@ export async function startGatewayServer( wizardSessions.delete(id); }; - const normalizeHookHeaders = (req: IncomingMessage) => { - const headers: Record = {}; - for (const [key, value] of Object.entries(req.headers)) { - if (typeof value === "string") { - headers[key.toLowerCase()] = value; - } else if (Array.isArray(value) && value.length > 0) { - headers[key.toLowerCase()] = value.join(", "); - } - } - return headers; - }; - - const normalizeWakePayload = ( - payload: Record, - ): - | { ok: true; value: { text: string; mode: "now" | "next-heartbeat" } } - | { ok: false; error: string } => { - const text = typeof payload.text === "string" ? payload.text.trim() : ""; - if (!text) return { ok: false, error: "text required" }; - const mode = payload.mode === "next-heartbeat" ? "next-heartbeat" : "now"; - return { ok: true, value: { text, mode } }; - }; - - const normalizeAgentPayload = ( - payload: Record, - ): - | { - ok: true; - value: { - message: string; - name: string; - wakeMode: "now" | "next-heartbeat"; - sessionKey: string; - deliver: boolean; - channel: - | "last" - | "whatsapp" - | "telegram" - | "discord" - | "signal" - | "imessage"; - to?: string; - thinking?: string; - timeoutSeconds?: number; - }; - } - | { ok: false; error: string } => { - const message = - typeof payload.message === "string" ? payload.message.trim() : ""; - if (!message) return { ok: false, error: "message required" }; - const nameRaw = payload.name; - const name = - typeof nameRaw === "string" && nameRaw.trim() ? nameRaw.trim() : "Hook"; - const wakeMode = - payload.wakeMode === "next-heartbeat" ? "next-heartbeat" : "now"; - const sessionKeyRaw = payload.sessionKey; - const sessionKey = - typeof sessionKeyRaw === "string" && sessionKeyRaw.trim() - ? sessionKeyRaw.trim() - : `hook:${randomUUID()}`; - const channelRaw = payload.channel; - const channel = - channelRaw === "whatsapp" || - channelRaw === "telegram" || - channelRaw === "discord" || - channelRaw === "signal" || - channelRaw === "imessage" || - channelRaw === "last" - ? channelRaw - : channelRaw === "imsg" - ? "imessage" - : channelRaw === undefined - ? "last" - : null; - if (channel === null) { - return { - ok: false, - error: "channel must be last|whatsapp|telegram|discord|signal|imessage", - }; - } - const toRaw = payload.to; - const to = - typeof toRaw === "string" && toRaw.trim() ? toRaw.trim() : undefined; - const deliver = payload.deliver === true; - const thinkingRaw = payload.thinking; - const thinking = - typeof thinkingRaw === "string" && thinkingRaw.trim() - ? thinkingRaw.trim() - : undefined; - const timeoutRaw = payload.timeoutSeconds; - const timeoutSeconds = - typeof timeoutRaw === "number" && - Number.isFinite(timeoutRaw) && - timeoutRaw > 0 - ? Math.floor(timeoutRaw) - : undefined; - return { - ok: true, - value: { - message, - name, - wakeMode, - sessionKey, - deliver, - channel, - to, - thinking, - timeoutSeconds, - }, - }; - }; - const dispatchWakeHook = (value: { text: string; mode: "now" | "next-heartbeat"; diff --git a/src/gateway/session-utils.test.ts b/src/gateway/session-utils.test.ts new file mode 100644 index 000000000..5a6368c15 --- /dev/null +++ b/src/gateway/session-utils.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, test } from "vitest"; +import type { SessionEntry } from "../config/sessions.js"; +import { + capArrayByJsonBytes, + classifySessionKey, + parseGroupKey, +} from "./session-utils.js"; + +describe("gateway session utils", () => { + test("capArrayByJsonBytes trims from the front", () => { + const res = capArrayByJsonBytes(["a", "b", "c"], 10); + expect(res.items).toEqual(["b", "c"]); + }); + + test("parseGroupKey handles group prefixes", () => { + expect(parseGroupKey("group:abc")).toEqual({ id: "abc" }); + expect(parseGroupKey("discord:group:dev")).toEqual({ + surface: "discord", + kind: "group", + id: "dev", + }); + expect(parseGroupKey("foo:bar")).toBeNull(); + }); + + test("classifySessionKey respects chat type + prefixes", () => { + expect(classifySessionKey("global")).toBe("global"); + expect(classifySessionKey("unknown")).toBe("unknown"); + expect(classifySessionKey("group:abc")).toBe("group"); + expect(classifySessionKey("discord:group:dev")).toBe("group"); + expect(classifySessionKey("main")).toBe("direct"); + const entry = { chatType: "group" } as SessionEntry; + expect(classifySessionKey("main", entry)).toBe("group"); + }); +}); diff --git a/src/gateway/session-utils.ts b/src/gateway/session-utils.ts new file mode 100644 index 000000000..cc8c51716 --- /dev/null +++ b/src/gateway/session-utils.ts @@ -0,0 +1,300 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { lookupContextTokens } from "../agents/context.js"; +import { + DEFAULT_CONTEXT_TOKENS, + DEFAULT_MODEL, + DEFAULT_PROVIDER, +} from "../agents/defaults.js"; +import { resolveConfiguredModelRef } from "../agents/model-selection.js"; +import { type ClawdisConfig, loadConfig } from "../config/config.js"; +import { + buildGroupDisplayName, + loadSessionStore, + resolveStorePath, + type SessionEntry, +} from "../config/sessions.js"; + +export type GatewaySessionsDefaults = { + model: string | null; + contextTokens: number | null; +}; + +export type GatewaySessionRow = { + key: string; + kind: "direct" | "group" | "global" | "unknown"; + displayName?: string; + surface?: string; + subject?: string; + room?: string; + space?: string; + updatedAt: number | null; + sessionId?: string; + systemSent?: boolean; + abortedLastRun?: boolean; + thinkingLevel?: string; + verboseLevel?: string; + inputTokens?: number; + outputTokens?: number; + totalTokens?: number; + model?: string; + contextTokens?: number; +}; + +export type SessionsListResult = { + ts: number; + path: string; + count: number; + defaults: GatewaySessionsDefaults; + sessions: GatewaySessionRow[]; +}; + +export type SessionsPatchResult = { + ok: true; + path: string; + key: string; + entry: SessionEntry; +}; + +export function readSessionMessages( + sessionId: string, + storePath: string | undefined, +): unknown[] { + const candidates = resolveSessionTranscriptCandidates(sessionId, storePath); + + const filePath = candidates.find((p) => fs.existsSync(p)); + if (!filePath) return []; + + const lines = fs.readFileSync(filePath, "utf-8").split(/\r?\n/); + const messages: unknown[] = []; + for (const line of lines) { + if (!line.trim()) continue; + try { + const parsed = JSON.parse(line); + if (parsed?.message) { + messages.push(parsed.message); + } + } catch { + // ignore bad lines + } + } + return messages; +} + +export function resolveSessionTranscriptCandidates( + sessionId: string, + storePath: string | undefined, +): string[] { + const candidates: string[] = []; + if (storePath) { + const dir = path.dirname(storePath); + candidates.push(path.join(dir, `${sessionId}.jsonl`)); + } + candidates.push( + path.join(os.homedir(), ".clawdis", "sessions", `${sessionId}.jsonl`), + ); + return candidates; +} + +export function archiveFileOnDisk(filePath: string, reason: string): string { + const ts = new Date().toISOString().replaceAll(":", "-"); + const archived = `${filePath}.${reason}.${ts}`; + fs.renameSync(filePath, archived); + return archived; +} + +function jsonUtf8Bytes(value: unknown): number { + try { + return Buffer.byteLength(JSON.stringify(value), "utf8"); + } catch { + return Buffer.byteLength(String(value), "utf8"); + } +} + +export function capArrayByJsonBytes( + items: T[], + maxBytes: number, +): { items: T[]; bytes: number } { + if (items.length === 0) return { items, bytes: 2 }; + const parts = items.map((item) => jsonUtf8Bytes(item)); + let bytes = 2 + parts.reduce((a, b) => a + b, 0) + (items.length - 1); + let start = 0; + while (bytes > maxBytes && start < items.length - 1) { + bytes -= parts[start] + 1; + start += 1; + } + const next = start > 0 ? items.slice(start) : items; + return { items: next, bytes }; +} + +export function loadSessionEntry(sessionKey: string) { + const cfg = loadConfig(); + const sessionCfg = cfg.session; + const storePath = sessionCfg?.store + ? resolveStorePath(sessionCfg.store) + : resolveStorePath(undefined); + const store = loadSessionStore(storePath); + const entry = store[sessionKey]; + return { cfg, storePath, store, entry }; +} + +export function classifySessionKey( + key: string, + entry?: SessionEntry, +): GatewaySessionRow["kind"] { + if (key === "global") return "global"; + if (key === "unknown") return "unknown"; + if (entry?.chatType === "group" || entry?.chatType === "room") return "group"; + if ( + key.startsWith("group:") || + key.includes(":group:") || + key.includes(":channel:") + ) { + return "group"; + } + return "direct"; +} + +export function parseGroupKey( + key: string, +): { surface?: string; kind?: "group" | "channel"; id?: string } | null { + if (key.startsWith("group:")) { + const raw = key.slice("group:".length); + return raw ? { id: raw } : null; + } + const parts = key.split(":").filter(Boolean); + if (parts.length >= 3) { + const [surface, kind, ...rest] = parts; + if (kind === "group" || kind === "channel") { + const id = rest.join(":"); + return { surface, kind, id }; + } + } + return null; +} + +export function getSessionDefaults( + cfg: ClawdisConfig, +): GatewaySessionsDefaults { + const resolved = resolveConfiguredModelRef({ + cfg, + defaultProvider: DEFAULT_PROVIDER, + defaultModel: DEFAULT_MODEL, + }); + const contextTokens = + cfg.agent?.contextTokens ?? + lookupContextTokens(resolved.model) ?? + DEFAULT_CONTEXT_TOKENS; + return { + model: resolved.model ?? null, + contextTokens: contextTokens ?? null, + }; +} + +export function resolveSessionModelRef( + cfg: ClawdisConfig, + entry?: SessionEntry, +): { provider: string; model: string } { + const resolved = resolveConfiguredModelRef({ + cfg, + defaultProvider: DEFAULT_PROVIDER, + defaultModel: DEFAULT_MODEL, + }); + let provider = resolved.provider; + let model = resolved.model; + const storedModelOverride = entry?.modelOverride?.trim(); + if (storedModelOverride) { + provider = entry?.providerOverride?.trim() || provider; + model = storedModelOverride; + } + return { provider, model }; +} + +export function listSessionsFromStore(params: { + cfg: ClawdisConfig; + storePath: string; + store: Record; + opts: import("./protocol/index.js").SessionsListParams; +}): SessionsListResult { + const { cfg, storePath, store, opts } = params; + const now = Date.now(); + + const includeGlobal = opts.includeGlobal === true; + const includeUnknown = opts.includeUnknown === true; + const activeMinutes = + typeof opts.activeMinutes === "number" && + Number.isFinite(opts.activeMinutes) + ? Math.max(1, Math.floor(opts.activeMinutes)) + : undefined; + + let sessions = Object.entries(store) + .filter(([key]) => { + if (!includeGlobal && key === "global") return false; + if (!includeUnknown && key === "unknown") return false; + return true; + }) + .map(([key, entry]) => { + const updatedAt = entry?.updatedAt ?? null; + const input = entry?.inputTokens ?? 0; + const output = entry?.outputTokens ?? 0; + const total = entry?.totalTokens ?? input + output; + const parsed = parseGroupKey(key); + const surface = entry?.surface ?? parsed?.surface; + const subject = entry?.subject; + const room = entry?.room; + const space = entry?.space; + const id = parsed?.id; + const displayName = + entry?.displayName ?? + (surface + ? buildGroupDisplayName({ + surface, + subject, + room, + space, + id, + key, + }) + : undefined); + return { + key, + kind: classifySessionKey(key, entry), + displayName, + surface, + subject, + room, + space, + updatedAt, + sessionId: entry?.sessionId, + systemSent: entry?.systemSent, + abortedLastRun: entry?.abortedLastRun, + thinkingLevel: entry?.thinkingLevel, + verboseLevel: entry?.verboseLevel, + inputTokens: entry?.inputTokens, + outputTokens: entry?.outputTokens, + totalTokens: total, + model: entry?.model, + contextTokens: entry?.contextTokens, + } satisfies GatewaySessionRow; + }) + .sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0)); + + if (activeMinutes !== undefined) { + const cutoff = now - activeMinutes * 60_000; + sessions = sessions.filter((s) => (s.updatedAt ?? 0) >= cutoff); + } + + if (typeof opts.limit === "number" && Number.isFinite(opts.limit)) { + const limit = Math.max(1, Math.floor(opts.limit)); + sessions = sessions.slice(0, limit); + } + + return { + ts: now, + path: storePath, + count: sessions.length, + defaults: getSessionDefaults(cfg), + sessions, + }; +} diff --git a/src/gateway/test-helpers.ts b/src/gateway/test-helpers.ts new file mode 100644 index 000000000..c7287ec41 --- /dev/null +++ b/src/gateway/test-helpers.ts @@ -0,0 +1,511 @@ +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, expect, vi } from "vitest"; +import { WebSocket } from "ws"; +import { agentCommand } from "../commands/agent.js"; +import { resetAgentRunContextForTest } from "../infra/agent-events.js"; +import { drainSystemEvents, peekSystemEvents } from "../infra/system-events.js"; +import { rawDataToString } from "../infra/ws.js"; +import { PROTOCOL_VERSION } from "./protocol/index.js"; +import type { GatewayServerOptions } from "./server.js"; + +export type BridgeClientInfo = { + nodeId: string; + displayName?: string; + platform?: string; + version?: string; + remoteIp?: string; + deviceFamily?: string; + modelIdentifier?: string; + caps?: string[]; + commands?: string[]; +}; + +export 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 } } + >; +}; + +export const bridgeStartCalls = vi.hoisted(() => [] as BridgeStartOpts[]); +export const bridgeInvoke = vi.hoisted(() => + vi.fn(async () => ({ + type: "invoke-res", + id: "1", + ok: true, + payloadJSON: JSON.stringify({ ok: true }), + error: null, + })), +); +export const bridgeListConnected = vi.hoisted(() => + vi.fn(() => [] as BridgeClientInfo[]), +); +export const bridgeSendEvent = vi.hoisted(() => vi.fn()); +export const testTailnetIPv4 = vi.hoisted(() => ({ + value: undefined as string | undefined, +})); + +export const piSdkMock = vi.hoisted(() => ({ + enabled: false, + discoverCalls: 0, + models: [] as Array<{ + id: string; + name?: string; + provider: string; + contextWindow?: number; + reasoning?: boolean; + }>, +})); + +export const cronIsolatedRun = vi.hoisted(() => + vi.fn(async () => ({ status: "ok", summary: "ok" })), +); + +export const testState = { + sessionStorePath: undefined as string | undefined, + allowFrom: undefined as string[] | undefined, + cronStorePath: undefined as string | undefined, + cronEnabled: false as boolean | undefined, + gatewayBind: undefined as "auto" | "lan" | "tailnet" | "loopback" | undefined, + gatewayAuth: undefined as Record | undefined, + hooksConfig: undefined as Record | undefined, + canvasHostPort: undefined as number | undefined, + legacyIssues: [] as Array<{ path: string; message: string }>, + legacyParsed: {} as Record, + migrationConfig: null as Record | null, + migrationChanges: [] as string[], +}; + +export const testIsNixMode = vi.hoisted(() => ({ value: false })); +export const sessionStoreSaveDelayMs = vi.hoisted(() => ({ value: 0 })); + +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, +})); + +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", async () => { + const actual = await vi.importActual( + "../config/config.js", + ); + const resolveConfigPath = () => + path.join(os.homedir(), ".clawdis", "clawdis.json"); + + const readConfigFileSnapshot = async () => { + if (testState.legacyIssues.length > 0) { + return { + path: resolveConfigPath(), + exists: true, + raw: JSON.stringify(testState.legacyParsed ?? {}), + parsed: testState.legacyParsed ?? {}, + valid: false, + config: {}, + issues: testState.legacyIssues.map((issue) => ({ + path: issue.path, + message: issue.message, + })), + legacyIssues: testState.legacyIssues, + }; + } + 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 { + ...actual, + CONFIG_PATH_CLAWDIS: resolveConfigPath(), + STATE_DIR_CLAWDIS: path.dirname(resolveConfigPath()), + get isNixMode() { + return testIsNixMode.value; + }, + migrateLegacyConfig: (raw: unknown) => ({ + config: testState.migrationConfig ?? (raw as Record), + changes: testState.migrationChanges, + }), + loadConfig: () => ({ + agent: { + model: "anthropic/claude-opus-4-5", + workspace: path.join(os.tmpdir(), "clawd-gateway-test"), + }, + whatsapp: { + allowFrom: testState.allowFrom, + }, + session: { mainKey: "main", store: testState.sessionStorePath }, + gateway: (() => { + const gateway: Record = {}; + if (testState.gatewayBind) gateway.bind = testState.gatewayBind; + if (testState.gatewayAuth) gateway.auth = testState.gatewayAuth; + return Object.keys(gateway).length > 0 ? gateway : undefined; + })(), + canvasHost: (() => { + const canvasHost: Record = {}; + if (typeof testState.canvasHostPort === "number") + canvasHost.port = testState.canvasHostPort; + return Object.keys(canvasHost).length > 0 ? canvasHost : undefined; + })(), + hooks: testState.hooksConfig, + cron: (() => { + const cron: Record = {}; + if (typeof testState.cronEnabled === "boolean") + cron.enabled = testState.cronEnabled; + if (typeof testState.cronStorePath === "string") + cron.store = testState.cronStorePath; + 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; + +export function installGatewayTestHooks() { + 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; + testState.gatewayBind = undefined; + testState.gatewayAuth = undefined; + testState.hooksConfig = undefined; + testState.canvasHostPort = undefined; + testState.legacyIssues = []; + testState.legacyParsed = {}; + testState.migrationConfig = null; + testState.migrationChanges = []; + testState.cronEnabled = false; + testState.cronStorePath = undefined; + testState.sessionStorePath = undefined; + testState.allowFrom = undefined; + testIsNixMode.value = false; + cronIsolatedRun.mockClear(); + drainSystemEvents(); + resetAgentRunContextForTest(); + const mod = await import("./server.js"); + mod.__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; + } + }); +} + +export 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))); + }); + }); +} + +export 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 }); + }); + }); +} + +export 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); + }); +} + +export async function startGatewayServer( + port: number, + opts?: GatewayServerOptions, +) { + const mod = await import("./server.js"); + return await mod.startGatewayServer(port, opts); +} + +export async function startServerWithClient( + token?: string, + opts?: GatewayServerOptions, +) { + 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, opts); + 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 }; +}; + +export 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 { randomUUID } = await import("node:crypto"); + 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, + ); +} + +export 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" }; +} + +export async function rpcReq( + ws: WebSocket, + method: string, + params?: unknown, +) { + const { randomUUID } = await import("node:crypto"); + 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; code?: string }; + }>(ws, (o) => o.type === "res" && o.id === id); +} + +export 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"); +} + +export { agentCommand }; diff --git a/src/gateway/ws-log.test.ts b/src/gateway/ws-log.test.ts new file mode 100644 index 000000000..7a16cd06f --- /dev/null +++ b/src/gateway/ws-log.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, test } from "vitest"; +import { + formatForLog, + shortId, + summarizeAgentEventForWsLog, +} from "./ws-log.js"; + +describe("gateway ws log helpers", () => { + test("shortId compacts uuids and long strings", () => { + expect(shortId("12345678-1234-1234-1234-123456789abc")).toBe( + "12345678…9abc", + ); + expect(shortId("a".repeat(30))).toBe("aaaaaaaaaaaa…aaaa"); + expect(shortId("short")).toBe("short"); + }); + + test("formatForLog formats errors and messages", () => { + const err = new Error("boom"); + err.name = "TestError"; + expect(formatForLog(err)).toContain("TestError"); + expect(formatForLog(err)).toContain("boom"); + + const obj = { name: "Oops", message: "failed", code: "E1" }; + expect(formatForLog(obj)).toBe("Oops: failed: code=E1"); + }); + + test("summarizeAgentEventForWsLog extracts useful fields", () => { + const summary = summarizeAgentEventForWsLog({ + runId: "12345678-1234-1234-1234-123456789abc", + stream: "assistant", + seq: 2, + data: { text: "hello world", mediaUrls: ["a", "b"] }, + }); + expect(summary).toMatchObject({ + run: "12345678…9abc", + stream: "assistant", + aseq: 2, + text: "hello world", + media: 2, + }); + + const tool = summarizeAgentEventForWsLog({ + runId: "run-1", + stream: "tool", + data: { phase: "start", name: "fetch", toolCallId: "call-1" }, + }); + expect(tool).toMatchObject({ + stream: "tool", + tool: "start:fetch", + call: "call-1", + }); + }); +}); diff --git a/src/gateway/ws-log.ts b/src/gateway/ws-log.ts new file mode 100644 index 000000000..57216604c --- /dev/null +++ b/src/gateway/ws-log.ts @@ -0,0 +1,392 @@ +import chalk from "chalk"; +import { isVerbose } from "../globals.js"; +import { DEFAULT_WS_SLOW_MS, getGatewayWsLogStyle } from "./ws-logging.js"; + +const LOG_VALUE_LIMIT = 240; +const UUID_RE = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +type WsInflightEntry = { + ts: number; + method?: string; + meta?: Record; +}; + +const wsInflightCompact = new Map(); +let wsLastCompactConnId: string | undefined; +const wsInflightOptimized = new Map(); +const wsInflightSince = new Map(); + +export function shortId(value: string): string { + const s = value.trim(); + if (UUID_RE.test(s)) return `${s.slice(0, 8)}…${s.slice(-4)}`; + if (s.length <= 24) return s; + return `${s.slice(0, 12)}…${s.slice(-4)}`; +} + +export function formatForLog(value: unknown): string { + try { + if (value instanceof Error) { + const parts: string[] = []; + if (value.name) parts.push(value.name); + if (value.message) parts.push(value.message); + const code = + "code" in value && + (typeof value.code === "string" || typeof value.code === "number") + ? String(value.code) + : ""; + if (code) parts.push(`code=${code}`); + const combined = parts.filter(Boolean).join(": ").trim(); + if (combined) { + return combined.length > LOG_VALUE_LIMIT + ? `${combined.slice(0, LOG_VALUE_LIMIT)}...` + : combined; + } + } + if (value && typeof value === "object") { + const rec = value as Record; + if (typeof rec.message === "string" && rec.message.trim()) { + const name = typeof rec.name === "string" ? rec.name.trim() : ""; + const code = + typeof rec.code === "string" || typeof rec.code === "number" + ? String(rec.code) + : ""; + const parts = [name, rec.message.trim()].filter(Boolean); + if (code) parts.push(`code=${code}`); + const combined = parts.join(": ").trim(); + return combined.length > LOG_VALUE_LIMIT + ? `${combined.slice(0, LOG_VALUE_LIMIT)}...` + : combined; + } + } + const str = + typeof value === "string" || typeof value === "number" + ? String(value) + : JSON.stringify(value); + if (!str) return ""; + return str.length > LOG_VALUE_LIMIT + ? `${str.slice(0, LOG_VALUE_LIMIT)}...` + : str; + } catch { + return String(value); + } +} + +function compactPreview(input: string, maxLen = 160): string { + const oneLine = input.replace(/\s+/g, " ").trim(); + if (oneLine.length <= maxLen) return oneLine; + return `${oneLine.slice(0, Math.max(0, maxLen - 1))}…`; +} + +export function summarizeAgentEventForWsLog( + payload: unknown, +): Record { + if (!payload || typeof payload !== "object") return {}; + const rec = payload as Record; + const runId = typeof rec.runId === "string" ? rec.runId : undefined; + const stream = typeof rec.stream === "string" ? rec.stream : undefined; + const seq = typeof rec.seq === "number" ? rec.seq : undefined; + const data = + rec.data && typeof rec.data === "object" + ? (rec.data as Record) + : undefined; + + const extra: Record = {}; + if (runId) extra.run = shortId(runId); + if (stream) extra.stream = stream; + if (seq !== undefined) extra.aseq = seq; + + if (!data) return extra; + + if (stream === "assistant") { + const text = typeof data.text === "string" ? data.text : undefined; + if (text?.trim()) extra.text = compactPreview(text); + const mediaUrls = Array.isArray(data.mediaUrls) + ? data.mediaUrls + : undefined; + if (mediaUrls && mediaUrls.length > 0) extra.media = mediaUrls.length; + return extra; + } + + if (stream === "tool") { + const phase = typeof data.phase === "string" ? data.phase : undefined; + const name = typeof data.name === "string" ? data.name : undefined; + if (phase || name) extra.tool = `${phase ?? "?"}:${name ?? "?"}`; + const toolCallId = + typeof data.toolCallId === "string" ? data.toolCallId : undefined; + if (toolCallId) extra.call = shortId(toolCallId); + const meta = typeof data.meta === "string" ? data.meta : undefined; + if (meta?.trim()) extra.meta = meta; + if (typeof data.isError === "boolean") extra.err = data.isError; + return extra; + } + + if (stream === "job") { + const state = typeof data.state === "string" ? data.state : undefined; + if (state) extra.state = state; + if (data.to === null) extra.to = null; + else if (typeof data.to === "string") extra.to = data.to; + if (typeof data.durationMs === "number") + extra.ms = Math.round(data.durationMs); + if (typeof data.aborted === "boolean") extra.aborted = data.aborted; + const error = typeof data.error === "string" ? data.error : undefined; + if (error?.trim()) extra.error = compactPreview(error, 120); + return extra; + } + + const reason = typeof data.reason === "string" ? data.reason : undefined; + if (reason?.trim()) extra.reason = reason; + return extra; +} + +export function logWs( + direction: "in" | "out", + kind: string, + meta?: Record, +) { + const style = getGatewayWsLogStyle(); + if (!isVerbose()) { + logWsOptimized(direction, kind, meta); + return; + } + + if (style === "compact" || style === "auto") { + logWsCompact(direction, kind, meta); + return; + } + + const now = Date.now(); + const connId = typeof meta?.connId === "string" ? meta.connId : undefined; + const id = typeof meta?.id === "string" ? meta.id : undefined; + const method = typeof meta?.method === "string" ? meta.method : undefined; + const ok = typeof meta?.ok === "boolean" ? meta.ok : undefined; + const event = typeof meta?.event === "string" ? meta.event : undefined; + + const inflightKey = connId && id ? `${connId}:${id}` : undefined; + if (direction === "in" && kind === "req" && inflightKey) { + wsInflightSince.set(inflightKey, now); + } + const durationMs = + direction === "out" && kind === "res" && inflightKey + ? (() => { + const startedAt = wsInflightSince.get(inflightKey); + if (startedAt === undefined) return undefined; + wsInflightSince.delete(inflightKey); + return now - startedAt; + })() + : undefined; + + const dirArrow = direction === "in" ? "←" : "→"; + const dirColor = direction === "in" ? chalk.greenBright : chalk.cyanBright; + const prefix = `${chalk.gray("[gws]")} ${dirColor(dirArrow)} ${chalk.bold(kind)}`; + + const headline = + (kind === "req" || kind === "res") && method + ? chalk.bold(method) + : kind === "event" && event + ? chalk.bold(event) + : undefined; + + const statusToken = + kind === "res" && ok !== undefined + ? ok + ? chalk.greenBright("✓") + : chalk.redBright("✗") + : undefined; + + const durationToken = + typeof durationMs === "number" ? chalk.dim(`${durationMs}ms`) : undefined; + + const restMeta: string[] = []; + if (meta) { + for (const [key, value] of Object.entries(meta)) { + if (value === undefined) continue; + if (key === "connId" || key === "id") continue; + if (key === "method" || key === "ok") continue; + if (key === "event") continue; + restMeta.push(`${chalk.dim(key)}=${formatForLog(value)}`); + } + } + + const trailing: string[] = []; + if (connId) { + trailing.push(`${chalk.dim("conn")}=${chalk.gray(shortId(connId))}`); + } + if (id) trailing.push(`${chalk.dim("id")}=${chalk.gray(shortId(id))}`); + + const tokens = [ + prefix, + statusToken, + headline, + durationToken, + ...restMeta, + ...trailing, + ].filter((t): t is string => Boolean(t)); + + console.log(tokens.join(" ")); +} + +function logWsOptimized( + direction: "in" | "out", + kind: string, + meta?: Record, +) { + const connId = typeof meta?.connId === "string" ? meta.connId : undefined; + const id = typeof meta?.id === "string" ? meta.id : undefined; + const ok = typeof meta?.ok === "boolean" ? meta.ok : undefined; + const method = typeof meta?.method === "string" ? meta.method : undefined; + + const inflightKey = connId && id ? `${connId}:${id}` : undefined; + + if (direction === "in" && kind === "req" && inflightKey) { + wsInflightOptimized.set(inflightKey, Date.now()); + if (wsInflightOptimized.size > 2000) wsInflightOptimized.clear(); + return; + } + + if (kind === "parse-error") { + const errorMsg = + typeof meta?.error === "string" ? formatForLog(meta.error) : undefined; + console.log( + [ + `${chalk.gray("[gws]")} ${chalk.redBright("✗")} ${chalk.bold("parse-error")}`, + errorMsg ? `${chalk.dim("error")}=${errorMsg}` : undefined, + `${chalk.dim("conn")}=${chalk.gray(shortId(connId ?? "?"))}`, + ] + .filter((t): t is string => Boolean(t)) + .join(" "), + ); + return; + } + + if (direction !== "out" || kind !== "res") return; + + const startedAt = inflightKey + ? wsInflightOptimized.get(inflightKey) + : undefined; + if (inflightKey) wsInflightOptimized.delete(inflightKey); + const durationMs = + typeof startedAt === "number" ? Date.now() - startedAt : undefined; + + const shouldLog = + ok === false || + (typeof durationMs === "number" && durationMs >= DEFAULT_WS_SLOW_MS); + if (!shouldLog) return; + + const statusToken = + ok === undefined + ? undefined + : ok + ? chalk.greenBright("✓") + : chalk.redBright("✗"); + const durationToken = + typeof durationMs === "number" ? chalk.dim(`${durationMs}ms`) : undefined; + + const restMeta: string[] = []; + if (meta) { + for (const [key, value] of Object.entries(meta)) { + if (value === undefined) continue; + if (key === "connId" || key === "id") continue; + if (key === "method" || key === "ok") continue; + restMeta.push(`${chalk.dim(key)}=${formatForLog(value)}`); + } + } + + const tokens = [ + `${chalk.gray("[gws]")} ${chalk.yellowBright("⇄")} ${chalk.bold("res")}`, + statusToken, + method ? chalk.bold(method) : undefined, + durationToken, + ...restMeta, + connId ? `${chalk.dim("conn")}=${chalk.gray(shortId(connId))}` : undefined, + id ? `${chalk.dim("id")}=${chalk.gray(shortId(id))}` : undefined, + ].filter((t): t is string => Boolean(t)); + + console.log(tokens.join(" ")); +} + +function logWsCompact( + direction: "in" | "out", + kind: string, + meta?: Record, +) { + const now = Date.now(); + const connId = typeof meta?.connId === "string" ? meta.connId : undefined; + const id = typeof meta?.id === "string" ? meta.id : undefined; + const method = typeof meta?.method === "string" ? meta.method : undefined; + const ok = typeof meta?.ok === "boolean" ? meta.ok : undefined; + const inflightKey = connId && id ? `${connId}:${id}` : undefined; + + if (kind === "req" && direction === "in" && inflightKey) { + wsInflightCompact.set(inflightKey, { ts: now, method, meta }); + return; + } + + const compactArrow = (() => { + if (kind === "req" || kind === "res") return "⇄"; + return direction === "in" ? "←" : "→"; + })(); + const arrowColor = + kind === "req" || kind === "res" + ? chalk.yellowBright + : direction === "in" + ? chalk.greenBright + : chalk.cyanBright; + + const prefix = `${chalk.gray("[gws]")} ${arrowColor(compactArrow)} ${chalk.bold(kind)}`; + + const statusToken = + kind === "res" && ok !== undefined + ? ok + ? chalk.greenBright("✓") + : chalk.redBright("✗") + : undefined; + + const startedAt = + kind === "res" && direction === "out" && inflightKey + ? wsInflightCompact.get(inflightKey)?.ts + : undefined; + if (kind === "res" && direction === "out" && inflightKey) { + wsInflightCompact.delete(inflightKey); + } + const durationToken = + typeof startedAt === "number" + ? chalk.dim(`${now - startedAt}ms`) + : undefined; + + const headline = + (kind === "req" || kind === "res") && method + ? chalk.bold(method) + : kind === "event" && typeof meta?.event === "string" + ? chalk.bold(meta.event) + : undefined; + + const restMeta: string[] = []; + if (meta) { + for (const [key, value] of Object.entries(meta)) { + if (value === undefined) continue; + if (key === "connId" || key === "id") continue; + if (key === "method" || key === "ok") continue; + if (key === "event") continue; + restMeta.push(`${chalk.dim(key)}=${formatForLog(value)}`); + } + } + + const trailing: string[] = []; + if (connId && connId !== wsLastCompactConnId) { + trailing.push(`${chalk.dim("conn")}=${chalk.gray(shortId(connId))}`); + wsLastCompactConnId = connId; + } + if (id) trailing.push(`${chalk.dim("id")}=${chalk.gray(shortId(id))}`); + + const tokens = [ + prefix, + statusToken, + headline, + durationToken, + ...restMeta, + ...trailing, + ].filter((t): t is string => Boolean(t)); + + console.log(tokens.join(" ")); +}