550 lines
15 KiB
TypeScript
550 lines
15 KiB
TypeScript
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
|
|
import type { AddressInfo } from "node:net";
|
|
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
const TEST_GATEWAY_TOKEN = "test-gateway-token-1234567890";
|
|
|
|
let cfg: Record<string, unknown> = {};
|
|
let lastCreateOpenClawToolsContext: Record<string, unknown> | undefined;
|
|
|
|
// Perf: keep this suite pure unit. Mock heavyweight config/session modules.
|
|
vi.mock("../config/config.js", () => ({
|
|
loadConfig: () => cfg,
|
|
}));
|
|
|
|
vi.mock("../config/sessions.js", () => ({
|
|
resolveMainSessionKey: (params?: {
|
|
session?: { scope?: string; mainKey?: string };
|
|
agents?: { list?: Array<{ id?: string; default?: boolean }> };
|
|
}) => {
|
|
if (params?.session?.scope === "global") {
|
|
return "global";
|
|
}
|
|
const agents = params?.agents?.list ?? [];
|
|
const rawDefault = agents.find((agent) => agent?.default)?.id ?? agents[0]?.id ?? "main";
|
|
const agentId =
|
|
String(rawDefault ?? "main")
|
|
.trim()
|
|
.toLowerCase() || "main";
|
|
const mainKeyRaw = String(params?.session?.mainKey ?? "main")
|
|
.trim()
|
|
.toLowerCase();
|
|
const mainKey = mainKeyRaw || "main";
|
|
return `agent:${agentId}:${mainKey}`;
|
|
},
|
|
}));
|
|
|
|
vi.mock("./auth.js", () => ({
|
|
authorizeHttpGatewayConnect: async () => ({ ok: true }),
|
|
}));
|
|
|
|
vi.mock("../logger.js", () => ({
|
|
logWarn: () => {},
|
|
}));
|
|
|
|
vi.mock("../plugins/config-state.js", () => ({
|
|
isTestDefaultMemorySlotDisabled: () => false,
|
|
}));
|
|
|
|
vi.mock("../plugins/tools.js", () => ({
|
|
getPluginToolMeta: () => undefined,
|
|
}));
|
|
|
|
// Perf: the real tool factory instantiates many tools per request; for these HTTP
|
|
// routing/policy tests we only need a small set of tool names.
|
|
vi.mock("../agents/openclaw-tools.js", () => {
|
|
const toolInputError = (message: string) => {
|
|
const err = new Error(message);
|
|
err.name = "ToolInputError";
|
|
return err;
|
|
};
|
|
const toolAuthorizationError = (message: string) => {
|
|
const err = new Error(message) as Error & { status?: number };
|
|
err.name = "ToolAuthorizationError";
|
|
err.status = 403;
|
|
return err;
|
|
};
|
|
|
|
const tools = [
|
|
{
|
|
name: "session_status",
|
|
parameters: { type: "object", properties: {} },
|
|
execute: async () => ({ ok: true }),
|
|
},
|
|
{
|
|
name: "agents_list",
|
|
parameters: { type: "object", properties: { action: { type: "string" } } },
|
|
execute: async () => ({ ok: true, result: [] }),
|
|
},
|
|
{
|
|
name: "sessions_spawn",
|
|
parameters: { type: "object", properties: {} },
|
|
execute: async () => ({
|
|
ok: true,
|
|
route: {
|
|
agentTo: lastCreateOpenClawToolsContext?.agentTo,
|
|
agentThreadId: lastCreateOpenClawToolsContext?.agentThreadId,
|
|
},
|
|
}),
|
|
},
|
|
{
|
|
name: "sessions_send",
|
|
parameters: { type: "object", properties: {} },
|
|
execute: async () => ({ ok: true }),
|
|
},
|
|
{
|
|
name: "gateway",
|
|
parameters: { type: "object", properties: {} },
|
|
execute: async () => {
|
|
throw toolInputError("invalid args");
|
|
},
|
|
},
|
|
{
|
|
name: "tools_invoke_test",
|
|
parameters: {
|
|
type: "object",
|
|
properties: {
|
|
mode: { type: "string" },
|
|
},
|
|
required: ["mode"],
|
|
additionalProperties: false,
|
|
},
|
|
execute: async (_toolCallId: string, args: unknown) => {
|
|
const mode = (args as { mode?: unknown })?.mode;
|
|
if (mode === "input") {
|
|
throw toolInputError("mode invalid");
|
|
}
|
|
if (mode === "auth") {
|
|
throw toolAuthorizationError("mode forbidden");
|
|
}
|
|
if (mode === "crash") {
|
|
throw new Error("boom");
|
|
}
|
|
return { ok: true };
|
|
},
|
|
},
|
|
];
|
|
|
|
return {
|
|
createOpenClawTools: (ctx: Record<string, unknown>) => {
|
|
lastCreateOpenClawToolsContext = ctx;
|
|
return tools;
|
|
},
|
|
};
|
|
});
|
|
|
|
const { handleToolsInvokeHttpRequest } = await import("./tools-invoke-http.js");
|
|
|
|
let pluginHttpHandlers: Array<(req: IncomingMessage, res: ServerResponse) => Promise<boolean>> = [];
|
|
|
|
let sharedPort = 0;
|
|
let sharedServer: ReturnType<typeof createServer> | undefined;
|
|
|
|
beforeAll(async () => {
|
|
sharedServer = createServer((req, res) => {
|
|
void (async () => {
|
|
const handled = await handleToolsInvokeHttpRequest(req, res, {
|
|
auth: { mode: "token", token: TEST_GATEWAY_TOKEN, allowTailscale: false },
|
|
});
|
|
if (handled) {
|
|
return;
|
|
}
|
|
for (const handler of pluginHttpHandlers) {
|
|
if (await handler(req, res)) {
|
|
return;
|
|
}
|
|
}
|
|
res.statusCode = 404;
|
|
res.end("not found");
|
|
})().catch((err) => {
|
|
res.statusCode = 500;
|
|
res.end(String(err));
|
|
});
|
|
});
|
|
|
|
await new Promise<void>((resolve, reject) => {
|
|
sharedServer?.once("error", reject);
|
|
sharedServer?.listen(0, "127.0.0.1", () => {
|
|
const address = sharedServer?.address() as AddressInfo | null;
|
|
sharedPort = address?.port ?? 0;
|
|
resolve();
|
|
});
|
|
});
|
|
});
|
|
|
|
afterAll(async () => {
|
|
const server = sharedServer;
|
|
if (!server) {
|
|
return;
|
|
}
|
|
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
sharedServer = undefined;
|
|
});
|
|
|
|
beforeEach(() => {
|
|
delete process.env.OPENCLAW_GATEWAY_TOKEN;
|
|
delete process.env.OPENCLAW_GATEWAY_PASSWORD;
|
|
pluginHttpHandlers = [];
|
|
cfg = {};
|
|
lastCreateOpenClawToolsContext = undefined;
|
|
});
|
|
|
|
const resolveGatewayToken = (): string => TEST_GATEWAY_TOKEN;
|
|
const gatewayAuthHeaders = () => ({ authorization: `Bearer ${resolveGatewayToken()}` });
|
|
|
|
const allowAgentsListForMain = () => {
|
|
cfg = {
|
|
...cfg,
|
|
agents: {
|
|
list: [
|
|
{
|
|
id: "main",
|
|
default: true,
|
|
tools: {
|
|
allow: ["agents_list"],
|
|
},
|
|
},
|
|
],
|
|
},
|
|
};
|
|
};
|
|
|
|
const postToolsInvoke = async (params: {
|
|
port: number;
|
|
headers?: Record<string, string>;
|
|
body: Record<string, unknown>;
|
|
}) =>
|
|
await fetch(`http://127.0.0.1:${params.port}/tools/invoke`, {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json", ...params.headers },
|
|
body: JSON.stringify(params.body),
|
|
});
|
|
|
|
const invokeAgentsList = async (params: {
|
|
port: number;
|
|
headers?: Record<string, string>;
|
|
sessionKey?: string;
|
|
}) => {
|
|
const body: Record<string, unknown> = { tool: "agents_list", action: "json", args: {} };
|
|
if (params.sessionKey) {
|
|
body.sessionKey = params.sessionKey;
|
|
}
|
|
return await postToolsInvoke({ port: params.port, headers: params.headers, body });
|
|
};
|
|
|
|
const invokeTool = async (params: {
|
|
port: number;
|
|
tool: string;
|
|
args?: Record<string, unknown>;
|
|
action?: string;
|
|
headers?: Record<string, string>;
|
|
sessionKey?: string;
|
|
}) => {
|
|
const body: Record<string, unknown> = {
|
|
tool: params.tool,
|
|
args: params.args ?? {},
|
|
};
|
|
if (params.action) {
|
|
body.action = params.action;
|
|
}
|
|
if (params.sessionKey) {
|
|
body.sessionKey = params.sessionKey;
|
|
}
|
|
return await postToolsInvoke({ port: params.port, headers: params.headers, body });
|
|
};
|
|
|
|
const invokeAgentsListAuthed = async (params: { sessionKey?: string } = {}) =>
|
|
invokeAgentsList({
|
|
port: sharedPort,
|
|
headers: gatewayAuthHeaders(),
|
|
sessionKey: params.sessionKey,
|
|
});
|
|
|
|
const invokeToolAuthed = async (params: {
|
|
tool: string;
|
|
args?: Record<string, unknown>;
|
|
action?: string;
|
|
sessionKey?: string;
|
|
}) =>
|
|
invokeTool({
|
|
port: sharedPort,
|
|
headers: gatewayAuthHeaders(),
|
|
...params,
|
|
});
|
|
|
|
describe("POST /tools/invoke", () => {
|
|
it("invokes a tool and returns {ok:true,result}", async () => {
|
|
allowAgentsListForMain();
|
|
const res = await invokeAgentsListAuthed({ sessionKey: "main" });
|
|
|
|
expect(res.status).toBe(200);
|
|
const body = await res.json();
|
|
expect(body.ok).toBe(true);
|
|
expect(body).toHaveProperty("result");
|
|
});
|
|
|
|
it("supports tools.alsoAllow in profile and implicit modes", async () => {
|
|
cfg = {
|
|
...cfg,
|
|
agents: { list: [{ id: "main", default: true }] },
|
|
tools: { profile: "minimal", alsoAllow: ["agents_list"] },
|
|
};
|
|
|
|
const resProfile = await invokeAgentsListAuthed({ sessionKey: "main" });
|
|
|
|
expect(resProfile.status).toBe(200);
|
|
const profileBody = await resProfile.json();
|
|
expect(profileBody.ok).toBe(true);
|
|
|
|
cfg = {
|
|
...cfg,
|
|
tools: { alsoAllow: ["agents_list"] },
|
|
};
|
|
|
|
const resImplicit = await invokeAgentsListAuthed({ sessionKey: "main" });
|
|
expect(resImplicit.status).toBe(200);
|
|
const implicitBody = await resImplicit.json();
|
|
expect(implicitBody.ok).toBe(true);
|
|
});
|
|
|
|
it("routes tools invoke before plugin HTTP handlers", async () => {
|
|
const pluginHandler = vi.fn(async (_req: IncomingMessage, res: ServerResponse) => {
|
|
res.statusCode = 418;
|
|
res.end("plugin");
|
|
return true;
|
|
});
|
|
allowAgentsListForMain();
|
|
pluginHttpHandlers = [async (req, res) => pluginHandler(req, res)];
|
|
|
|
const res = await invokeAgentsListAuthed({ sessionKey: "main" });
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(pluginHandler).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("returns 404 when denylisted or blocked by tools.profile", async () => {
|
|
cfg = {
|
|
...cfg,
|
|
agents: {
|
|
list: [
|
|
{
|
|
id: "main",
|
|
default: true,
|
|
tools: {
|
|
deny: ["agents_list"],
|
|
},
|
|
},
|
|
],
|
|
},
|
|
};
|
|
const denyRes = await invokeAgentsListAuthed({ sessionKey: "main" });
|
|
expect(denyRes.status).toBe(404);
|
|
|
|
allowAgentsListForMain();
|
|
cfg = {
|
|
...cfg,
|
|
tools: { profile: "minimal" },
|
|
};
|
|
|
|
const profileRes = await invokeAgentsListAuthed({ sessionKey: "main" });
|
|
expect(profileRes.status).toBe(404);
|
|
});
|
|
|
|
it("denies sessions_spawn via HTTP even when agent policy allows", async () => {
|
|
cfg = {
|
|
...cfg,
|
|
agents: {
|
|
list: [
|
|
{
|
|
id: "main",
|
|
default: true,
|
|
tools: { allow: ["sessions_spawn"] },
|
|
},
|
|
],
|
|
},
|
|
};
|
|
|
|
const res = await invokeToolAuthed({
|
|
tool: "sessions_spawn",
|
|
args: { task: "test" },
|
|
sessionKey: "main",
|
|
});
|
|
|
|
expect(res.status).toBe(404);
|
|
const body = await res.json();
|
|
expect(body.ok).toBe(false);
|
|
expect(body.error.type).toBe("not_found");
|
|
});
|
|
|
|
it("propagates message target/thread headers into tools context for sessions_spawn", async () => {
|
|
cfg = {
|
|
...cfg,
|
|
agents: {
|
|
list: [{ id: "main", default: true, tools: { allow: ["sessions_spawn"] } }],
|
|
},
|
|
gateway: { tools: { allow: ["sessions_spawn"] } },
|
|
};
|
|
|
|
const res = await invokeTool({
|
|
port: sharedPort,
|
|
headers: {
|
|
...gatewayAuthHeaders(),
|
|
"x-openclaw-message-to": "channel:24514",
|
|
"x-openclaw-thread-id": "thread-24514",
|
|
},
|
|
tool: "sessions_spawn",
|
|
sessionKey: "main",
|
|
});
|
|
|
|
expect(res.status).toBe(200);
|
|
const body = await res.json();
|
|
expect(body.ok).toBe(true);
|
|
expect(body.result?.route).toEqual({
|
|
agentTo: "channel:24514",
|
|
agentThreadId: "thread-24514",
|
|
});
|
|
});
|
|
|
|
it("denies sessions_send via HTTP gateway", async () => {
|
|
cfg = {
|
|
...cfg,
|
|
agents: {
|
|
list: [{ id: "main", default: true, tools: { allow: ["sessions_send"] } }],
|
|
},
|
|
};
|
|
|
|
const res = await invokeToolAuthed({
|
|
tool: "sessions_send",
|
|
sessionKey: "main",
|
|
});
|
|
|
|
expect(res.status).toBe(404);
|
|
});
|
|
|
|
it("denies gateway tool via HTTP", async () => {
|
|
cfg = {
|
|
...cfg,
|
|
agents: {
|
|
list: [{ id: "main", default: true, tools: { allow: ["gateway"] } }],
|
|
},
|
|
};
|
|
|
|
const res = await invokeToolAuthed({
|
|
tool: "gateway",
|
|
sessionKey: "main",
|
|
});
|
|
|
|
expect(res.status).toBe(404);
|
|
});
|
|
|
|
it("allows gateway tool via HTTP when explicitly enabled in gateway.tools.allow", async () => {
|
|
cfg = {
|
|
...cfg,
|
|
agents: {
|
|
list: [{ id: "main", default: true, tools: { allow: ["gateway"] } }],
|
|
},
|
|
gateway: { tools: { allow: ["gateway"] } },
|
|
};
|
|
|
|
const res = await invokeToolAuthed({
|
|
tool: "gateway",
|
|
sessionKey: "main",
|
|
});
|
|
|
|
// Ensure we didn't hit the HTTP deny list (404). Invalid args should map to 400.
|
|
expect(res.status).toBe(400);
|
|
const body = await res.json();
|
|
expect(body.ok).toBe(false);
|
|
expect(body.error?.type).toBe("tool_error");
|
|
});
|
|
|
|
it("treats gateway.tools.deny as higher priority than gateway.tools.allow", async () => {
|
|
cfg = {
|
|
...cfg,
|
|
agents: {
|
|
list: [{ id: "main", default: true, tools: { allow: ["gateway"] } }],
|
|
},
|
|
gateway: { tools: { allow: ["gateway"], deny: ["gateway"] } },
|
|
};
|
|
|
|
const res = await invokeToolAuthed({
|
|
tool: "gateway",
|
|
sessionKey: "main",
|
|
});
|
|
|
|
expect(res.status).toBe(404);
|
|
});
|
|
|
|
it("uses the configured main session key when sessionKey is missing or main", async () => {
|
|
cfg = {
|
|
...cfg,
|
|
agents: {
|
|
list: [
|
|
{
|
|
id: "main",
|
|
tools: {
|
|
deny: ["agents_list"],
|
|
},
|
|
},
|
|
{
|
|
id: "ops",
|
|
default: true,
|
|
tools: {
|
|
allow: ["agents_list"],
|
|
},
|
|
},
|
|
],
|
|
},
|
|
session: { mainKey: "primary" },
|
|
};
|
|
|
|
const resDefault = await invokeAgentsListAuthed();
|
|
expect(resDefault.status).toBe(200);
|
|
|
|
const resMain = await invokeAgentsListAuthed({ sessionKey: "main" });
|
|
expect(resMain.status).toBe(200);
|
|
});
|
|
|
|
it("maps tool input/auth errors to 400/403 and unexpected execution errors to 500", async () => {
|
|
cfg = {
|
|
...cfg,
|
|
agents: {
|
|
list: [{ id: "main", default: true, tools: { allow: ["tools_invoke_test"] } }],
|
|
},
|
|
};
|
|
|
|
const inputRes = await invokeToolAuthed({
|
|
tool: "tools_invoke_test",
|
|
args: { mode: "input" },
|
|
sessionKey: "main",
|
|
});
|
|
expect(inputRes.status).toBe(400);
|
|
const inputBody = await inputRes.json();
|
|
expect(inputBody.ok).toBe(false);
|
|
expect(inputBody.error?.type).toBe("tool_error");
|
|
expect(inputBody.error?.message).toBe("mode invalid");
|
|
|
|
const authRes = await invokeToolAuthed({
|
|
tool: "tools_invoke_test",
|
|
args: { mode: "auth" },
|
|
sessionKey: "main",
|
|
});
|
|
expect(authRes.status).toBe(403);
|
|
const authBody = await authRes.json();
|
|
expect(authBody.ok).toBe(false);
|
|
expect(authBody.error?.type).toBe("tool_error");
|
|
expect(authBody.error?.message).toBe("mode forbidden");
|
|
|
|
const crashRes = await invokeToolAuthed({
|
|
tool: "tools_invoke_test",
|
|
args: { mode: "crash" },
|
|
sessionKey: "main",
|
|
});
|
|
expect(crashRes.status).toBe(500);
|
|
const crashBody = await crashRes.json();
|
|
expect(crashBody.ok).toBe(false);
|
|
expect(crashBody.error?.type).toBe("tool_error");
|
|
expect(crashBody.error?.message).toBe("tool execution failed");
|
|
});
|
|
});
|