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 };