Files
clawdbot/src/gateway/server.test.ts
2026-01-02 13:09:41 +01:00

4283 lines
121 KiB
TypeScript

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> | void;
onDisconnected?: (node: BridgeClientInfo) => Promise<void> | void;
onPairRequested?: (request: unknown) => Promise<void> | void;
onEvent?: (
nodeId: string,
evt: { event: string; payloadJSON?: string | null },
) => Promise<void> | void;
onRequest?: (
nodeId: string,
req: { id: string; method: string; paramsJSON?: string | null },
) => Promise<
| { ok: true; payloadJSON?: string | null }
| { ok: false; error: { code: string; message: string; details?: unknown } }
>;
};
const bridgeStartCalls = vi.hoisted(() => [] as BridgeStartOpts[]);
const bridgeInvoke = vi.hoisted(() =>
vi.fn(async () => ({
type: "invoke-res",
id: "1",
ok: true,
payloadJSON: JSON.stringify({ ok: true }),
error: null,
})),
);
const bridgeListConnected = vi.hoisted(() =>
vi.fn(() => [] as BridgeClientInfo[]),
);
const bridgeSendEvent = vi.hoisted(() => vi.fn());
const testTailnetIPv4 = vi.hoisted(() => ({
value: undefined as string | undefined,
}));
const piSdkMock = vi.hoisted(() => ({
enabled: false,
discoverCalls: 0,
models: [] as Array<{
id: string;
name?: string;
provider: string;
contextWindow?: number;
}>,
}));
const cronIsolatedRun = vi.hoisted(() =>
vi.fn(async () => ({ status: "ok", summary: "ok" })),
);
vi.mock("@mariozechner/pi-coding-agent", async () => {
const actual = await vi.importActual<
typeof import("@mariozechner/pi-coding-agent")
>("@mariozechner/pi-coding-agent");
return {
...actual,
discoverModels: () => {
if (!piSdkMock.enabled) return actual.discoverModels();
piSdkMock.discoverCalls += 1;
return piSdkMock.models;
},
};
});
vi.mock("../infra/bridge/server.js", () => ({
startNodeBridgeServer: vi.fn(async (opts: BridgeStartOpts) => {
bridgeStartCalls.push(opts);
return {
port: 18790,
close: async () => {},
listConnected: bridgeListConnected,
invoke: bridgeInvoke,
sendEvent: bridgeSendEvent,
};
}),
}));
vi.mock("../cron/isolated-agent.js", () => ({
runCronIsolatedAgentTurn: (...args: unknown[]) => cronIsolatedRun(...args),
}));
vi.mock("../infra/tailnet.js", () => ({
pickPrimaryTailnetIPv4: () => testTailnetIPv4.value,
pickPrimaryTailnetIPv6: () => undefined,
}));
let testSessionStorePath: string | undefined;
let testAllowFrom: string[] | undefined;
let testCronStorePath: string | undefined;
let testCronEnabled: boolean | undefined = false;
let testGatewayBind: "auto" | "lan" | "tailnet" | "loopback" | undefined;
let testGatewayAuth: Record<string, unknown> | undefined;
let testHooksConfig: Record<string, unknown> | undefined;
let testCanvasHostPort: number | undefined;
let testLegacyIssues: Array<{ path: string; message: string }> = [];
let testLegacyParsed: Record<string, unknown> = {};
let testMigrationConfig: Record<string, unknown> | null = null;
let testMigrationChanges: string[] = [];
let testIsNixMode = false;
const sessionStoreSaveDelayMs = vi.hoisted(() => ({ value: 0 }));
vi.mock("../config/sessions.js", async () => {
const actual = await vi.importActual<typeof import("../config/sessions.js")>(
"../config/sessions.js",
);
return {
...actual,
saveSessionStore: vi.fn(async (storePath: string, store: unknown) => {
const delay = sessionStoreSaveDelayMs.value;
if (delay > 0) {
await new Promise((resolve) => setTimeout(resolve, delay));
}
return actual.saveSessionStore(storePath, store as never);
}),
};
});
vi.mock("../config/config.js", () => {
const resolveConfigPath = () =>
path.join(os.homedir(), ".clawdis", "clawdis.json");
const readConfigFileSnapshot = async () => {
if (testLegacyIssues.length > 0) {
return {
path: resolveConfigPath(),
exists: true,
raw: JSON.stringify(testLegacyParsed ?? {}),
parsed: testLegacyParsed ?? {},
valid: false,
config: {},
issues: testLegacyIssues.map((issue) => ({
path: issue.path,
message: issue.message,
})),
legacyIssues: testLegacyIssues,
};
}
const configPath = resolveConfigPath();
try {
await fs.access(configPath);
} catch {
return {
path: configPath,
exists: false,
raw: null,
parsed: {},
valid: true,
config: {},
issues: [],
legacyIssues: [],
};
}
try {
const raw = await fs.readFile(configPath, "utf-8");
const parsed = JSON.parse(raw) as Record<string, unknown>;
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<string, unknown>) => {
const configPath = resolveConfigPath();
await fs.mkdir(path.dirname(configPath), { recursive: true });
const raw = JSON.stringify(cfg, null, 2).trimEnd().concat("\n");
await fs.writeFile(configPath, raw, "utf-8");
});
return {
CONFIG_PATH_CLAWDIS: resolveConfigPath(),
STATE_DIR_CLAWDIS: path.dirname(resolveConfigPath()),
isNixMode: testIsNixMode,
migrateLegacyConfig: (raw: unknown) => ({
config: testMigrationConfig ?? (raw as Record<string, unknown>),
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<string, unknown> = {};
if (testGatewayBind) gateway.bind = testGatewayBind;
if (testGatewayAuth) gateway.auth = testGatewayAuth;
return Object.keys(gateway).length > 0 ? gateway : undefined;
})(),
canvasHost: (() => {
const canvasHost: Record<string, unknown> = {};
if (typeof testCanvasHostPort === "number")
canvasHost.port = testCanvasHostPort;
return Object.keys(canvasHost).length > 0 ? canvasHost : undefined;
})(),
hooks: testHooksConfig,
cron: (() => {
const cron: Record<string, unknown> = {};
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<string, unknown>,
issues: [],
}),
readConfigFileSnapshot,
writeConfigFile,
};
});
vi.mock("../commands/health.js", () => ({
getHealthSnapshot: vi.fn().mockResolvedValue({ ok: true, stub: true }),
}));
vi.mock("../commands/status.js", () => ({
getStatusSummary: vi.fn().mockResolvedValue({ ok: true }),
}));
vi.mock("../web/outbound.js", () => ({
sendMessageWhatsApp: vi
.fn()
.mockResolvedValue({ messageId: "msg-1", toJid: "jid-1" }),
}));
vi.mock("../commands/agent.js", () => ({
agentCommand: vi.fn().mockResolvedValue(undefined),
}));
process.env.CLAWDIS_SKIP_PROVIDERS = "1";
let previousHome: string | undefined;
let tempHome: string | undefined;
beforeEach(async () => {
previousHome = process.env.HOME;
tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-gateway-home-"));
process.env.HOME = tempHome;
sessionStoreSaveDelayMs.value = 0;
testTailnetIPv4.value = undefined;
testGatewayBind = undefined;
testGatewayAuth = undefined;
testHooksConfig = undefined;
testCanvasHostPort = undefined;
testLegacyIssues = [];
testLegacyParsed = {};
testMigrationConfig = null;
testMigrationChanges = [];
testIsNixMode = false;
cronIsolatedRun.mockClear();
drainSystemEvents();
resetAgentRunContextForTest();
__resetModelCatalogCacheForTest();
piSdkMock.enabled = false;
piSdkMock.discoverCalls = 0;
piSdkMock.models = [];
});
afterEach(async () => {
process.env.HOME = previousHome;
if (tempHome) {
await fs.rm(tempHome, { recursive: true, force: true });
tempHome = undefined;
}
});
async function getFreePort(): Promise<number> {
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<typeof createServer>;
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<T = unknown>(
ws: WebSocket,
filter: (obj: unknown) => boolean,
timeoutMs = 3000,
): Promise<T> {
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error("timeout")), timeoutMs);
const closeHandler = (code: number, reason: Buffer) => {
clearTimeout(timer);
ws.off("message", handler);
reject(new Error(`closed ${code}: ${reason.toString()}`));
};
const handler = (data: WebSocket.RawData) => {
const obj = JSON.parse(rawDataToString(data));
if (filter(obj)) {
clearTimeout(timer);
ws.off("message", handler);
ws.off("close", closeHandler);
resolve(obj as T);
}
};
ws.on("message", handler);
ws.once("close", closeHandler);
});
}
async function startServerWithClient(token?: string) {
const port = await getFreePort();
const prev = process.env.CLAWDIS_GATEWAY_TOKEN;
if (token === undefined) {
delete process.env.CLAWDIS_GATEWAY_TOKEN;
} else {
process.env.CLAWDIS_GATEWAY_TOKEN = token;
}
const server = await startGatewayServer(port);
const ws = new WebSocket(`ws://127.0.0.1:${port}`);
await new Promise<void>((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<ConnectResponse> {
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<ConnectResponse>(
ws,
(o) => o.type === "res" && o.id === id,
);
}
async function connectOk(
ws: WebSocket,
opts?: Parameters<typeof connectReq>[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<T = unknown>(
ws: WebSocket,
method: string,
params?: unknown,
) {
const id = randomUUID();
ws.send(JSON.stringify({ type: "req", id, method, params }));
return await onceMessage<{
type: "res";
id: string;
ok: boolean;
payload?: T;
error?: { message?: string };
}>(ws, (o) => o.type === "res" && o.id === id);
}
async function waitForSystemEvent(timeoutMs = 2000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const events = peekSystemEvents();
if (events.length > 0) return events;
await new Promise((resolve) => setTimeout(resolve, 10));
}
throw new Error("timeout waiting for system event");
}
describe("gateway server", () => {
test(
"voicewake.get returns defaults and voicewake.set broadcasts",
{ timeout: 15_000 },
async () => {
const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-home-"));
const prevHome = process.env.HOME;
process.env.HOME = homeDir;
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const initial = await rpcReq<{ triggers: string[] }>(ws, "voicewake.get");
expect(initial.ok).toBe(true);
expect(initial.payload?.triggers).toEqual([
"clawd",
"claude",
"computer",
]);
const changedP = onceMessage<{
type: "event";
event: string;
payload?: unknown;
}>(ws, (o) => o.type === "event" && o.event === "voicewake.changed");
const setRes = await rpcReq<{ triggers: string[] }>(ws, "voicewake.set", {
triggers: [" hi ", "", "there"],
});
expect(setRes.ok).toBe(true);
expect(setRes.payload?.triggers).toEqual(["hi", "there"]);
const changed = await changedP;
expect(changed.event).toBe("voicewake.changed");
expect(
(changed.payload as { triggers?: unknown } | undefined)?.triggers,
).toEqual(["hi", "there"]);
const after = await rpcReq<{ triggers: string[] }>(ws, "voicewake.get");
expect(after.ok).toBe(true);
expect(after.payload?.triggers).toEqual(["hi", "there"]);
const onDisk = JSON.parse(
await fs.readFile(
path.join(homeDir, ".clawdis", "settings", "voicewake.json"),
"utf8",
),
) as { triggers?: unknown; updatedAtMs?: unknown };
expect(onDisk.triggers).toEqual(["hi", "there"]);
expect(typeof onDisk.updatedAtMs).toBe("number");
ws.close();
await server.close();
if (prevHome === undefined) {
delete process.env.HOME;
} else {
process.env.HOME = prevHome;
}
},
);
test("auto-migrates legacy config on startup", async () => {
(writeConfigFile as unknown as { mockClear?: () => void })?.mockClear?.();
testLegacyIssues = [
{
path: "routing.allowFrom",
message: "legacy",
},
];
testLegacyParsed = { routing: { allowFrom: ["+15555550123"] } };
testMigrationConfig = { whatsapp: { allowFrom: ["+15555550123"] } };
testMigrationChanges = ["Moved routing.allowFrom → whatsapp.allowFrom."];
const port = await getFreePort();
const server = await startGatewayServer(port);
expect(writeConfigFile).toHaveBeenCalledWith(testMigrationConfig);
await server.close();
});
test("fails in Nix mode when legacy config is present", async () => {
testLegacyIssues = [
{
path: "routing.allowFrom",
message: "legacy",
},
];
testLegacyParsed = { routing: { allowFrom: ["+15555550123"] } };
testIsNixMode = true;
const port = await getFreePort();
await expect(startGatewayServer(port)).rejects.toThrow(
"Legacy config entries detected while running in Nix mode",
);
});
test("models.list returns model catalog", async () => {
piSdkMock.enabled = true;
piSdkMock.models = [
{ id: "gpt-test-z", provider: "openai", contextWindow: 0 },
{
id: "gpt-test-a",
name: "A-Model",
provider: "openai",
contextWindow: 8000,
},
{
id: "claude-test-b",
name: "B-Model",
provider: "anthropic",
contextWindow: 1000,
},
{
id: "claude-test-a",
name: "A-Model",
provider: "anthropic",
contextWindow: 200_000,
},
];
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const res1 = await rpcReq<{
models: Array<{
id: string;
name: string;
provider: string;
contextWindow?: number;
}>;
}>(ws, "models.list");
const res2 = await rpcReq<{
models: Array<{
id: string;
name: string;
provider: string;
contextWindow?: number;
}>;
}>(ws, "models.list");
expect(res1.ok).toBe(true);
expect(res2.ok).toBe(true);
const models = res1.payload?.models ?? [];
expect(models).toEqual([
{
id: "claude-test-a",
name: "A-Model",
provider: "anthropic",
contextWindow: 200_000,
},
{
id: "claude-test-b",
name: "B-Model",
provider: "anthropic",
contextWindow: 1000,
},
{
id: "gpt-test-a",
name: "A-Model",
provider: "openai",
contextWindow: 8000,
},
{
id: "gpt-test-z",
name: "gpt-test-z",
provider: "openai",
},
]);
// Cached across requests: should only call discoverModels once.
expect(piSdkMock.discoverCalls).toBe(1);
ws.close();
await server.close();
});
test("models.list rejects unknown params", async () => {
piSdkMock.enabled = true;
piSdkMock.models = [{ id: "gpt-test-a", name: "A", provider: "openai" }];
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const res = await rpcReq(ws, "models.list", { extra: true });
expect(res.ok).toBe(false);
expect(res.error?.message ?? "").toMatch(/invalid models\.list params/i);
ws.close();
await server.close();
});
test("bridge RPC supports models.list and validates params", async () => {
piSdkMock.enabled = true;
piSdkMock.models = [{ id: "gpt-test-a", name: "A", provider: "openai" }];
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const startCall = bridgeStartCalls.at(-1);
expect(startCall).toBeTruthy();
const okRes = await startCall?.onRequest?.("n1", {
id: "1",
method: "models.list",
paramsJSON: "{}",
});
expect(okRes?.ok).toBe(true);
const okPayload = JSON.parse(String(okRes?.payloadJSON ?? "{}")) as {
models?: unknown;
};
expect(Array.isArray(okPayload.models)).toBe(true);
const badRes = await startCall?.onRequest?.("n1", {
id: "2",
method: "models.list",
paramsJSON: JSON.stringify({ extra: true }),
});
expect(badRes?.ok).toBe(false);
expect(badRes && "error" in badRes ? badRes.error.code : "").toBe(
"INVALID_REQUEST",
);
ws.close();
await server.close();
});
test("pushes voicewake.changed to nodes on connect and on updates", async () => {
const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-home-"));
const prevHome = process.env.HOME;
process.env.HOME = homeDir;
bridgeSendEvent.mockClear();
bridgeListConnected.mockReturnValue([{ nodeId: "n1" }]);
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const startCall = bridgeStartCalls.at(-1);
expect(startCall).toBeTruthy();
await startCall?.onAuthenticated?.({ nodeId: "n1" });
const first = bridgeSendEvent.mock.calls.find(
(c) => c[0]?.event === "voicewake.changed" && c[0]?.nodeId === "n1",
)?.[0] as { payloadJSON?: string | null } | undefined;
expect(first?.payloadJSON).toBeTruthy();
const firstPayload = JSON.parse(String(first?.payloadJSON)) as {
triggers?: unknown;
};
expect(firstPayload.triggers).toEqual(["clawd", "claude", "computer"]);
bridgeSendEvent.mockClear();
const setRes = await rpcReq<{ triggers: string[] }>(ws, "voicewake.set", {
triggers: ["clawd", "computer"],
});
expect(setRes.ok).toBe(true);
const broadcast = bridgeSendEvent.mock.calls.find(
(c) => c[0]?.event === "voicewake.changed" && c[0]?.nodeId === "n1",
)?.[0] as { payloadJSON?: string | null } | undefined;
expect(broadcast?.payloadJSON).toBeTruthy();
const broadcastPayload = JSON.parse(String(broadcast?.payloadJSON)) as {
triggers?: unknown;
};
expect(broadcastPayload.triggers).toEqual(["clawd", "computer"]);
ws.close();
await server.close();
if (prevHome === undefined) {
delete process.env.HOME;
} else {
process.env.HOME = prevHome;
}
});
test("supports gateway-owned node pairing methods and events", async () => {
const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-home-"));
const prevHome = process.env.HOME;
process.env.HOME = homeDir;
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const requestedP = onceMessage<{
type: "event";
event: string;
payload?: unknown;
}>(ws, (o) => o.type === "event" && o.event === "node.pair.requested");
ws.send(
JSON.stringify({
type: "req",
id: "pair-req-1",
method: "node.pair.request",
params: { nodeId: "n1", displayName: "Node" },
}),
);
const res1 = await onceMessage<{
type: "res";
ok: boolean;
payload?: unknown;
}>(ws, (o) => o.type === "res" && o.id === "pair-req-1");
expect(res1.ok).toBe(true);
const req1 = (res1.payload as { request?: { requestId?: unknown } } | null)
?.request;
const requestId = typeof req1?.requestId === "string" ? req1.requestId : "";
expect(requestId.length).toBeGreaterThan(0);
const evt1 = await requestedP;
expect(evt1.event).toBe("node.pair.requested");
expect((evt1.payload as { requestId?: unknown } | null)?.requestId).toBe(
requestId,
);
// Second request for same node should return the existing pending request
// without emitting a second requested event.
ws.send(
JSON.stringify({
type: "req",
id: "pair-req-2",
method: "node.pair.request",
params: { nodeId: "n1", displayName: "Node" },
}),
);
const res2 = await onceMessage<{
type: "res";
ok: boolean;
payload?: unknown;
}>(ws, (o) => o.type === "res" && o.id === "pair-req-2");
expect(res2.ok).toBe(true);
await expect(
onceMessage(
ws,
(o) => o.type === "event" && o.event === "node.pair.requested",
200,
),
).rejects.toThrow();
const resolvedP = onceMessage<{
type: "event";
event: string;
payload?: unknown;
}>(ws, (o) => o.type === "event" && o.event === "node.pair.resolved");
ws.send(
JSON.stringify({
type: "req",
id: "pair-approve-1",
method: "node.pair.approve",
params: { requestId },
}),
);
const approveRes = await onceMessage<{
type: "res";
ok: boolean;
payload?: unknown;
}>(ws, (o) => o.type === "res" && o.id === "pair-approve-1");
expect(approveRes.ok).toBe(true);
const tokenValue = (
approveRes.payload as { node?: { token?: unknown } } | null
)?.node?.token;
const token = typeof tokenValue === "string" ? tokenValue : "";
expect(token.length).toBeGreaterThan(0);
const evt2 = await resolvedP;
expect((evt2.payload as { requestId?: unknown } | null)?.requestId).toBe(
requestId,
);
expect((evt2.payload as { decision?: unknown } | null)?.decision).toBe(
"approved",
);
ws.send(
JSON.stringify({
type: "req",
id: "pair-verify-1",
method: "node.pair.verify",
params: { nodeId: "n1", token },
}),
);
const verifyRes = await onceMessage<{
type: "res";
ok: boolean;
payload?: unknown;
}>(ws, (o) => o.type === "res" && o.id === "pair-verify-1");
expect(verifyRes.ok).toBe(true);
expect((verifyRes.payload as { ok?: unknown } | null)?.ok).toBe(true);
ws.send(
JSON.stringify({
type: "req",
id: "pair-list-1",
method: "node.pair.list",
params: {},
}),
);
const listRes = await onceMessage<{
type: "res";
ok: boolean;
payload?: unknown;
}>(ws, (o) => o.type === "res" && o.id === "pair-list-1");
expect(listRes.ok).toBe(true);
const paired = (listRes.payload as { paired?: unknown } | null)?.paired;
expect(Array.isArray(paired)).toBe(true);
expect(
(paired as Array<{ nodeId?: unknown }>).some((n) => n.nodeId === "n1"),
).toBe(true);
ws.close();
await server.close();
await fs.rm(homeDir, { recursive: true, force: true });
if (prevHome === undefined) {
delete process.env.HOME;
} else {
process.env.HOME = prevHome;
}
});
test("routes node.invoke to the node bridge", async () => {
const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-home-"));
const prevHome = process.env.HOME;
process.env.HOME = homeDir;
try {
bridgeInvoke.mockResolvedValueOnce({
type: "invoke-res",
id: "inv-1",
ok: true,
payloadJSON: JSON.stringify({ result: "4" }),
error: null,
});
const { server, ws } = await startServerWithClient();
try {
await connectOk(ws);
const res = await rpcReq(ws, "node.invoke", {
nodeId: "ios-node",
command: "canvas.eval",
params: { javaScript: "2+2" },
timeoutMs: 123,
idempotencyKey: "idem-1",
});
expect(res.ok).toBe(true);
expect(bridgeInvoke).toHaveBeenCalledWith(
expect.objectContaining({
nodeId: "ios-node",
command: "canvas.eval",
paramsJSON: JSON.stringify({ javaScript: "2+2" }),
timeoutMs: 123,
}),
);
} finally {
ws.close();
await server.close();
}
} finally {
await fs.rm(homeDir, { recursive: true, force: true });
if (prevHome === undefined) {
delete process.env.HOME;
} else {
process.env.HOME = prevHome;
}
}
});
test("node.describe returns supported invoke commands for paired nodes", async () => {
const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-home-"));
const prevHome = process.env.HOME;
process.env.HOME = homeDir;
try {
const { server, ws } = await startServerWithClient();
try {
await connectOk(ws);
const reqRes = await rpcReq<{
status?: string;
request?: { requestId?: string };
}>(ws, "node.pair.request", {
nodeId: "n1",
displayName: "iPad",
platform: "iPadOS",
version: "dev",
deviceFamily: "iPad",
modelIdentifier: "iPad16,6",
caps: ["canvas", "camera"],
commands: ["canvas.eval", "canvas.snapshot", "camera.snap"],
remoteIp: "10.0.0.10",
});
expect(reqRes.ok).toBe(true);
const requestId = reqRes.payload?.request?.requestId;
expect(typeof requestId).toBe("string");
const approveRes = await rpcReq(ws, "node.pair.approve", {
requestId,
});
expect(approveRes.ok).toBe(true);
const describeRes = await rpcReq<{ commands?: string[] }>(
ws,
"node.describe",
{ nodeId: "n1" },
);
expect(describeRes.ok).toBe(true);
expect(describeRes.payload?.commands).toEqual([
"camera.snap",
"canvas.eval",
"canvas.snapshot",
]);
} finally {
ws.close();
await server.close();
}
} finally {
await fs.rm(homeDir, { recursive: true, force: true });
if (prevHome === undefined) {
delete process.env.HOME;
} else {
process.env.HOME = prevHome;
}
}
});
test("node.describe works for connected unpaired nodes (caps + commands)", async () => {
const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-home-"));
const prevHome = process.env.HOME;
process.env.HOME = homeDir;
try {
const { server, ws } = await startServerWithClient();
try {
await connectOk(ws);
bridgeListConnected.mockReturnValueOnce([
{
nodeId: "u1",
displayName: "Unpaired Live",
platform: "Android",
version: "dev-live",
remoteIp: "10.0.0.12",
deviceFamily: "Android",
modelIdentifier: "samsung SM-X926B",
caps: ["canvas", "camera", "canvas"],
commands: ["canvas.eval", "camera.snap", "canvas.eval"],
},
]);
const describeRes = await rpcReq<{
paired?: boolean;
connected?: boolean;
caps?: string[];
commands?: string[];
deviceFamily?: string;
modelIdentifier?: string;
remoteIp?: string;
}>(ws, "node.describe", { nodeId: "u1" });
expect(describeRes.ok).toBe(true);
expect(describeRes.payload).toMatchObject({
paired: false,
connected: true,
deviceFamily: "Android",
modelIdentifier: "samsung SM-X926B",
remoteIp: "10.0.0.12",
});
expect(describeRes.payload?.caps).toEqual(["camera", "canvas"]);
expect(describeRes.payload?.commands).toEqual([
"camera.snap",
"canvas.eval",
]);
} finally {
ws.close();
await server.close();
}
} finally {
await fs.rm(homeDir, { recursive: true, force: true });
if (prevHome === undefined) {
delete process.env.HOME;
} else {
process.env.HOME = prevHome;
}
}
});
test("node.list includes connected unpaired nodes with capabilities + commands", async () => {
const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-home-"));
const prevHome = process.env.HOME;
process.env.HOME = homeDir;
try {
const { server, ws } = await startServerWithClient();
try {
await connectOk(ws);
const reqRes = await rpcReq<{
status?: string;
request?: { requestId?: string };
}>(ws, "node.pair.request", {
nodeId: "p1",
displayName: "Paired",
platform: "iPadOS",
version: "dev",
deviceFamily: "iPad",
modelIdentifier: "iPad16,6",
caps: ["canvas"],
commands: ["canvas.eval"],
remoteIp: "10.0.0.10",
});
expect(reqRes.ok).toBe(true);
const requestId = reqRes.payload?.request?.requestId;
expect(typeof requestId).toBe("string");
const approveRes = await rpcReq(ws, "node.pair.approve", { requestId });
expect(approveRes.ok).toBe(true);
bridgeListConnected.mockReturnValueOnce([
{
nodeId: "p1",
displayName: "Paired Live",
platform: "iPadOS",
version: "dev-live",
remoteIp: "10.0.0.11",
deviceFamily: "iPad",
modelIdentifier: "iPad16,6",
caps: ["canvas", "camera"],
commands: ["canvas.snapshot", "canvas.eval"],
},
{
nodeId: "u1",
displayName: "Unpaired Live",
platform: "Android",
version: "dev",
remoteIp: "10.0.0.12",
deviceFamily: "Android",
modelIdentifier: "samsung SM-X926B",
caps: ["canvas"],
commands: ["canvas.eval"],
},
]);
const listRes = await rpcReq<{
nodes?: Array<{
nodeId: string;
paired?: boolean;
connected?: boolean;
caps?: string[];
commands?: string[];
displayName?: string;
remoteIp?: string;
}>;
}>(ws, "node.list", {});
expect(listRes.ok).toBe(true);
const nodes = listRes.payload?.nodes ?? [];
const pairedNode = nodes.find((n) => n.nodeId === "p1");
expect(pairedNode).toMatchObject({
nodeId: "p1",
paired: true,
connected: true,
displayName: "Paired Live",
remoteIp: "10.0.0.11",
});
expect(pairedNode?.caps?.slice().sort()).toEqual(["camera", "canvas"]);
expect(pairedNode?.commands?.slice().sort()).toEqual([
"canvas.eval",
"canvas.snapshot",
]);
const unpairedNode = nodes.find((n) => n.nodeId === "u1");
expect(unpairedNode).toMatchObject({
nodeId: "u1",
paired: false,
connected: true,
displayName: "Unpaired Live",
});
expect(unpairedNode?.caps).toEqual(["canvas"]);
expect(unpairedNode?.commands).toEqual(["canvas.eval"]);
} finally {
ws.close();
await server.close();
}
} finally {
await fs.rm(homeDir, { recursive: true, force: true });
if (prevHome === undefined) {
delete process.env.HOME;
} else {
process.env.HOME = prevHome;
}
}
});
test("emits presence updates for bridge connect/disconnect", async () => {
const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-home-"));
const prevHome = process.env.HOME;
process.env.HOME = homeDir;
try {
const before = bridgeStartCalls.length;
const { server, ws } = await startServerWithClient();
try {
await connectOk(ws);
const bridgeCall = bridgeStartCalls[before];
expect(bridgeCall).toBeTruthy();
const waitPresenceReason = async (reason: string) => {
await onceMessage(
ws,
(o) => {
if (o.type !== "event" || o.event !== "presence") return false;
const payload = o.payload as { presence?: unknown } | null;
const list = payload?.presence;
if (!Array.isArray(list)) return false;
return list.some(
(p) =>
typeof p === "object" &&
p !== null &&
(p as { instanceId?: unknown }).instanceId === "node-1" &&
(p as { reason?: unknown }).reason === reason,
);
},
3000,
);
};
const presenceConnectedP = waitPresenceReason("node-connected");
await bridgeCall?.onAuthenticated?.({
nodeId: "node-1",
displayName: "Node",
platform: "ios",
version: "1.0",
remoteIp: "10.0.0.10",
});
await presenceConnectedP;
const presenceDisconnectedP = waitPresenceReason("node-disconnected");
await bridgeCall?.onDisconnected?.({
nodeId: "node-1",
displayName: "Node",
platform: "ios",
version: "1.0",
remoteIp: "10.0.0.10",
});
await presenceDisconnectedP;
} finally {
try {
ws.close();
} catch {
/* ignore */
}
await server.close();
await fs.rm(homeDir, { recursive: true, force: true });
}
} finally {
if (prevHome === undefined) {
delete process.env.HOME;
} else {
process.env.HOME = prevHome;
}
}
});
test("supports cron.add and cron.list", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-gw-cron-"));
testCronStorePath = path.join(dir, "cron", "jobs.json");
await fs.mkdir(path.dirname(testCronStorePath), { recursive: true });
await fs.writeFile(
testCronStorePath,
JSON.stringify({ version: 1, jobs: [] }),
);
const { server, ws } = await startServerWithClient();
await connectOk(ws);
ws.send(
JSON.stringify({
type: "req",
id: "cron-add-1",
method: "cron.add",
params: {
name: "daily",
enabled: true,
schedule: { kind: "every", everyMs: 60_000 },
sessionTarget: "main",
wakeMode: "next-heartbeat",
payload: { kind: "systemEvent", text: "hello" },
},
}),
);
const addRes = await onceMessage<{
type: "res";
ok: boolean;
payload?: unknown;
}>(ws, (o) => o.type === "res" && o.id === "cron-add-1");
expect(addRes.ok).toBe(true);
expect(typeof (addRes.payload as { id?: unknown } | null)?.id).toBe(
"string",
);
ws.send(
JSON.stringify({
type: "req",
id: "cron-list-1",
method: "cron.list",
params: { includeDisabled: true },
}),
);
const listRes = await onceMessage<{
type: "res";
ok: boolean;
payload?: unknown;
}>(ws, (o) => o.type === "res" && o.id === "cron-list-1");
expect(listRes.ok).toBe(true);
const jobs = (listRes.payload as { jobs?: unknown } | null)?.jobs;
expect(Array.isArray(jobs)).toBe(true);
expect((jobs as unknown[]).length).toBe(1);
expect(((jobs as Array<{ name?: unknown }>)[0]?.name as string) ?? "").toBe(
"daily",
);
ws.close();
await server.close();
await fs.rm(dir, { recursive: true, force: true });
testCronStorePath = undefined;
});
test("writes cron run history to runs/<jobId>.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<EventFrame>(
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<ResFrame>(
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<ResFrame>(
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<void>((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<void>((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<void>((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<boolean>((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<void>((resolve) => ws.once("close", () => resolve()));
await server.close();
});
test(
"connect + health + presence + status succeed",
{ timeout: 8000 },
async () => {
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const healthP = onceMessage(
ws,
(o) => o.type === "res" && o.id === "health1",
);
const statusP = onceMessage(
ws,
(o) => o.type === "res" && o.id === "status1",
);
const presenceP = onceMessage(
ws,
(o) => o.type === "res" && o.id === "presence1",
);
const providersP = onceMessage(
ws,
(o) => o.type === "res" && o.id === "providers1",
);
const sendReq = (id: string, method: string) =>
ws.send(JSON.stringify({ type: "req", id, method }));
sendReq("health1", "health");
sendReq("status1", "status");
sendReq("presence1", "system-presence");
sendReq("providers1", "providers.status");
const health = await healthP;
const status = await statusP;
const presence = await presenceP;
const providers = await providersP;
expect(health.ok).toBe(true);
expect(status.ok).toBe(true);
expect(presence.ok).toBe(true);
expect(providers.ok).toBe(true);
expect(Array.isArray(presence.payload)).toBe(true);
ws.close();
await server.close();
},
);
test("providers.status returns snapshot without probe", async () => {
const prevToken = process.env.TELEGRAM_BOT_TOKEN;
delete process.env.TELEGRAM_BOT_TOKEN;
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const res = await rpcReq<{
whatsapp?: { linked?: boolean };
telegram?: {
configured?: boolean;
tokenSource?: string;
probe?: unknown;
lastProbeAt?: unknown;
};
signal?: {
configured?: boolean;
probe?: unknown;
lastProbeAt?: unknown;
};
}>(ws, "providers.status", { probe: false, timeoutMs: 2000 });
expect(res.ok).toBe(true);
expect(res.payload?.whatsapp).toBeTruthy();
expect(res.payload?.telegram?.configured).toBe(false);
expect(res.payload?.telegram?.tokenSource).toBe("none");
expect(res.payload?.telegram?.probe).toBeUndefined();
expect(res.payload?.telegram?.lastProbeAt).toBeNull();
expect(res.payload?.signal?.configured).toBe(false);
expect(res.payload?.signal?.probe).toBeUndefined();
expect(res.payload?.signal?.lastProbeAt).toBeNull();
ws.close();
await server.close();
if (prevToken === undefined) {
delete process.env.TELEGRAM_BOT_TOKEN;
} else {
process.env.TELEGRAM_BOT_TOKEN = prevToken;
}
});
test("web.logout reports no session when missing", async () => {
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const res = await rpcReq<{ cleared?: boolean }>(ws, "web.logout");
expect(res.ok).toBe(true);
expect(res.payload?.cleared).toBe(false);
ws.close();
await server.close();
});
test("telegram.logout clears bot token from config", async () => {
const prevToken = process.env.TELEGRAM_BOT_TOKEN;
delete process.env.TELEGRAM_BOT_TOKEN;
await writeConfigFile({
telegram: { botToken: "123:abc", requireMention: false },
});
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const res = await rpcReq<{ cleared?: boolean; envToken?: boolean }>(
ws,
"telegram.logout",
);
expect(res.ok).toBe(true);
expect(res.payload?.cleared).toBe(true);
expect(res.payload?.envToken).toBe(false);
const snap = await readConfigFileSnapshot();
expect(snap.valid).toBe(true);
expect(snap.config?.telegram?.botToken).toBeUndefined();
expect(snap.config?.telegram?.requireMention).toBe(false);
ws.close();
await server.close();
if (prevToken === undefined) {
delete process.env.TELEGRAM_BOT_TOKEN;
} else {
process.env.TELEGRAM_BOT_TOKEN = prevToken;
}
});
test(
"presence events carry seq + stateVersion",
{ timeout: 8000 },
async () => {
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const presenceEventP = onceMessage(
ws,
(o) => o.type === "event" && o.event === "presence",
);
ws.send(
JSON.stringify({
type: "req",
id: "evt-1",
method: "system-event",
params: { text: "note from test" },
}),
);
const evt = await presenceEventP;
expect(typeof evt.seq).toBe("number");
expect(evt.stateVersion?.presence).toBeGreaterThan(0);
expect(Array.isArray(evt.payload?.presence)).toBe(true);
ws.close();
await server.close();
},
);
test("agent events stream with seq", { timeout: 8000 }, async () => {
const { server, ws } = await startServerWithClient();
await connectOk(ws);
// Emit a fake agent event directly through the shared emitter.
const runId = randomUUID();
const evtPromise = onceMessage(
ws,
(o) =>
o.type === "event" &&
o.event === "agent" &&
o.payload?.runId === runId &&
o.payload?.stream === "job",
);
emitAgentEvent({ runId, stream: "job", data: { msg: "hi" } });
const evt = await evtPromise;
expect(evt.payload.runId).toBe(runId);
expect(typeof evt.seq).toBe("number");
expect(evt.payload.data.msg).toBe("hi");
ws.close();
await server.close();
});
test(
"agent ack response then final response",
{ timeout: 8000 },
async () => {
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const ackP = onceMessage(
ws,
(o) =>
o.type === "res" &&
o.id === "ag1" &&
o.payload?.status === "accepted",
);
const finalP = onceMessage(
ws,
(o) =>
o.type === "res" &&
o.id === "ag1" &&
o.payload?.status !== "accepted",
);
ws.send(
JSON.stringify({
type: "req",
id: "ag1",
method: "agent",
params: { message: "hi", idempotencyKey: "idem-ag" },
}),
);
const ack = await ackP;
const final = await finalP;
expect(ack.payload.runId).toBeDefined();
expect(final.payload.runId).toBe(ack.payload.runId);
expect(final.payload.status).toBe("ok");
ws.close();
await server.close();
},
);
test(
"agent dedupes by idempotencyKey after completion",
{ timeout: 8000 },
async () => {
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const firstFinalP = onceMessage(
ws,
(o) =>
o.type === "res" &&
o.id === "ag1" &&
o.payload?.status !== "accepted",
);
ws.send(
JSON.stringify({
type: "req",
id: "ag1",
method: "agent",
params: { message: "hi", idempotencyKey: "same-agent" },
}),
);
const firstFinal = await firstFinalP;
const secondP = onceMessage(
ws,
(o) => o.type === "res" && o.id === "ag2",
);
ws.send(
JSON.stringify({
type: "req",
id: "ag2",
method: "agent",
params: { message: "hi again", idempotencyKey: "same-agent" },
}),
);
const second = await secondP;
expect(second.payload).toEqual(firstFinal.payload);
ws.close();
await server.close();
},
);
test("shutdown event is broadcast on close", { timeout: 8000 }, async () => {
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const shutdownP = onceMessage(
ws,
(o) => o.type === "event" && o.event === "shutdown",
5000,
);
await server.close();
const evt = await shutdownP;
expect(evt.payload?.reason).toBeDefined();
});
test(
"presence broadcast reaches multiple clients",
{ timeout: 8000 },
async () => {
const port = await getFreePort();
const server = await startGatewayServer(port);
const mkClient = async () => {
const c = new WebSocket(`ws://127.0.0.1:${port}`);
await new Promise<void>((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<void>((resolve) => ws.once("open", resolve));
await connectOk(ws);
return ws;
};
const idem = "reconnect-agent";
const ws1 = await dial();
const final1P = onceMessage(
ws1,
(o) =>
o.type === "res" && o.id === "ag1" && o.payload?.status !== "accepted",
6000,
);
ws1.send(
JSON.stringify({
type: "req",
id: "ag1",
method: "agent",
params: { message: "hi", idempotencyKey: idem },
}),
);
const final1 = await final1P;
ws1.close();
const ws2 = await dial();
const final2P = onceMessage(
ws2,
(o) =>
o.type === "res" && o.id === "ag2" && o.payload?.status !== "accepted",
6000,
);
ws2.send(
JSON.stringify({
type: "req",
id: "ag2",
method: "agent",
params: { message: "hi again", idempotencyKey: idem },
}),
);
const res = await final2P;
expect(res.payload).toEqual(final1.payload);
ws2.close();
await server.close();
});
test("chat.send accepts image attachment", { timeout: 12000 }, async () => {
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const reqId = "chat-img";
ws.send(
JSON.stringify({
type: "req",
id: reqId,
method: "chat.send",
params: {
sessionKey: "main",
message: "see image",
idempotencyKey: "idem-img",
attachments: [
{
type: "image",
mimeType: "image/png",
fileName: "dot.png",
content:
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/woAAn8B9FD5fHAAAAAASUVORK5CYII=",
},
],
},
}),
);
const res = await onceMessage(
ws,
(o) => o.type === "res" && o.id === reqId,
8000,
);
expect(res.ok).toBe(true);
expect(res.payload?.runId).toBeDefined();
ws.close();
await server.close();
});
test("chat.history caps large histories and honors limit", async () => {
const firstContentText = (msg: unknown): string | undefined => {
if (!msg || typeof msg !== "object") return undefined;
const content = (msg as { content?: unknown }).content;
if (!Array.isArray(content) || content.length === 0) return undefined;
const first = content[0];
if (!first || typeof first !== "object") return undefined;
const text = (first as { text?: unknown }).text;
return typeof text === "string" ? text : undefined;
};
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-gw-"));
testSessionStorePath = path.join(dir, "sessions.json");
await fs.writeFile(
testSessionStorePath,
JSON.stringify(
{
main: {
sessionId: "sess-main",
updatedAt: Date.now(),
},
},
null,
2,
),
"utf-8",
);
const lines: string[] = [];
for (let i = 0; i < 300; i += 1) {
lines.push(
JSON.stringify({
message: {
role: "user",
content: [{ type: "text", text: `m${i}` }],
timestamp: Date.now() + i,
},
}),
);
}
await fs.writeFile(
path.join(dir, "sess-main.jsonl"),
lines.join("\n"),
"utf-8",
);
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const defaultRes = await rpcReq<{ messages?: unknown[] }>(
ws,
"chat.history",
{
sessionKey: "main",
},
);
expect(defaultRes.ok).toBe(true);
const defaultMsgs = defaultRes.payload?.messages ?? [];
expect(defaultMsgs.length).toBe(200);
expect(firstContentText(defaultMsgs[0])).toBe("m100");
const limitedRes = await rpcReq<{ messages?: unknown[] }>(
ws,
"chat.history",
{
sessionKey: "main",
limit: 5,
},
);
expect(limitedRes.ok).toBe(true);
const limitedMsgs = limitedRes.payload?.messages ?? [];
expect(limitedMsgs.length).toBe(5);
expect(firstContentText(limitedMsgs[0])).toBe("m295");
const largeLines: string[] = [];
for (let i = 0; i < 1500; i += 1) {
largeLines.push(
JSON.stringify({
message: {
role: "user",
content: [{ type: "text", text: `b${i}` }],
timestamp: Date.now() + i,
},
}),
);
}
await fs.writeFile(
path.join(dir, "sess-main.jsonl"),
largeLines.join("\n"),
"utf-8",
);
const cappedRes = await rpcReq<{ messages?: unknown[] }>(
ws,
"chat.history",
{
sessionKey: "main",
},
);
expect(cappedRes.ok).toBe(true);
const cappedMsgs = cappedRes.payload?.messages ?? [];
expect(cappedMsgs.length).toBe(200);
expect(firstContentText(cappedMsgs[0])).toBe("b1300");
const maxRes = await rpcReq<{ messages?: unknown[] }>(ws, "chat.history", {
sessionKey: "main",
limit: 1000,
});
expect(maxRes.ok).toBe(true);
const maxMsgs = maxRes.payload?.messages ?? [];
expect(maxMsgs.length).toBe(1000);
expect(firstContentText(maxMsgs[0])).toBe("b500");
ws.close();
await server.close();
});
test("chat.history caps payload bytes", { timeout: 15_000 }, async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-gw-"));
testSessionStorePath = path.join(dir, "sessions.json");
await fs.writeFile(
testSessionStorePath,
JSON.stringify(
{
main: {
sessionId: "sess-main",
updatedAt: Date.now(),
},
},
null,
2,
),
"utf-8",
);
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const bigText = "x".repeat(200_000);
const largeLines: string[] = [];
for (let i = 0; i < 40; i += 1) {
largeLines.push(
JSON.stringify({
message: {
role: "user",
content: [{ type: "text", text: `${i}:${bigText}` }],
timestamp: Date.now() + i,
},
}),
);
}
await fs.writeFile(
path.join(dir, "sess-main.jsonl"),
largeLines.join("\n"),
"utf-8",
);
const cappedRes = await rpcReq<{ messages?: unknown[] }>(
ws,
"chat.history",
{ sessionKey: "main", limit: 1000 },
);
expect(cappedRes.ok).toBe(true);
const cappedMsgs = cappedRes.payload?.messages ?? [];
const bytes = Buffer.byteLength(JSON.stringify(cappedMsgs), "utf8");
expect(bytes).toBeLessThanOrEqual(6 * 1024 * 1024);
expect(cappedMsgs.length).toBeLessThan(60);
ws.close();
await server.close();
});
test("chat.send does not overwrite last delivery route", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-gw-"));
testSessionStorePath = path.join(dir, "sessions.json");
await fs.writeFile(
testSessionStorePath,
JSON.stringify(
{
main: {
sessionId: "sess-main",
updatedAt: Date.now(),
lastChannel: "whatsapp",
lastTo: "+1555",
},
},
null,
2,
),
"utf-8",
);
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const reqId = "chat-route";
ws.send(
JSON.stringify({
type: "req",
id: reqId,
method: "chat.send",
params: {
sessionKey: "main",
message: "hello",
idempotencyKey: "idem-route",
},
}),
);
const res = await onceMessage(
ws,
(o) => o.type === "res" && o.id === reqId,
);
expect(res.ok).toBe(true);
const stored = JSON.parse(
await fs.readFile(testSessionStorePath, "utf-8"),
) as {
main?: { lastChannel?: string; lastTo?: string };
};
expect(stored.main?.lastChannel).toBe("whatsapp");
expect(stored.main?.lastTo).toBe("+1555");
ws.close();
await server.close();
});
test(
"chat.abort cancels an in-flight chat.send",
{ timeout: 15000 },
async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-gw-"));
testSessionStorePath = path.join(dir, "sessions.json");
await fs.writeFile(
testSessionStorePath,
JSON.stringify(
{
main: {
sessionId: "sess-main",
updatedAt: Date.now(),
},
},
null,
2,
),
"utf-8",
);
const { server, ws } = await startServerWithClient();
let inFlight: Promise<unknown> | 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<void>((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<void>((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<void>((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<void>((resolve) => {
agentStartedResolve = resolve;
});
spy.mockImplementationOnce(async (opts) => {
agentStartedResolve?.();
const signal = (opts as { abortSignal?: AbortSignal }).abortSignal;
await new Promise<void>((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<string, unknown>;
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<string, { sessionId?: string } | undefined>;
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<string, unknown>;
if (rec.type !== "event" || rec.event !== "chat") return false;
if (!rec.payload || typeof rec.payload !== "object") return false;
const payload = rec.payload as Record<string, unknown>;
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<string, unknown>)
: {};
expect(payload.sessionKey).toBe("main");
const message =
payload.message && typeof payload.message === "object"
? (payload.message as Record<string, unknown>)
: {};
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<string, unknown>)
: {};
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<void>((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<Record<string, unknown>>;
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<Record<string, unknown>>;
expect(entries.some((e) => e.instanceId === cliId)).toBe(false);
ws.close();
await server.close();
});
test("lists and patches session store via sessions.* RPC", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-sessions-"));
const storePath = path.join(dir, "sessions.json");
const now = Date.now();
testSessionStorePath = storePath;
await fs.writeFile(
path.join(dir, "sess-main.jsonl"),
`${Array.from({ length: 10 })
.map((_, idx) =>
JSON.stringify({ role: "user", content: `line ${idx}` }),
)
.join("\n")}\n`,
"utf-8",
);
await fs.writeFile(
path.join(dir, "sess-group.jsonl"),
`${JSON.stringify({ role: "user", content: "group line 0" })}\n`,
"utf-8",
);
await fs.writeFile(
storePath,
JSON.stringify(
{
main: {
sessionId: "sess-main",
updatedAt: now - 30_000,
inputTokens: 10,
outputTokens: 20,
thinkingLevel: "low",
verboseLevel: "on",
},
"discord:group:dev": {
sessionId: "sess-group",
updatedAt: now - 120_000,
totalTokens: 50,
},
global: {
sessionId: "sess-global",
updatedAt: now - 10_000,
},
},
null,
2,
),
"utf-8",
);
const { server, ws } = await startServerWithClient();
const hello = await connectOk(ws);
expect(
(hello as unknown as { features?: { methods?: string[] } }).features
?.methods,
).toEqual(
expect.arrayContaining([
"sessions.list",
"sessions.patch",
"sessions.reset",
"sessions.delete",
"sessions.compact",
]),
);
const list1 = await rpcReq<{
path: string;
sessions: Array<{
key: string;
totalTokens?: number;
thinkingLevel?: string;
verboseLevel?: string;
}>;
}>(ws, "sessions.list", { includeGlobal: false, includeUnknown: false });
expect(list1.ok).toBe(true);
expect(list1.payload?.path).toBe(storePath);
expect(list1.payload?.sessions.some((s) => s.key === "global")).toBe(false);
const main = list1.payload?.sessions.find((s) => s.key === "main");
expect(main?.totalTokens).toBe(30);
expect(main?.thinkingLevel).toBe("low");
expect(main?.verboseLevel).toBe("on");
const active = await rpcReq<{
sessions: Array<{ key: string }>;
}>(ws, "sessions.list", {
includeGlobal: false,
includeUnknown: false,
activeMinutes: 1,
});
expect(active.ok).toBe(true);
expect(active.payload?.sessions.map((s) => s.key)).toEqual(["main"]);
const limited = await rpcReq<{
sessions: Array<{ key: string }>;
}>(ws, "sessions.list", {
includeGlobal: true,
includeUnknown: false,
limit: 1,
});
expect(limited.ok).toBe(true);
expect(limited.payload?.sessions).toHaveLength(1);
expect(limited.payload?.sessions[0]?.key).toBe("global");
const patched = await rpcReq<{ ok: true; key: string }>(
ws,
"sessions.patch",
{ key: "main", thinkingLevel: "medium", verboseLevel: null },
);
expect(patched.ok).toBe(true);
expect(patched.payload?.ok).toBe(true);
expect(patched.payload?.key).toBe("main");
const list2 = await rpcReq<{
sessions: Array<{
key: string;
thinkingLevel?: string;
verboseLevel?: string;
}>;
}>(ws, "sessions.list", {});
expect(list2.ok).toBe(true);
const main2 = list2.payload?.sessions.find((s) => s.key === "main");
expect(main2?.thinkingLevel).toBe("medium");
expect(main2?.verboseLevel).toBeUndefined();
const compacted = await rpcReq<{ ok: true; compacted: boolean }>(
ws,
"sessions.compact",
{ key: "main", maxLines: 3 },
);
expect(compacted.ok).toBe(true);
expect(compacted.payload?.compacted).toBe(true);
const compactedLines = (
await fs.readFile(path.join(dir, "sess-main.jsonl"), "utf-8")
)
.split(/\r?\n/)
.filter((l) => l.trim().length > 0);
expect(compactedLines).toHaveLength(3);
const filesAfterCompact = await fs.readdir(dir);
expect(
filesAfterCompact.some((f) => f.startsWith("sess-main.jsonl.bak.")),
).toBe(true);
const deleted = await rpcReq<{ ok: true; deleted: boolean }>(
ws,
"sessions.delete",
{ key: "discord:group:dev" },
);
expect(deleted.ok).toBe(true);
expect(deleted.payload?.deleted).toBe(true);
const listAfterDelete = await rpcReq<{
sessions: Array<{ key: string }>;
}>(ws, "sessions.list", {});
expect(listAfterDelete.ok).toBe(true);
expect(
listAfterDelete.payload?.sessions.some(
(s) => s.key === "discord:group:dev",
),
).toBe(false);
const filesAfterDelete = await fs.readdir(dir);
expect(
filesAfterDelete.some((f) => f.startsWith("sess-group.jsonl.deleted.")),
).toBe(true);
const reset = await rpcReq<{
ok: true;
key: string;
entry: { sessionId: string };
}>(ws, "sessions.reset", { key: "main" });
expect(reset.ok).toBe(true);
expect(reset.payload?.key).toBe("main");
expect(reset.payload?.entry.sessionId).not.toBe("sess-main");
const badThinking = await rpcReq(ws, "sessions.patch", {
key: "main",
thinkingLevel: "banana",
});
expect(badThinking.ok).toBe(false);
expect(
(badThinking.error as { message?: unknown } | undefined)?.message ?? "",
).toMatch(/invalid thinkinglevel/i);
ws.close();
await server.close();
});
test("refuses to start when port already bound", async () => {
const { server: blocker, port } = await occupyPort();
await expect(startGatewayServer(port)).rejects.toBeInstanceOf(
GatewayLockError,
);
await expect(startGatewayServer(port)).rejects.toThrow(
/already listening/i,
);
blocker.close();
});
test("releases port after close", async () => {
const port = await getFreePort();
const server = await startGatewayServer(port);
await server.close();
// If the port was released, another listener can bind immediately.
const probe = createServer();
await new Promise<void>((resolve, reject) => {
probe.once("error", reject);
probe.listen(port, "127.0.0.1", () => resolve());
});
await new Promise<void>((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();
});
});