mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-08 15:48:28 +00:00
test: dedupe gateway browser discord and channel coverage
This commit is contained in:
@@ -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 });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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: ");
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.",
|
||||
);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user