test: dedupe gateway browser discord and channel coverage

This commit is contained in:
Peter Steinberger
2026-02-22 17:11:42 +00:00
parent 34ea33f057
commit 296b19e413
29 changed files with 938 additions and 1041 deletions

View File

@@ -40,6 +40,32 @@ describe("handleControlUiHttpRequest", () => {
expect(params.end).toHaveBeenCalledWith("Not Found");
}
function runControlUiRequest(params: {
url: string;
method: "GET" | "HEAD";
rootPath: string;
basePath?: string;
}) {
const { res, end } = makeMockHttpResponse();
const handled = handleControlUiHttpRequest(
{ url: params.url, method: params.method } as IncomingMessage,
res,
{
...(params.basePath ? { basePath: params.basePath } : {}),
root: { kind: "resolved", path: params.rootPath },
},
);
return { res, end, handled };
}
async function writeAssetFile(rootPath: string, filename: string, contents: string) {
const assetsDir = path.join(rootPath, "assets");
await fs.mkdir(assetsDir, { recursive: true });
const filePath = path.join(assetsDir, filename);
await fs.writeFile(filePath, contents);
return { assetsDir, filePath };
}
async function withBasePathRootFixture<T>(params: {
siblingDir: string;
fn: (paths: { root: string; sibling: string }) => Promise<T>;
@@ -183,19 +209,14 @@ describe("handleControlUiHttpRequest", () => {
it("allows symlinked assets that resolve inside control-ui root", async () => {
await withControlUiRoot({
fn: async (tmp) => {
const assetsDir = path.join(tmp, "assets");
await fs.mkdir(assetsDir, { recursive: true });
await fs.writeFile(path.join(assetsDir, "actual.txt"), "inside-ok\n");
await fs.symlink(path.join(assetsDir, "actual.txt"), path.join(assetsDir, "linked.txt"));
const { assetsDir, filePath } = await writeAssetFile(tmp, "actual.txt", "inside-ok\n");
await fs.symlink(filePath, path.join(assetsDir, "linked.txt"));
const { res, end } = makeMockHttpResponse();
const handled = handleControlUiHttpRequest(
{ url: "/assets/linked.txt", method: "GET" } as IncomingMessage,
res,
{
root: { kind: "resolved", path: tmp },
},
);
const { res, end, handled } = runControlUiRequest({
url: "/assets/linked.txt",
method: "GET",
rootPath: tmp,
});
expect(handled).toBe(true);
expect(res.statusCode).toBe(200);
@@ -207,18 +228,13 @@ describe("handleControlUiHttpRequest", () => {
it("serves HEAD for in-root assets without writing a body", async () => {
await withControlUiRoot({
fn: async (tmp) => {
const assetsDir = path.join(tmp, "assets");
await fs.mkdir(assetsDir, { recursive: true });
await fs.writeFile(path.join(assetsDir, "actual.txt"), "inside-ok\n");
await writeAssetFile(tmp, "actual.txt", "inside-ok\n");
const { res, end } = makeMockHttpResponse();
const handled = handleControlUiHttpRequest(
{ url: "/assets/actual.txt", method: "HEAD" } as IncomingMessage,
res,
{
root: { kind: "resolved", path: tmp },
},
);
const { res, end, handled } = runControlUiRequest({
url: "/assets/actual.txt",
method: "HEAD",
rootPath: tmp,
});
expect(handled).toBe(true);
expect(res.statusCode).toBe(200);
@@ -237,14 +253,11 @@ describe("handleControlUiHttpRequest", () => {
await fs.rm(path.join(tmp, "index.html"));
await fs.symlink(outsideIndex, path.join(tmp, "index.html"));
const { res, end } = makeMockHttpResponse();
const handled = handleControlUiHttpRequest(
{ url: "/app/route", method: "GET" } as IncomingMessage,
res,
{
root: { kind: "resolved", path: tmp },
},
);
const { res, end, handled } = runControlUiRequest({
url: "/app/route",
method: "GET",
rootPath: tmp,
});
expectNotFoundResponse({ handled, res, end });
} finally {
await fs.rm(outsideDir, { recursive: true, force: true });
@@ -262,16 +275,12 @@ describe("handleControlUiHttpRequest", () => {
const secretPathUrl = secretPath.split(path.sep).join("/");
const absolutePathUrl = secretPathUrl.startsWith("/") ? secretPathUrl : `/${secretPathUrl}`;
const { res, end } = makeMockHttpResponse();
const handled = handleControlUiHttpRequest(
{ url: `/openclaw/${absolutePathUrl}`, method: "GET" } as IncomingMessage,
res,
{
basePath: "/openclaw",
root: { kind: "resolved", path: root },
},
);
const { res, end, handled } = runControlUiRequest({
url: `/openclaw/${absolutePathUrl}`,
method: "GET",
rootPath: root,
basePath: "/openclaw",
});
expectNotFoundResponse({ handled, res, end });
},
});
@@ -295,15 +304,12 @@ describe("handleControlUiHttpRequest", () => {
throw error;
}
const { res, end } = makeMockHttpResponse();
const handled = handleControlUiHttpRequest(
{ url: "/openclaw/assets/leak.txt", method: "GET" } as IncomingMessage,
res,
{
basePath: "/openclaw",
root: { kind: "resolved", path: root },
},
);
const { res, end, handled } = runControlUiRequest({
url: "/openclaw/assets/leak.txt",
method: "GET",
rootPath: root,
basePath: "/openclaw",
});
expectNotFoundResponse({ handled, res, end });
},
});

View File

@@ -43,6 +43,40 @@ describe("hooks mapping", () => {
});
}
function expectAgentMessage(
result: Awaited<ReturnType<typeof applyHookMappings>> | undefined,
expectedMessage: string,
) {
expect(result?.ok).toBe(true);
if (result?.ok && result.action?.kind === "agent") {
expect(result.action.kind).toBe("agent");
expect(result.action.message).toBe(expectedMessage);
}
}
async function expectBlockedPrototypeTraversal(params: {
id: string;
messageTemplate: string;
payload: Record<string, unknown>;
expectedMessage: string;
}) {
const mappings = resolveHookMappings({
mappings: [
createGmailAgentMapping({
id: params.id,
messageTemplate: params.messageTemplate,
}),
],
});
const result = await applyHookMappings(mappings, {
payload: params.payload,
headers: {},
url: baseUrl,
path: "gmail",
});
expectAgentMessage(result, params.expectedMessage);
}
async function applyNullTransformFromTempConfig(params: {
configDir: string;
transformsDir?: string;
@@ -91,11 +125,7 @@ describe("hooks mapping", () => {
}),
],
});
expect(result?.ok).toBe(true);
if (result?.ok && result.action?.kind === "agent") {
expect(result.action.kind).toBe("agent");
expect(result.action.message).toBe("Subject: Hello");
}
expectAgentMessage(result, "Subject: Hello");
});
it("passes model override from mapping", async () => {
@@ -342,11 +372,7 @@ describe("hooks mapping", () => {
}),
],
});
expect(result?.ok).toBe(true);
if (result?.ok && result.action?.kind === "agent") {
expect(result.action.kind).toBe("agent");
expect(result.action.message).toBe("Override subject: Hello");
}
expectAgentMessage(result, "Override subject: Hello");
});
it("passes agentId from mapping", async () => {
@@ -461,75 +487,30 @@ describe("hooks mapping", () => {
describe("prototype pollution protection", () => {
it("blocks __proto__ traversal in webhook payload", async () => {
const mappings = resolveHookMappings({
mappings: [
createGmailAgentMapping({
id: "proto-test",
messageTemplate: "value: {{__proto__}}",
}),
],
});
const result = await applyHookMappings(mappings, {
await expectBlockedPrototypeTraversal({
id: "proto-test",
messageTemplate: "value: {{__proto__}}",
payload: { __proto__: { polluted: true } } as Record<string, unknown>,
headers: {},
url: baseUrl,
path: "gmail",
expectedMessage: "value: ",
});
expect(result?.ok).toBe(true);
if (result?.ok) {
const action = result.action;
if (action?.kind === "agent") {
expect(action.message).toBe("value: ");
}
}
});
it("blocks constructor traversal in webhook payload", async () => {
const mappings = resolveHookMappings({
mappings: [
createGmailAgentMapping({
id: "constructor-test",
messageTemplate: "type: {{constructor.name}}",
}),
],
});
const result = await applyHookMappings(mappings, {
await expectBlockedPrototypeTraversal({
id: "constructor-test",
messageTemplate: "type: {{constructor.name}}",
payload: { constructor: { name: "INJECTED" } } as Record<string, unknown>,
headers: {},
url: baseUrl,
path: "gmail",
expectedMessage: "type: ",
});
expect(result?.ok).toBe(true);
if (result?.ok) {
const action = result.action;
if (action?.kind === "agent") {
expect(action.message).toBe("type: ");
}
}
});
it("blocks prototype traversal in webhook payload", async () => {
const mappings = resolveHookMappings({
mappings: [
createGmailAgentMapping({
id: "prototype-test",
messageTemplate: "val: {{prototype}}",
}),
],
});
const result = await applyHookMappings(mappings, {
await expectBlockedPrototypeTraversal({
id: "prototype-test",
messageTemplate: "val: {{prototype}}",
payload: { prototype: "leaked" } as Record<string, unknown>,
headers: {},
url: baseUrl,
path: "gmail",
expectedMessage: "val: ",
});
expect(result?.ok).toBe(true);
if (result?.ok) {
const action = result.action;
if (action?.kind === "agent") {
expect(action.message).toBe("val: ");
}
}
});
});
});

View File

@@ -97,6 +97,66 @@ describe("agent event handler", () => {
return nodeSendToSession.mock.calls.filter(([, event]) => event === "chat");
}
const FALLBACK_LIFECYCLE_DATA = {
phase: "fallback",
selectedProvider: "fireworks",
selectedModel: "fireworks/minimax-m2p5",
activeProvider: "deepinfra",
activeModel: "moonshotai/Kimi-K2.5",
} as const;
function emitLifecycleEnd(
handler: ReturnType<typeof createHarness>["handler"],
runId: string,
seq = 2,
) {
handler({
runId,
seq,
stream: "lifecycle",
ts: Date.now(),
data: { phase: "end" },
});
}
function emitFallbackLifecycle(params: {
handler: ReturnType<typeof createHarness>["handler"];
runId: string;
seq?: number;
sessionKey?: string;
}) {
params.handler({
runId: params.runId,
seq: params.seq ?? 1,
stream: "lifecycle",
ts: Date.now(),
...(params.sessionKey ? { sessionKey: params.sessionKey } : {}),
data: { ...FALLBACK_LIFECYCLE_DATA },
});
}
function expectSingleAgentBroadcastPayload(broadcast: ReturnType<typeof vi.fn>) {
const broadcastAgentCalls = broadcast.mock.calls.filter(([event]) => event === "agent");
expect(broadcastAgentCalls).toHaveLength(1);
return broadcastAgentCalls[0]?.[1] as {
runId?: string;
sessionKey?: string;
stream?: string;
data?: Record<string, unknown>;
};
}
function expectSingleFinalChatPayload(broadcast: ReturnType<typeof vi.fn>) {
const chatCalls = chatBroadcastCalls(broadcast);
expect(chatCalls).toHaveLength(1);
const payload = chatCalls[0]?.[1] as {
state?: string;
message?: unknown;
};
expect(payload.state).toBe("final");
return payload;
}
it("emits chat delta for assistant text-only events", () => {
const { broadcast, nodeSendToSession, nowSpy } = emitRun1AssistantText(
createHarness({ now: 1_000 }),
@@ -152,18 +212,9 @@ describe("agent event handler", () => {
ts: Date.now(),
data: { text: "NO_REPLY" },
});
handler({
runId: "run-2",
seq: 2,
stream: "lifecycle",
ts: Date.now(),
data: { phase: "end" },
});
emitLifecycleEnd(handler, "run-2");
const chatCalls = chatBroadcastCalls(broadcast);
expect(chatCalls).toHaveLength(1);
const payload = chatCalls[0]?.[1] as { state?: string; message?: unknown };
expect(payload.state).toBe("final");
const payload = expectSingleFinalChatPayload(broadcast) as { message?: unknown };
expect(payload.message).toBeUndefined();
expect(sessionChatCalls(nodeSendToSession)).toHaveLength(1);
nowSpy?.mockRestore();
@@ -305,28 +356,10 @@ describe("agent event handler", () => {
resolveSessionKeyForRun: () => "session-fallback",
});
handler({
runId: "run-fallback",
seq: 1,
stream: "lifecycle",
ts: Date.now(),
data: {
phase: "fallback",
selectedProvider: "fireworks",
selectedModel: "fireworks/minimax-m2p5",
activeProvider: "deepinfra",
activeModel: "moonshotai/Kimi-K2.5",
},
});
emitFallbackLifecycle({ handler, runId: "run-fallback" });
expect(broadcastToConnIds).not.toHaveBeenCalled();
const broadcastAgentCalls = broadcast.mock.calls.filter(([event]) => event === "agent");
expect(broadcastAgentCalls).toHaveLength(1);
const payload = broadcastAgentCalls[0]?.[1] as {
sessionKey?: string;
stream?: string;
data?: Record<string, unknown>;
};
const payload = expectSingleAgentBroadcastPayload(broadcast);
expect(payload.stream).toBe("lifecycle");
expect(payload.data?.phase).toBe("fallback");
expect(payload.sessionKey).toBe("session-fallback");
@@ -345,28 +378,9 @@ describe("agent event handler", () => {
clientRunId: "run-fallback-client",
});
handler({
runId: "run-fallback-internal",
seq: 1,
stream: "lifecycle",
ts: Date.now(),
data: {
phase: "fallback",
selectedProvider: "fireworks",
selectedModel: "fireworks/minimax-m2p5",
activeProvider: "deepinfra",
activeModel: "moonshotai/Kimi-K2.5",
},
});
emitFallbackLifecycle({ handler, runId: "run-fallback-internal" });
const broadcastAgentCalls = broadcast.mock.calls.filter(([event]) => event === "agent");
expect(broadcastAgentCalls).toHaveLength(1);
const payload = broadcastAgentCalls[0]?.[1] as {
runId?: string;
sessionKey?: string;
stream?: string;
data?: Record<string, unknown>;
};
const payload = expectSingleAgentBroadcastPayload(broadcast);
expect(payload.runId).toBe("run-fallback-client");
expect(payload.stream).toBe("lifecycle");
expect(payload.data?.phase).toBe("fallback");
@@ -382,24 +396,13 @@ describe("agent event handler", () => {
resolveSessionKeyForRun: () => undefined,
});
handler({
emitFallbackLifecycle({
handler,
runId: "run-fallback-session-key",
seq: 1,
stream: "lifecycle",
ts: Date.now(),
sessionKey: "session-from-event",
data: {
phase: "fallback",
selectedProvider: "fireworks",
selectedModel: "fireworks/minimax-m2p5",
activeProvider: "deepinfra",
activeModel: "moonshotai/Kimi-K2.5",
},
});
const broadcastAgentCalls = broadcast.mock.calls.filter(([event]) => event === "agent");
expect(broadcastAgentCalls).toHaveLength(1);
const payload = broadcastAgentCalls[0]?.[1] as { sessionKey?: string };
const payload = expectSingleAgentBroadcastPayload(broadcast);
expect(payload.sessionKey).toBe("session-from-event");
});
@@ -464,18 +467,9 @@ describe("agent event handler", () => {
expect(chatBroadcastCalls(broadcast)).toHaveLength(0);
expect(sessionChatCalls(nodeSendToSession)).toHaveLength(0);
handler({
runId: "run-heartbeat",
seq: 2,
stream: "lifecycle",
ts: Date.now(),
data: { phase: "end" },
});
emitLifecycleEnd(handler, "run-heartbeat");
const chatCalls = chatBroadcastCalls(broadcast);
expect(chatCalls).toHaveLength(1);
const finalPayload = chatCalls[0]?.[1] as { state?: string; message?: unknown };
expect(finalPayload.state).toBe("final");
const finalPayload = expectSingleFinalChatPayload(broadcast) as { message?: unknown };
expect(finalPayload.message).toBeUndefined();
expect(sessionChatCalls(nodeSendToSession)).toHaveLength(1);
});
@@ -506,21 +500,11 @@ describe("agent event handler", () => {
},
});
handler({
runId: "run-heartbeat-alert",
seq: 2,
stream: "lifecycle",
ts: Date.now(),
data: { phase: "end" },
});
emitLifecycleEnd(handler, "run-heartbeat-alert");
const chatCalls = chatBroadcastCalls(broadcast);
expect(chatCalls).toHaveLength(1);
const payload = chatCalls[0]?.[1] as {
state?: string;
const payload = expectSingleFinalChatPayload(broadcast) as {
message?: { content?: Array<{ text?: string }> };
};
expect(payload.state).toBe("final");
expect(payload.message?.content?.[0]?.text).toBe(
"Disk usage crossed 95 percent on /data and needs cleanup now.",
);

View File

@@ -150,6 +150,29 @@ function readLastAgentCommandCall():
| undefined;
}
function mockSessionResetSuccess(params: {
reason: "new" | "reset";
key?: string;
sessionId?: string;
}) {
const key = params.key ?? "agent:main:main";
const sessionId = params.sessionId ?? "reset-session-id";
mocks.sessionsResetHandler.mockImplementation(
async (opts: {
params: { key: string; reason: string };
respond: (ok: boolean, payload?: unknown) => void;
}) => {
expect(opts.params.key).toBe(key);
expect(opts.params.reason).toBe(params.reason);
opts.respond(true, {
ok: true,
key,
entry: { sessionId },
});
},
);
}
async function invokeAgent(
params: AgentParams,
options?: {
@@ -321,20 +344,7 @@ describe("gateway agent handler", () => {
});
it("handles bare /new by resetting the same session and sending reset greeting prompt", async () => {
mocks.sessionsResetHandler.mockImplementation(
async (opts: {
params: { key: string; reason: string };
respond: (ok: boolean, payload?: unknown) => void;
}) => {
expect(opts.params.key).toBe("agent:main:main");
expect(opts.params.reason).toBe("new");
opts.respond(true, {
ok: true,
key: "agent:main:main",
entry: { sessionId: "reset-session-id" },
});
},
);
mockSessionResetSuccess({ reason: "new" });
primeMainAgentRun({ sessionId: "reset-session-id" });
@@ -366,20 +376,7 @@ describe("gateway agent handler", () => {
},
},
};
mocks.sessionsResetHandler.mockImplementation(
async (opts: {
params: { key: string; reason: string };
respond: (ok: boolean, payload?: unknown) => void;
}) => {
expect(opts.params.key).toBe("agent:main:main");
expect(opts.params.reason).toBe("reset");
opts.respond(true, {
ok: true,
key: "agent:main:main",
entry: { sessionId: "reset-session-id" },
});
},
);
mockSessionResetSuccess({ reason: "reset" });
mocks.sessionsResetHandler.mockClear();
primeMainAgentRun({
sessionId: "reset-session-id",

View File

@@ -34,6 +34,16 @@ function createInvokeParams(params: Record<string, unknown>) {
};
}
function expectInvalidRequestResponse(
respond: ReturnType<typeof vi.fn>,
expectedMessagePart: string,
) {
const call = respond.mock.calls[0] as RespondCall | undefined;
expect(call?.[0]).toBe(false);
expect(call?.[2]?.code).toBe(ErrorCodes.INVALID_REQUEST);
expect(call?.[2]?.message).toContain(expectedMessagePart);
}
describe("push.test handler", () => {
beforeEach(() => {
vi.mocked(loadApnsRegistration).mockClear();
@@ -45,20 +55,14 @@ describe("push.test handler", () => {
it("rejects invalid params", async () => {
const { respond, invoke } = createInvokeParams({ title: "hello" });
await invoke();
const call = respond.mock.calls[0] as RespondCall | undefined;
expect(call?.[0]).toBe(false);
expect(call?.[2]?.code).toBe(ErrorCodes.INVALID_REQUEST);
expect(call?.[2]?.message).toContain("invalid push.test params");
expectInvalidRequestResponse(respond, "invalid push.test params");
});
it("returns invalid request when node has no APNs registration", async () => {
vi.mocked(loadApnsRegistration).mockResolvedValue(null);
const { respond, invoke } = createInvokeParams({ nodeId: "ios-node-1" });
await invoke();
const call = respond.mock.calls[0] as RespondCall | undefined;
expect(call?.[0]).toBe(false);
expect(call?.[2]?.code).toBe(ErrorCodes.INVALID_REQUEST);
expect(call?.[2]?.message).toContain("has no APNs registration");
expectInvalidRequestResponse(respond, "has no APNs registration");
});
it("sends push test when registration and auth are available", async () => {

View File

@@ -42,6 +42,48 @@ const getInflightMap = (context: GatewayRequestContext) => {
return inflight;
};
async function resolveRequestedChannel(params: {
requestChannel: unknown;
unsupportedMessage: (input: string) => string;
rejectWebchatAsInternalOnly?: boolean;
}): Promise<
| {
cfg: ReturnType<typeof loadConfig>;
channel: string;
}
| {
error: ReturnType<typeof errorShape>;
}
> {
const channelInput =
typeof params.requestChannel === "string" ? params.requestChannel : undefined;
const normalizedChannel = channelInput ? normalizeChannelId(channelInput) : null;
if (channelInput && !normalizedChannel) {
const normalizedInput = channelInput.trim().toLowerCase();
if (params.rejectWebchatAsInternalOnly && normalizedInput === "webchat") {
return {
error: errorShape(
ErrorCodes.INVALID_REQUEST,
"unsupported channel: webchat (internal-only). Use `chat.send` for WebChat UI messages or choose a deliverable channel.",
),
};
}
return {
error: errorShape(ErrorCodes.INVALID_REQUEST, params.unsupportedMessage(channelInput)),
};
}
const cfg = loadConfig();
let channel = normalizedChannel;
if (!channel) {
try {
channel = (await resolveMessageChannelSelection({ cfg })).channel;
} catch (err) {
return { error: errorShape(ErrorCodes.INVALID_REQUEST, String(err)) };
}
}
return { cfg, channel };
}
export const sendHandlers: GatewayRequestHandlers = {
send: async ({ params, respond, context }) => {
const p = params;
@@ -104,38 +146,16 @@ export const sendHandlers: GatewayRequestHandlers = {
);
return;
}
const channelInput = typeof request.channel === "string" ? request.channel : undefined;
const normalizedChannel = channelInput ? normalizeChannelId(channelInput) : null;
if (channelInput && !normalizedChannel) {
const normalizedInput = channelInput.trim().toLowerCase();
if (normalizedInput === "webchat") {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
"unsupported channel: webchat (internal-only). Use `chat.send` for WebChat UI messages or choose a deliverable channel.",
),
);
return;
}
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, `unsupported channel: ${channelInput}`),
);
const resolvedChannel = await resolveRequestedChannel({
requestChannel: request.channel,
unsupportedMessage: (input) => `unsupported channel: ${input}`,
rejectWebchatAsInternalOnly: true,
});
if ("error" in resolvedChannel) {
respond(false, undefined, resolvedChannel.error);
return;
}
const cfg = loadConfig();
let channel = normalizedChannel;
if (!channel) {
try {
channel = (await resolveMessageChannelSelection({ cfg })).channel;
} catch (err) {
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, String(err)));
return;
}
}
const { cfg, channel } = resolvedChannel;
const accountId =
typeof request.accountId === "string" && request.accountId.trim().length
? request.accountId.trim()
@@ -322,26 +342,15 @@ export const sendHandlers: GatewayRequestHandlers = {
return;
}
const to = request.to.trim();
const channelInput = typeof request.channel === "string" ? request.channel : undefined;
const normalizedChannel = channelInput ? normalizeChannelId(channelInput) : null;
if (channelInput && !normalizedChannel) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, `unsupported poll channel: ${channelInput}`),
);
const resolvedChannel = await resolveRequestedChannel({
requestChannel: request.channel,
unsupportedMessage: (input) => `unsupported poll channel: ${input}`,
});
if ("error" in resolvedChannel) {
respond(false, undefined, resolvedChannel.error);
return;
}
const cfg = loadConfig();
let channel = normalizedChannel;
if (!channel) {
try {
channel = (await resolveMessageChannelSelection({ cfg })).channel;
} catch (err) {
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, String(err)));
return;
}
}
const { cfg, channel } = resolvedChannel;
if (typeof request.durationSeconds === "number" && channel !== "telegram") {
respond(
false,

View File

@@ -109,6 +109,23 @@ async function runSessionsUsageLogs(params: Record<string, unknown>) {
return respond;
}
const BASE_USAGE_RANGE = {
startDate: "2026-02-01",
endDate: "2026-02-02",
limit: 10,
} as const;
function expectSuccessfulSessionsUsage(
respond: ReturnType<typeof vi.fn>,
): Array<{ key: string; agentId: string }> {
expect(respond).toHaveBeenCalledTimes(1);
expect(respond.mock.calls[0]?.[0]).toBe(true);
const result = respond.mock.calls[0]?.[1] as {
sessions: Array<{ key: string; agentId: string }>;
};
return result.sessions;
}
describe("sessions.usage", () => {
beforeEach(() => {
vi.useRealTimers();
@@ -116,28 +133,20 @@ describe("sessions.usage", () => {
});
it("discovers sessions across configured agents and keeps agentId in key", async () => {
const respond = await runSessionsUsage({
startDate: "2026-02-01",
endDate: "2026-02-02",
limit: 10,
});
const respond = await runSessionsUsage(BASE_USAGE_RANGE);
expect(vi.mocked(discoverAllSessions)).toHaveBeenCalledTimes(2);
expect(vi.mocked(discoverAllSessions).mock.calls[0]?.[0]?.agentId).toBe("main");
expect(vi.mocked(discoverAllSessions).mock.calls[1]?.[0]?.agentId).toBe("opus");
expect(respond).toHaveBeenCalledTimes(1);
expect(respond.mock.calls[0]?.[0]).toBe(true);
const result = respond.mock.calls[0]?.[1] as unknown as {
sessions: Array<{ key: string; agentId: string }>;
};
expect(result.sessions).toHaveLength(2);
const sessions = expectSuccessfulSessionsUsage(respond);
expect(sessions).toHaveLength(2);
// Sorted by most recent first (mtime=200 -> opus first).
expect(result.sessions[0].key).toBe("agent:opus:s-opus");
expect(result.sessions[0].agentId).toBe("opus");
expect(result.sessions[1].key).toBe("agent:main:s-main");
expect(result.sessions[1].agentId).toBe("main");
expect(sessions[0].key).toBe("agent:opus:s-opus");
expect(sessions[0].agentId).toBe("opus");
expect(sessions[1].key).toBe("agent:main:s-main");
expect(sessions[1].agentId).toBe("main");
});
it("resolves store entries by sessionId when queried via discovered agent-prefixed key", async () => {
@@ -166,20 +175,10 @@ describe("sessions.usage", () => {
});
// Query via discovered key: agent:<id>:<sessionId>
const respond = await runSessionsUsage({
startDate: "2026-02-01",
endDate: "2026-02-02",
key: "agent:opus:s-opus",
limit: 10,
});
expect(respond).toHaveBeenCalledTimes(1);
expect(respond.mock.calls[0]?.[0]).toBe(true);
const result = respond.mock.calls[0]?.[1] as unknown as {
sessions: Array<{ key: string }>;
};
expect(result.sessions).toHaveLength(1);
expect(result.sessions[0]?.key).toBe(storeKey);
const respond = await runSessionsUsage({ ...BASE_USAGE_RANGE, key: "agent:opus:s-opus" });
const sessions = expectSuccessfulSessionsUsage(respond);
expect(sessions).toHaveLength(1);
expect(sessions[0]?.key).toBe(storeKey);
expect(vi.mocked(loadSessionCostSummary)).toHaveBeenCalled();
expect(
vi.mocked(loadSessionCostSummary).mock.calls.some((call) => call[0]?.agentId === "opus"),
@@ -192,10 +191,8 @@ describe("sessions.usage", () => {
it("rejects traversal-style keys in specific session usage lookups", async () => {
const respond = await runSessionsUsage({
startDate: "2026-02-01",
endDate: "2026-02-02",
...BASE_USAGE_RANGE,
key: "agent:opus:../../etc/passwd",
limit: 10,
});
expect(respond).toHaveBeenCalledTimes(1);

View File

@@ -21,6 +21,54 @@ let gatewayPort: number;
const gatewayToken = "test-token";
let envSnapshot: ReturnType<typeof captureEnv>;
type SessionSendTool = ReturnType<typeof createOpenClawTools>[number];
function getSessionsSendTool(): SessionSendTool {
const tool = createOpenClawTools().find((candidate) => candidate.name === "sessions_send");
if (!tool) {
throw new Error("missing sessions_send tool");
}
return tool;
}
async function emitLifecycleAssistantReply(params: {
opts: unknown;
defaultSessionId: string;
includeTimestamp?: boolean;
resolveText: (extraSystemPrompt?: string) => string;
}) {
const commandParams = params.opts as {
sessionId?: string;
runId?: string;
extraSystemPrompt?: string;
};
const sessionId = commandParams.sessionId ?? params.defaultSessionId;
const runId = commandParams.runId ?? sessionId;
const sessionFile = resolveSessionTranscriptPath(sessionId);
await fs.mkdir(path.dirname(sessionFile), { recursive: true });
const startedAt = Date.now();
emitAgentEvent({
runId,
stream: "lifecycle",
data: { phase: "start", startedAt },
});
const text = params.resolveText(commandParams.extraSystemPrompt);
const message = {
role: "assistant",
content: [{ type: "text", text }],
...(params.includeTimestamp ? { timestamp: Date.now() } : {}),
};
await fs.appendFile(sessionFile, `${JSON.stringify({ message })}\n`, "utf8");
emitAgentEvent({
runId,
stream: "lifecycle",
data: { phase: "end", startedAt, endedAt: Date.now() },
});
}
beforeAll(async () => {
envSnapshot = captureEnv(["OPENCLAW_GATEWAY_PORT", "OPENCLAW_GATEWAY_TOKEN"]);
gatewayPort = await getFreePort();
@@ -52,52 +100,24 @@ afterAll(async () => {
describe("sessions_send gateway loopback", () => {
it("returns reply when lifecycle ends before agent.wait", async () => {
const spy = agentCommand as unknown as Mock<(opts: unknown) => Promise<void>>;
spy.mockImplementation(async (opts: unknown) => {
const params = opts as {
sessionId?: string;
runId?: string;
extraSystemPrompt?: string;
};
const sessionId = params.sessionId ?? "main";
const runId = params.runId ?? sessionId;
const sessionFile = resolveSessionTranscriptPath(sessionId);
await fs.mkdir(path.dirname(sessionFile), { recursive: true });
const startedAt = Date.now();
emitAgentEvent({
runId,
stream: "lifecycle",
data: { phase: "start", startedAt },
});
let text = "pong";
if (params.extraSystemPrompt?.includes("Agent-to-agent reply step")) {
text = "REPLY_SKIP";
} else if (params.extraSystemPrompt?.includes("Agent-to-agent announce step")) {
text = "ANNOUNCE_SKIP";
}
const message = {
role: "assistant",
content: [{ type: "text", text }],
timestamp: Date.now(),
};
await fs.appendFile(sessionFile, `${JSON.stringify({ message })}\n`, "utf8");
emitAgentEvent({
runId,
stream: "lifecycle",
data: {
phase: "end",
startedAt,
endedAt: Date.now(),
spy.mockImplementation(async (opts: unknown) =>
emitLifecycleAssistantReply({
opts,
defaultSessionId: "main",
includeTimestamp: true,
resolveText: (extraSystemPrompt) => {
if (extraSystemPrompt?.includes("Agent-to-agent reply step")) {
return "REPLY_SKIP";
}
if (extraSystemPrompt?.includes("Agent-to-agent announce step")) {
return "ANNOUNCE_SKIP";
}
return "pong";
},
});
});
}),
);
const tool = createOpenClawTools().find((candidate) => candidate.name === "sessions_send");
if (!tool) {
throw new Error("missing sessions_send tool");
}
const tool = getSessionsSendTool();
const result = await tool.execute("call-loopback", {
sessionKey: "main",
@@ -139,37 +159,13 @@ describe("sessions_send label lookup", () => {
);
const spy = agentCommand as unknown as Mock<(opts: unknown) => Promise<void>>;
spy.mockImplementation(async (opts: unknown) => {
const params = opts as {
sessionId?: string;
runId?: string;
extraSystemPrompt?: string;
};
const sessionId = params.sessionId ?? "test-labeled";
const runId = params.runId ?? sessionId;
const sessionFile = resolveSessionTranscriptPath(sessionId);
await fs.mkdir(path.dirname(sessionFile), { recursive: true });
const startedAt = Date.now();
emitAgentEvent({
runId,
stream: "lifecycle",
data: { phase: "start", startedAt },
});
const text = "labeled response";
const message = {
role: "assistant",
content: [{ type: "text", text }],
};
await fs.appendFile(sessionFile, `${JSON.stringify({ message })}\n`, "utf8");
emitAgentEvent({
runId,
stream: "lifecycle",
data: { phase: "end", startedAt, endedAt: Date.now() },
});
});
spy.mockImplementation(async (opts: unknown) =>
emitLifecycleAssistantReply({
opts,
defaultSessionId: "test-labeled",
resolveText: () => "labeled response",
}),
);
// First, create a session with a label via sessions.patch
const { callGateway } = await import("./call.js");
@@ -179,10 +175,7 @@ describe("sessions_send label lookup", () => {
timeoutMs: 5000,
});
const tool = createOpenClawTools().find((candidate) => candidate.name === "sessions_send");
if (!tool) {
throw new Error("missing sessions_send tool");
}
const tool = getSessionsSendTool();
// Send using label instead of sessionKey
const result = await tool.execute("call-by-label", {
@@ -201,10 +194,7 @@ describe("sessions_send label lookup", () => {
});
it("returns error when label not found", { timeout: 60_000 }, async () => {
const tool = createOpenClawTools().find((candidate) => candidate.name === "sessions_send");
if (!tool) {
throw new Error("missing sessions_send tool");
}
const tool = getSessionsSendTool();
const result = await tool.execute("call-missing-label", {
label: "nonexistent-label",
@@ -217,10 +207,7 @@ describe("sessions_send label lookup", () => {
});
it("returns error when neither sessionKey nor label provided", { timeout: 60_000 }, async () => {
const tool = createOpenClawTools().find((candidate) => candidate.name === "sessions_send");
if (!tool) {
throw new Error("missing sessions_send tool");
}
const tool = getSessionsSendTool();
const result = await tool.execute("call-no-key", {
message: "hello",

View File

@@ -9,6 +9,8 @@ import { withServer } from "./test-with-server.js";
installGatewayTestHooks({ scope: "suite" });
type GatewaySocket = Parameters<Parameters<typeof withServer>[0]>[0];
async function createFreshOperatorDevice(scopes: string[], nonce: string) {
const { randomUUID } = await import("node:crypto");
const { tmpdir } = await import("node:os");
@@ -41,6 +43,21 @@ async function createFreshOperatorDevice(scopes: string[], nonce: string) {
};
}
async function connectOperator(ws: GatewaySocket, scopes: string[]) {
const nonce = await readConnectChallengeNonce(ws);
expect(nonce).toBeTruthy();
await connectOk(ws, {
token: "secret",
scopes,
device: await createFreshOperatorDevice(scopes, String(nonce)),
});
}
async function writeTalkConfig(config: { apiKey?: string; voiceId?: string }) {
const { writeConfigFile } = await import("../config/config.js");
await writeConfigFile({ talk: config });
}
describe("gateway talk.config", () => {
it("returns redacted talk config for read scope", async () => {
const { writeConfigFile } = await import("../config/config.js");
@@ -58,13 +75,7 @@ describe("gateway talk.config", () => {
});
await withServer(async (ws) => {
const nonce = await readConnectChallengeNonce(ws);
expect(nonce).toBeTruthy();
await connectOk(ws, {
token: "secret",
scopes: ["operator.read"],
device: await createFreshOperatorDevice(["operator.read"], String(nonce)),
});
await connectOperator(ws, ["operator.read"]);
const res = await rpcReq<{ config?: { talk?: { apiKey?: string; voiceId?: string } } }>(
ws,
"talk.config",
@@ -77,21 +88,10 @@ describe("gateway talk.config", () => {
});
it("requires operator.talk.secrets for includeSecrets", async () => {
const { writeConfigFile } = await import("../config/config.js");
await writeConfigFile({
talk: {
apiKey: "secret-key-abc",
},
});
await writeTalkConfig({ apiKey: "secret-key-abc" });
await withServer(async (ws) => {
const nonce = await readConnectChallengeNonce(ws);
expect(nonce).toBeTruthy();
await connectOk(ws, {
token: "secret",
scopes: ["operator.read"],
device: await createFreshOperatorDevice(["operator.read"], String(nonce)),
});
await connectOperator(ws, ["operator.read"]);
const res = await rpcReq(ws, "talk.config", { includeSecrets: true });
expect(res.ok).toBe(false);
expect(res.error?.message).toContain("missing scope: operator.talk.secrets");
@@ -99,24 +99,10 @@ describe("gateway talk.config", () => {
});
it("returns secrets for operator.talk.secrets scope", async () => {
const { writeConfigFile } = await import("../config/config.js");
await writeConfigFile({
talk: {
apiKey: "secret-key-abc",
},
});
await writeTalkConfig({ apiKey: "secret-key-abc" });
await withServer(async (ws) => {
const nonce = await readConnectChallengeNonce(ws);
expect(nonce).toBeTruthy();
await connectOk(ws, {
token: "secret",
scopes: ["operator.read", "operator.write", "operator.talk.secrets"],
device: await createFreshOperatorDevice(
["operator.read", "operator.write", "operator.talk.secrets"],
String(nonce),
),
});
await connectOperator(ws, ["operator.read", "operator.write", "operator.talk.secrets"]);
const res = await rpcReq<{ config?: { talk?: { apiKey?: string } } }>(ws, "talk.config", {
includeSecrets: true,
});

View File

@@ -1,5 +1,6 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { expectGeneratedTokenPersistedToGatewayAuth } from "../test-utils/auth-token-assertions.js";
const mocks = vi.hoisted(() => ({
writeConfigFile: vi.fn(async (_cfg: OpenClawConfig) => {}),
@@ -62,11 +63,12 @@ describe("ensureGatewayStartupAuth", () => {
expect(result.generatedToken).toMatch(/^[0-9a-f]{48}$/);
expect(result.persistedGeneratedToken).toBe(true);
expect(result.auth.mode).toBe("token");
expect(result.auth.token).toBe(result.generatedToken);
expect(mocks.writeConfigFile).toHaveBeenCalledTimes(1);
const persisted = mocks.writeConfigFile.mock.calls[0]?.[0];
expect(persisted?.gateway?.auth?.mode).toBe("token");
expect(persisted?.gateway?.auth?.token).toBe(result.generatedToken);
expectGeneratedTokenPersistedToGatewayAuth({
generatedToken: result.generatedToken,
authToken: result.auth.token,
persistedConfig: mocks.writeConfigFile.mock.calls[0]?.[0],
});
});
it("does not generate when token already exists", async () => {