mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
434 lines
13 KiB
TypeScript
434 lines
13 KiB
TypeScript
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { ToolInputError } from "../agents/tools/common.js";
|
|
import { createTestRegistry } from "../test-utils/channel-plugins.js";
|
|
import { resetTestPluginRegistry, setTestPluginRegistry, testState } from "./test-helpers.mocks.js";
|
|
import { installGatewayTestHooks, getFreePort, startGatewayServer } from "./test-helpers.server.js";
|
|
|
|
installGatewayTestHooks({ scope: "suite" });
|
|
|
|
beforeEach(() => {
|
|
// Ensure these tests are not affected by host env vars.
|
|
delete process.env.OPENCLAW_GATEWAY_TOKEN;
|
|
delete process.env.OPENCLAW_GATEWAY_PASSWORD;
|
|
});
|
|
|
|
const resolveGatewayToken = (): string => {
|
|
const token = (testState.gatewayAuth as { token?: string } | undefined)?.token;
|
|
if (!token) {
|
|
throw new Error("test gateway token missing");
|
|
}
|
|
return token;
|
|
};
|
|
|
|
const allowAgentsListForMain = () => {
|
|
testState.agentsConfig = {
|
|
list: [
|
|
{
|
|
id: "main",
|
|
tools: {
|
|
allow: ["agents_list"],
|
|
},
|
|
},
|
|
],
|
|
// oxlint-disable-next-line typescript/no-explicit-any
|
|
} as any;
|
|
};
|
|
|
|
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 fetch(`http://127.0.0.1:${params.port}/tools/invoke`, {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json", ...params.headers },
|
|
body: JSON.stringify(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 fetch(`http://127.0.0.1:${params.port}/tools/invoke`, {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json", ...params.headers },
|
|
body: JSON.stringify(body),
|
|
});
|
|
};
|
|
|
|
describe("POST /tools/invoke", () => {
|
|
let sharedPort = 0;
|
|
let sharedServer: Awaited<ReturnType<typeof startGatewayServer>>;
|
|
|
|
beforeAll(async () => {
|
|
sharedPort = await getFreePort();
|
|
sharedServer = await startGatewayServer(sharedPort, {
|
|
bind: "loopback",
|
|
});
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await sharedServer.close();
|
|
});
|
|
|
|
it("invokes a tool and returns {ok:true,result}", async () => {
|
|
allowAgentsListForMain();
|
|
const token = resolveGatewayToken();
|
|
|
|
const res = await invokeAgentsList({
|
|
port: sharedPort,
|
|
headers: { authorization: `Bearer ${token}` },
|
|
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 () => {
|
|
testState.agentsConfig = {
|
|
list: [{ id: "main" }],
|
|
// oxlint-disable-next-line typescript/no-explicit-any
|
|
} as any;
|
|
|
|
const { writeConfigFile } = await import("../config/config.js");
|
|
await writeConfigFile({
|
|
tools: { profile: "minimal", alsoAllow: ["agents_list"] },
|
|
// oxlint-disable-next-line typescript/no-explicit-any
|
|
} as any);
|
|
const token = resolveGatewayToken();
|
|
|
|
const resProfile = await invokeAgentsList({
|
|
port: sharedPort,
|
|
headers: { authorization: `Bearer ${token}` },
|
|
sessionKey: "main",
|
|
});
|
|
|
|
expect(resProfile.status).toBe(200);
|
|
const profileBody = await resProfile.json();
|
|
expect(profileBody.ok).toBe(true);
|
|
|
|
await writeConfigFile({
|
|
tools: { alsoAllow: ["agents_list"] },
|
|
// oxlint-disable-next-line typescript/no-explicit-any
|
|
} as any);
|
|
const resImplicit = await invokeAgentsList({
|
|
port: sharedPort,
|
|
headers: { authorization: `Bearer ${token}` },
|
|
sessionKey: "main",
|
|
});
|
|
expect(resImplicit.status).toBe(200);
|
|
const implicitBody = await resImplicit.json();
|
|
expect(implicitBody.ok).toBe(true);
|
|
});
|
|
|
|
it("handles dedicated auth modes for password accept and token reject", async () => {
|
|
allowAgentsListForMain();
|
|
|
|
const passwordPort = await getFreePort();
|
|
const passwordServer = await startGatewayServer(passwordPort, {
|
|
bind: "loopback",
|
|
auth: { mode: "password", password: "secret" },
|
|
});
|
|
try {
|
|
const passwordRes = await invokeAgentsList({
|
|
port: passwordPort,
|
|
headers: { authorization: "Bearer secret" },
|
|
sessionKey: "main",
|
|
});
|
|
expect(passwordRes.status).toBe(200);
|
|
} finally {
|
|
await passwordServer.close();
|
|
}
|
|
|
|
const tokenPort = await getFreePort();
|
|
const tokenServer = await startGatewayServer(tokenPort, {
|
|
bind: "loopback",
|
|
auth: { mode: "token", token: "t" },
|
|
});
|
|
try {
|
|
const tokenRes = await invokeAgentsList({
|
|
port: tokenPort,
|
|
sessionKey: "main",
|
|
});
|
|
expect(tokenRes.status).toBe(401);
|
|
} finally {
|
|
await tokenServer.close();
|
|
}
|
|
});
|
|
|
|
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;
|
|
});
|
|
const registry = createTestRegistry();
|
|
registry.httpHandlers = [
|
|
{
|
|
pluginId: "test-plugin",
|
|
source: "test",
|
|
handler: pluginHandler as unknown as (
|
|
req: import("node:http").IncomingMessage,
|
|
res: import("node:http").ServerResponse,
|
|
) => Promise<boolean>,
|
|
},
|
|
];
|
|
setTestPluginRegistry(registry);
|
|
|
|
allowAgentsListForMain();
|
|
try {
|
|
const token = resolveGatewayToken();
|
|
const res = await invokeAgentsList({
|
|
port: sharedPort,
|
|
headers: { authorization: `Bearer ${token}` },
|
|
sessionKey: "main",
|
|
});
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(pluginHandler).not.toHaveBeenCalled();
|
|
} finally {
|
|
resetTestPluginRegistry();
|
|
}
|
|
});
|
|
|
|
it("returns 404 when denylisted or blocked by tools.profile", async () => {
|
|
testState.agentsConfig = {
|
|
list: [
|
|
{
|
|
id: "main",
|
|
tools: {
|
|
deny: ["agents_list"],
|
|
},
|
|
},
|
|
],
|
|
// oxlint-disable-next-line typescript/no-explicit-any
|
|
} as any;
|
|
const token = resolveGatewayToken();
|
|
|
|
const denyRes = await invokeAgentsList({
|
|
port: sharedPort,
|
|
headers: { authorization: `Bearer ${token}` },
|
|
sessionKey: "main",
|
|
});
|
|
expect(denyRes.status).toBe(404);
|
|
|
|
allowAgentsListForMain();
|
|
|
|
const { writeConfigFile } = await import("../config/config.js");
|
|
await writeConfigFile({
|
|
tools: { profile: "minimal" },
|
|
// oxlint-disable-next-line typescript/no-explicit-any
|
|
} as any);
|
|
|
|
const profileRes = await invokeAgentsList({
|
|
port: sharedPort,
|
|
headers: { authorization: `Bearer ${token}` },
|
|
sessionKey: "main",
|
|
});
|
|
expect(profileRes.status).toBe(404);
|
|
});
|
|
|
|
it("denies sessions_spawn via HTTP even when agent policy allows", async () => {
|
|
testState.agentsConfig = {
|
|
list: [
|
|
{
|
|
id: "main",
|
|
tools: { allow: ["sessions_spawn"] },
|
|
},
|
|
],
|
|
// oxlint-disable-next-line typescript/no-explicit-any
|
|
} as any;
|
|
|
|
const port = await getFreePort();
|
|
const server = await startGatewayServer(port, { bind: "loopback" });
|
|
const token = resolveGatewayToken();
|
|
|
|
const res = await fetch(`http://127.0.0.1:${port}/tools/invoke`, {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
|
|
body: JSON.stringify({ 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");
|
|
|
|
await server.close();
|
|
});
|
|
|
|
it("denies sessions_send via HTTP gateway", async () => {
|
|
testState.agentsConfig = {
|
|
list: [{ id: "main", tools: { allow: ["sessions_send"] } }],
|
|
// oxlint-disable-next-line typescript/no-explicit-any
|
|
} as any;
|
|
|
|
const port = await getFreePort();
|
|
const server = await startGatewayServer(port, { bind: "loopback" });
|
|
const token = resolveGatewayToken();
|
|
|
|
const res = await fetch(`http://127.0.0.1:${port}/tools/invoke`, {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
|
|
body: JSON.stringify({ tool: "sessions_send", args: {}, sessionKey: "main" }),
|
|
});
|
|
|
|
expect(res.status).toBe(404);
|
|
await server.close();
|
|
});
|
|
|
|
it("denies gateway tool via HTTP", async () => {
|
|
testState.agentsConfig = {
|
|
list: [{ id: "main", tools: { allow: ["gateway"] } }],
|
|
// oxlint-disable-next-line typescript/no-explicit-any
|
|
} as any;
|
|
|
|
const port = await getFreePort();
|
|
const server = await startGatewayServer(port, { bind: "loopback" });
|
|
const token = resolveGatewayToken();
|
|
|
|
const res = await fetch(`http://127.0.0.1:${port}/tools/invoke`, {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
|
|
body: JSON.stringify({ tool: "gateway", args: {}, sessionKey: "main" }),
|
|
});
|
|
|
|
expect(res.status).toBe(404);
|
|
await server.close();
|
|
});
|
|
|
|
it("uses the configured main session key when sessionKey is missing or main", async () => {
|
|
testState.agentsConfig = {
|
|
list: [
|
|
{
|
|
id: "main",
|
|
tools: {
|
|
deny: ["agents_list"],
|
|
},
|
|
},
|
|
{
|
|
id: "ops",
|
|
default: true,
|
|
tools: {
|
|
allow: ["agents_list"],
|
|
},
|
|
},
|
|
],
|
|
// oxlint-disable-next-line typescript/no-explicit-any
|
|
} as any;
|
|
testState.sessionConfig = { mainKey: "primary" };
|
|
|
|
const token = resolveGatewayToken();
|
|
|
|
const resDefault = await invokeAgentsList({
|
|
port: sharedPort,
|
|
headers: { authorization: `Bearer ${token}` },
|
|
});
|
|
expect(resDefault.status).toBe(200);
|
|
|
|
const resMain = await invokeAgentsList({
|
|
port: sharedPort,
|
|
headers: { authorization: `Bearer ${token}` },
|
|
sessionKey: "main",
|
|
});
|
|
expect(resMain.status).toBe(200);
|
|
});
|
|
|
|
it("maps tool input errors to 400 and unexpected execution errors to 500", async () => {
|
|
const registry = createTestRegistry();
|
|
registry.tools.push({
|
|
pluginId: "tools-invoke-test",
|
|
source: "test",
|
|
names: ["tools_invoke_test"],
|
|
optional: false,
|
|
factory: () => ({
|
|
label: "Tools Invoke Test",
|
|
name: "tools_invoke_test",
|
|
description: "Test-only tool.",
|
|
parameters: {
|
|
type: "object",
|
|
properties: {
|
|
mode: { type: "string" },
|
|
},
|
|
required: ["mode"],
|
|
additionalProperties: false,
|
|
},
|
|
execute: async (_toolCallId, args) => {
|
|
const mode = (args as { mode?: unknown }).mode;
|
|
if (mode === "input") {
|
|
throw new ToolInputError("mode invalid");
|
|
}
|
|
if (mode === "crash") {
|
|
throw new Error("boom");
|
|
}
|
|
return { ok: true };
|
|
},
|
|
}),
|
|
});
|
|
setTestPluginRegistry(registry);
|
|
const { writeConfigFile } = await import("../config/config.js");
|
|
await writeConfigFile({
|
|
plugins: { enabled: true },
|
|
// oxlint-disable-next-line typescript/no-explicit-any
|
|
} as any);
|
|
|
|
const token = resolveGatewayToken();
|
|
|
|
try {
|
|
const inputRes = await invokeTool({
|
|
port: sharedPort,
|
|
tool: "tools_invoke_test",
|
|
args: { mode: "input" },
|
|
headers: { authorization: `Bearer ${token}` },
|
|
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 crashRes = await invokeTool({
|
|
port: sharedPort,
|
|
tool: "tools_invoke_test",
|
|
args: { mode: "crash" },
|
|
headers: { authorization: `Bearer ${token}` },
|
|
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");
|
|
} finally {
|
|
await writeConfigFile({
|
|
// oxlint-disable-next-line typescript/no-explicit-any
|
|
} as any);
|
|
resetTestPluginRegistry();
|
|
}
|
|
});
|
|
});
|