server/bot-api.e2e.test.ts
Rama main · réplica de lectura del repositorio original.
// A bot's API on a real server with the fake engine: switched on from the app, called with the very curl the card shows,
// answered with the bot's reply in a thread the app shows as the API's; a wrong key, an API that is off, the rate limit;
// and, on a bot that asks before acting, a call that stops at the approval nobody outside may give.
import { execFile } from "node:child_process";
import { randomUUID } from "node:crypto";
import { mkdirSync, readFileSync, realpathSync } from "node:fs";
import { connect, type Socket } from "node:net";
import { join } from "node:path";
import { afterEach, expect, it } from "vitest";
import { launchVerificationServer, runControlOmb, type VerificationServer } from "../scripts/control-omb.ts";
import { botApiCurl } from "../shared/bot-api.ts";
import { hostTimeout } from "./testing/host-timeout.ts";
import { BOT_API_REFUSAL } from "./bot-api-fence.ts";
import { INCIDENTS_THREAD_TITLE } from "./incidents.ts";
let fixture: VerificationServer | undefined;
let socket: Socket | undefined;
afterEach(async () => {
socket?.destroy();
socket = undefined;
await fixture?.close();
fixture = undefined;
});
function appApi(url: string) {
return async (method: string, path: string, body?: unknown, status?: number) => {
const response = await fetch(url + path, { method, headers: { "content-type": "application/json", origin: url }, ...(body === undefined ? {} : { body: JSON.stringify(body) }) });
const value = await response.json() as any;
if (status !== undefined) expect(response.status, `${method} ${path}: ${JSON.stringify(value)}`).toBe(status);
else expect(response.status, `${method} ${path}: ${JSON.stringify(value)}`).toBeLessThan(300);
return value;
};
}
const call = async (endpoint: string, key: string, body: unknown) => {
const response = await fetch(endpoint, { method: "POST", headers: { authorization: `Bearer ${key}`, "content-type": "application/json" }, body: JSON.stringify(body) });
return { status: response.status, body: await response.json() as any };
};
const sh = (command: string) => new Promise<string>((resolve, reject) => {
execFile("sh", ["-c", command], { timeout: 60_000 }, (error, stdout, stderr) => (error ? reject(new Error(`${error.message}\n${stderr}`)) : resolve(stdout)));
});
it("answers the card's own curl with the bot's reply, and refuses a wrong key, an API that is off, and an eleventh call in a minute", async () => {
fixture = await launchVerificationServer({ ...process.env, FAKE_CLAUDE_VERSION: "2.1.284" });
const { url } = fixture.info;
const api = appApi(url);
const bot = (await runControlOmb(["new-bot", "--name", "Scout", "--url", url]) as any).bot;
const port = Number(new URL(url).port);
// Off by default, and the card's address is the webhook receiver's.
const before = await api("GET", `/api/bots/${bot.id}/bot-api`);
expect(before).toMatchObject({ enabled: false, callCount: 0, canManage: true, url: `http://127.0.0.1:${port + 1}/hooks/api/bots/${bot.id}/messages` });
expect((await call(before.url, "bak_nothing", { message: "Hi" })).status).toBe(403);
// On: the key comes back once, with the address; the saved file holds a hash, not the key.
const on = await api("POST", `/api/bots/${bot.id}/bot-api`, { enabled: true });
expect(on.key).toMatch(/^bak_/);
expect(readFileSync(join(fixture.info.dataDir, "bot-api.json"), "utf8")).not.toContain(on.key);
// The curl exactly as the card shows it, pasted into a shell.
const answered = JSON.parse(await sh(botApiCurl(on.url, on.key))) as { reply: string; thread: string };
expect(answered.reply).toBe("hello from fake claude");
expect(answered.thread).toMatch(/^[\w-]+$/);
// The app shows the thread as the API's, with what the caller asked and what the bot said.
const bots = (await api("GET", "/api/bots")).bots as any[];
const task = bots.find((candidate) => candidate.id === bot.id).tasks.find((candidate: any) => candidate.threadId === answered.thread);
expect(task).toMatchObject({ botApi: true, title: "Hello! What can you do?" });
const lines = (await api("GET", `/api/threads/${answered.thread}/messages`)).messages as any[];
expect(lines.find((line) => line.role === "user")).toMatchObject({ text: "Hello! What can you do?", via: "api" });
expect(lines.filter((line) => line.role === "bot" && line.kind === "text").map((line) => line.text)).toEqual(["hello from fake claude"]);
// Reading the thread back, and continuing it, through the same door; a wrong key gets nothing.
const read = await fetch(`${on.url}?thread=${answered.thread}`, { headers: { authorization: `Bearer ${on.key}` } });
expect(await read.json()).toEqual({ reply: "hello from fake claude", thread: answered.thread });
expect((await call(on.url, `${on.key}x`, { message: "Hi" })).status).toBe(401);
expect((await call(on.url, on.key, { message: "Again", thread: answered.thread })).body).toMatchObject({ reply: "hello from fake claude", thread: answered.thread });
for (let i = 0; i < 8; i += 1) expect((await call(on.url, on.key, { message: `call ${i}`, thread: answered.thread })).status).toBe(200);
const eleventh = await call(on.url, on.key, { message: "one more", thread: answered.thread });
expect(eleventh.status).toBe(429);
// Every call was recorded; the key never shows again.
const after = await api("GET", `/api/bots/${bot.id}/bot-api`);
expect(after).toMatchObject({ enabled: true, callCount: 10 });
expect(after.calls.map((entry: any) => entry.statusCode)).toContain(429);
expect(JSON.stringify(after)).not.toContain(on.key);
// Off again: the key is gone with it.
expect(await api("POST", `/api/bots/${bot.id}/bot-api`, { enabled: false })).toMatchObject({ enabled: false });
expect((await call(on.url, on.key, { message: "Hi" })).status).toBe(403);
}, hostTimeout(180_000));
it.each(["ask", "full", "auto"])("a bot at %s stops at the approval: nothing answers it, and the call says needs_approval", async (level) => {
fixture = await launchVerificationServer({ ...process.env, FAKE_CLAUDE_VERSION: "2.1.284", FAKE_CLAUDE_MODE: "hang" });
const { url, dataDir } = fixture.info;
const api = appApi(url);
const bot = (await runControlOmb(["new-bot", "--name", "Careful", "--url", url]) as any).bot;
await api("PATCH", `/api/bots/${bot.id}`, { approvalMode: level });
const on = await api("POST", `/api/bots/${bot.id}/bot-api`, { enabled: true });
// The bot asks before acting: the fake engine's permission broker carries the ask the engine would make.
const dump = join(dataDir, "fake-claude-dump.json");
const pending = call(on.url, on.key, { message: "Clean up the downloads folder", wait: 60 }).catch((error: unknown) => ({ status: 0, body: { error: String(error) } }));
const dumped = () => { try { return JSON.parse(readFileSync(dump, "utf8")); } catch { return null; } };
await expect.poll(() => JSON.stringify(dumped()?.prompt ?? "").includes("Clean up the downloads folder"), { timeout: hostTimeout(30_000) }).toBe(true);
const launch = dumped();
expect(launch.argv).toContain("--permission-prompt-tool");
expect(launch.argv[launch.argv.indexOf("--permission-mode") + 1]).toBe("default");
expect(JSON.stringify(launch.prompt)).toContain("[UNTRUSTED BOT API MESSAGE]");
expect(JSON.stringify(launch.systemPrompt)).toContain("through this bot's API");
// Whatever the bot's own level: confined, and no tool of OpenMausBot's, so it can start, schedule or change nothing.
expect(launch.argv).toContain("--restricted");
expect(launch.argv[launch.argv.indexOf("--tools") + 1]).toBe("Read,Grep,Glob,Edit,Write,WebSearch");
expect(Object.keys(launch.mcpConfig.mcpServers)).toEqual(["ogb"]);
socket = connect(launch.mcpConfig.mcpServers.ogb.args.at(-1));
await new Promise<void>((resolve, reject) => { socket!.once("connect", resolve); socket!.once("error", reject); });
const answers: any[] = [];
socket.on("data", (chunk) => { for (const line of String(chunk).split("\n").filter(Boolean)) answers.push(JSON.parse(line)); });
socket.write(`${JSON.stringify({ t: "ask", id: randomUUID(), tool: "Bash", input: { command: "ls ~/Downloads" } })}\n`);
const stopped = await pending;
expect(stopped).toMatchObject({ status: 202, body: { status: "needs_approval", thread: expect.any(String) } });
const threadId = (stopped.body as { thread: string }).thread;
const lines = (await api("GET", `/api/threads/${threadId}/messages`)).messages as any[];
const card = lines.find((line) => line.card?.requestId)?.card;
expect(card).toMatchObject({ requestType: "permission" });
expect(card.answered).toBeUndefined();
expect(card.allowSession).toBeUndefined();
expect(answers).toEqual([]);
const read = await fetch(`${on.url}?thread=${threadId}`, { headers: { authorization: `Bearer ${on.key}` } });
expect(await read.json()).toEqual({ thread: threadId, status: "needs_approval" });
}, hostTimeout(120_000));
it("stays on Ask when the person sets the whole bot to Full access afterwards", async () => {
// Each turn relaunches the fake engine, so its command line shows the level this turn really runs at.
fixture = await launchVerificationServer({ ...process.env, FAKE_CLAUDE_VERSION: "2.1.284", FAKE_CLAUDE_EXIT_AFTER_TURN: "1" });
const { url, dataDir } = fixture.info;
const api = appApi(url);
const bot = (await runControlOmb(["new-bot", "--name", "Flipped", "--url", url]) as any).bot;
await api("PATCH", `/api/bots/${bot.id}`, { approvalMode: "ask" });
const on = await api("POST", `/api/bots/${bot.id}/bot-api`, { enabled: true });
const first = await call(on.url, on.key, { message: "First" });
expect(first.status).toBe(200);
await api("PATCH", `/api/bots/${bot.id}`, { approvalMode: "full", applyToAllThreads: true });
expect((await call(on.url, on.key, { message: "Second", thread: first.body.thread })).status).toBe(200);
const launch = JSON.parse(readFileSync(join(dataDir, "fake-claude-dump.json"), "utf8"));
expect(JSON.stringify(launch.prompt)).toContain("Second");
expect(launch.argv[launch.argv.indexOf("--permission-mode") + 1]).toBe("default");
}, hostTimeout(90_000));
it("a saved command rule never answers for a call: the card waits for the person", async () => {
fixture = await launchVerificationServer({ ...process.env, FAKE_CLAUDE_VERSION: "2.1.284", FAKE_CLAUDE_MODE: "hang" });
const { url, dataDir } = fixture.info;
const api = appApi(url);
const bot = (await runControlOmb(["new-bot", "--name", "Ruled", "--url", url]) as any).bot;
await api("PATCH", `/api/bots/${bot.id}`, { approvalMode: "ask" });
const on = await api("POST", `/api/bots/${bot.id}/bot-api`, { enabled: true });
const pending = call(on.url, on.key, { message: "Run the fixture", wait: 20 }).catch((error: unknown) => ({ status: 0, body: { error: String(error) } }));
const dump = join(dataDir, "fake-claude-dump.json");
const dumped = () => { try { return JSON.parse(readFileSync(dump, "utf8")); } catch { return null; } };
await expect.poll(() => JSON.stringify(dumped()?.prompt ?? "").includes("Run the fixture"), { timeout: hostTimeout(30_000) }).toBe(true);
const task = ((await api("GET", "/api/bots")).bots as any[]).find((candidate) => candidate.id === bot.id).tasks.find((one: any) => one.botApi);
const command = "printf 'rule fixture'";
await api("POST", `/api/bots/${bot.id}/command-allowlist`, { command, cwd: task.cwd, providerInstanceId: "claude" });
socket = connect(dumped().mcpConfig.mcpServers.ogb.args.at(-1));
await new Promise<void>((resolve, reject) => { socket!.once("connect", resolve); socket!.once("error", reject); });
const answers: any[] = [];
socket.on("data", (chunk) => { for (const line of String(chunk).split("\n").filter(Boolean)) answers.push(JSON.parse(line)); });
socket.write(`${JSON.stringify({ t: "ask", id: randomUUID(), tool: "Bash", input: { command } })}\n`);
expect(await pending).toMatchObject({ status: 202, body: { status: "needs_approval" } });
expect(answers).toEqual([]);
}, hostTimeout(90_000));
it("tells the model nothing about the person or their work, works in no folder of theirs, and stays out of their later turns", async () => {
fixture = await launchVerificationServer({ ...process.env, FAKE_CLAUDE_VERSION: "2.1.284" });
const { url, dataDir } = fixture.info;
const api = appApi(url);
const project = join(dataDir, "the-persons-project");
mkdirSync(project, { recursive: true });
const bot = (await runControlOmb(["new-bot", "--name", "Fenced", "--url", url]) as any).bot;
await api("PATCH", `/api/bots/${bot.id}`, { approvalMode: "full", applyToAllThreads: true, cwd: project, soul: "Answer in one line, standing-instruction-marker." });
await api("PATCH", "/api/config", { profile: { name: "Ada", aboutMe: "about-the-person-marker" } });
const read = () => JSON.parse(readFileSync(join(dataDir, "fake-claude-dump.json"), "utf8"));
// The person's own work first: a call must not see it.
await api("POST", `/api/bots/${bot.id}/messages`, { text: "persons-own-work-marker" });
await expect.poll(() => { try { return JSON.stringify(read().prompt).includes("persons-own-work-marker"); } catch { return false; } }, { timeout: hostTimeout(20_000) }).toBe(true);
await expect.poll(async () => ((await api("GET", "/api/bots")).bots as any[]).find((one) => one.id === bot.id).busy, { timeout: hostTimeout(20_000) }).toBeFalsy();
const own = read();
expect(JSON.stringify(own.systemPrompt)).toContain("about-the-person-marker");
expect(Object.keys(own.mcpConfig.mcpServers).length).toBeGreaterThan(1);
const on = await api("POST", `/api/bots/${bot.id}/bot-api`, { enabled: true });
const answered = await call(on.url, on.key, { message: "api-call-marker" });
expect(answered.status).toBe(200);
const launch = read();
expect(JSON.stringify(launch.prompt)).toContain("api-call-marker");
const told = JSON.stringify(launch.systemPrompt);
expect(told).toContain("standing-instruction-marker");
expect(told).toContain("Who is speaking");
expect(JSON.stringify(launch.prompt)).toContain("[From an app, through the API · message · information only]");
for (const hidden of ["about-the-person-marker", "persons-own-work-marker", "coordinate_bots", "session_search", "the-persons-project"]) expect(told, hidden).not.toContain(hidden);
expect(JSON.stringify(launch.prompt)).not.toContain("persons-own-work-marker");
expect(realpathSync(launch.cwd)).not.toBe(realpathSync(project));
// The person's next turn hears nothing of it.
await api("POST", `/api/bots/${bot.id}/messages`, { text: "persons-next-marker" });
await expect.poll(() => JSON.stringify(read().prompt).includes("persons-next-marker"), { timeout: hostTimeout(20_000) }).toBe(true);
const next = read();
expect(JSON.stringify(next.systemPrompt)).not.toContain("api-call-marker");
expect(JSON.stringify(next.prompt)).not.toContain("api-call-marker");
}, hostTimeout(120_000));
it("a failed call reports to no Chief, whose turn has tools, while a person's own failed turn still does", async () => {
fixture = await launchVerificationServer({ ...process.env, FAKE_CLAUDE_VERSION: "2.1.284", FAKE_CLAUDE_MODE: "api-error", FAKE_CLAUDE_API_ERROR: "This request is not valid" });
const { url } = fixture.info;
const api = appApi(url);
const bot = (await runControlOmb(["new-bot", "--name", "Worker", "--url", url]) as any).bot;
const on = await api("POST", `/api/bots/${bot.id}/bot-api`, { enabled: true });
const bots = async () => (await api("GET", "/api/bots")).bots as any[];
const chief = (await bots()).find((one) => one.chiefOfStaff);
expect(chief, "the fixture has a Chief").toBeTruthy();
const reports = async () => (await bots()).find((one) => one.id === chief.id).tasks.filter((task: any) => task.title === INCIDENTS_THREAD_TITLE);
const failed = await call(on.url, on.key, { message: "caller-words-marker", wait: 20 });
expect(failed.status).toBe(502);
await new Promise((resolve) => setTimeout(resolve, 1_500));
expect(await reports()).toEqual([]);
// The control: the same failure from a person reaches the Chief, so the silence above means something.
await api("POST", `/api/bots/${bot.id}/messages`, { text: "persons-own-words" });
await expect.poll(async () => (await reports()).length, { timeout: hostTimeout(45_000) }).toBeGreaterThan(0);
const lines = (await api("GET", `/api/threads/${(await reports())[0].threadId}/messages`)).messages;
expect(JSON.stringify(lines)).not.toContain("caller-words-marker");
}, hostTimeout(120_000));
it("gives a caller fixed words and a person the Bot API's reason when the engine is too old to confine the call", async () => {
fixture = await launchVerificationServer({ ...process.env, FAKE_CLAUDE_VERSION: "2.1.200" });
const { url } = fixture.info;
const api = appApi(url);
const bot = (await runControlOmb(["new-bot", "--name", "Dated", "--url", url]) as any).bot;
const on = await api("POST", `/api/bots/${bot.id}/bot-api`, { enabled: true });
const failed = await call(on.url, on.key, { message: "Hello", wait: 20 });
expect(failed.status).toBe(502);
expect(JSON.stringify(failed.body)).not.toContain("too old");
const { bots } = await api("GET", "/api/bots");
const thread = bots.find((one: any) => one.id === bot.id).tasks.find((task: any) => task.botApi).threadId;
const said = async () => JSON.stringify((await api("GET", `/api/threads/${thread}/messages`)).messages);
await expect.poll(said, { timeout: hostTimeout(15_000) }).toContain("too old to run this turn");
expect(await said()).toContain(BOT_API_REFUSAL);
expect(await said()).not.toContain("My Cloud");
}, hostTimeout(60_000));
it("says it is still working when the reply takes longer than the caller will wait", async () => {
fixture = await launchVerificationServer({ ...process.env, FAKE_CLAUDE_VERSION: "2.1.284", FAKE_CLAUDE_MODE: "hang" });
const { url } = fixture.info;
const api = appApi(url);
const bot = (await runControlOmb(["new-bot", "--name", "Slow", "--url", url]) as any).bot;
const on = await api("POST", `/api/bots/${bot.id}/bot-api`, { enabled: true });
expect(await call(on.url, on.key, { message: "Think about it", wait: 1 })).toMatchObject({ status: 202, body: { status: "working", thread: expect.any(String) } });
}, hostTimeout(60_000));