import { spawn, type ChildProcess } from "node:child_process"; import { mkdirSync, mkdtempSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { SessionRegistry } from "./sessions.ts"; import { removeTempDir, waitForExit } from "./testing/cleanup.ts"; import { workerPort } from "./testing/worker-port.ts"; const SERVER_DIR = dirname(fileURLToPath(import.meta.url)); const PORT = workerPort(28800, 10_000); const BASE = `http://127.0.0.1:${PORT}`; const posixOnly = describe.skipIf(process.platform === "win32"); const BOSS = "boss@example.test"; const ADA = "ada@example.test"; const SECRET = "sk-ant-api03-activity-fixture-secret-0123456789abcdef"; const MCP_SECRET = "mcp-activity-fixture-secret-value"; const ARG_SECRET = "acme_live_9f8e7d6c5b4a3f2e1d0c"; let child: ChildProcess; let home: string; let data: string; let log = ""; const tokens: Record = {}; let oldPhone = ""; const api = async (method: string, path: string, body?: unknown, as?: string, extra: Record = {}): Promise<{ status: number; body: any; text: string; headers: Headers }> => { const res = await fetch(`${BASE}${path}`, { method, headers: { ...(body !== undefined ? { "content-type": "application/json" } : {}), ...(as ? { authorization: `Bearer ${tokens[as]}` } : {}), ...extra }, body: body === undefined ? undefined : JSON.stringify(body), }); const text = await res.text(); let parsed: any = {}; try { parsed = JSON.parse(text); } catch { } return { status: res.status, body: parsed, text, headers: res.headers }; }; async function start() { child = spawn(process.execPath, [join(SERVER_DIR, "index.ts")], { cwd: join(SERVER_DIR, ".."), env: { ...(process.env.PATH ? { PATH: process.env.PATH } : {}), ...(process.env.SystemRoot ? { SystemRoot: process.env.SystemRoot } : {}), HOME: home, USERPROFILE: home, OMB_PORT: String(PORT), OMB_WEBHOOK_PORT: String(PORT + 1), }, stdio: ["ignore", "pipe", "pipe"], }); child.stdout!.on("data", (c) => (log += c)); child.stderr!.on("data", (c) => (log += c)); const deadline = Date.now() + 20_000; for (;;) { try { if ((await fetch(`${BASE}/api/health`)).ok) return; } catch { } if (Date.now() > deadline) throw new Error(`server never came up:\n${log}`); await new Promise((r) => setTimeout(r, 150)); } } /** A request whose body is held back until `release()`: the server has * started on it (and taken its "before") while other requests run. */ function slowRequest(method: string, path: string, body: unknown, as?: string) { const text = new TextEncoder().encode(JSON.stringify(body)); let release!: () => void; const gate = new Promise((resolve) => (release = resolve)); const stream = new ReadableStream({ async start(controller) { controller.enqueue(text.slice(0, 2)); await gate; controller.enqueue(text.slice(2)); controller.close(); }, }); const response = fetch(`${BASE}${path}`, { method, headers: { "content-type": "application/json", ...(as ? { authorization: `Bearer ${tokens[as]}` } : {}) }, body: stream, duplex: "half", } as RequestInit); return { release, response }; } async function activity(query = "", as = BOSS): Promise { // Rows are written once the change's response has gone out. await new Promise((r) => setTimeout(r, 150)); const res = await api("GET", `/api/admin-activity${query}`, undefined, as); expect(res.status, JSON.stringify(res.body)).toBe(200); return res.body.entries; } const month = () => new Date().toISOString().slice(0, 7); posixOnly("admin activity log", () => { beforeAll(async () => { home = mkdtempSync(join(tmpdir(), "omb-admin-activity-")); data = join(home, ".openmausbot"); mkdirSync(join(data, "decisions"), { recursive: true }); writeFileSync(join(data, "config.json"), JSON.stringify({ signIn: { admins: [BOSS], members: [ADA] } })); // One card Ada answered earlier, as the decision log recorded it. writeFileSync(join(data, "decisions", `${month()}.ndjson`), [ { at: new Date(Date.now() - 60_000).toISOString(), threadId: "t1", requestId: "r1", botName: "Ops", tool: "Bash", summary: "echo hi", decision: "user-approved", source: "user", actor: { kind: "session", sessionId: "s-ada", label: "ada's laptop", email: ADA } }, { at: new Date(Date.now() - 50_000).toISOString(), threadId: "t1", requestId: "r2", tool: "Read", decision: "auto-approved", source: "full-access" }, ].map((row) => JSON.stringify(row)).join("\n") + "\n"); const role = (email: string) => (email === BOSS ? ["admin", "client"] as const : ["client"] as const); const registry = new SessionRegistry({ file: join(data, "sessions.json"), emailScopes: (email) => [...role(email)] }); for (const email of [BOSS, ADA]) tokens[email] = registry.issue({ label: `${email.split("@")[0]}'s laptop`, email, scopes: [...role(email)] }).token; oldPhone = registry.issue({ label: "Old phone", scopes: ["client"] }).session.id; registry.close(); await start(); }, 40_000); afterAll(async () => { await waitForExit(child, { signal: "SIGTERM" }); await removeTempDir(home); }); it("is an admin's to read", async () => { expect((await api("GET", "/api/admin-activity", undefined, ADA)).status).toBe(403); expect((await api("GET", "/api/admin-activity.csv", undefined, ADA)).status).toBe(403); expect((await api("GET", "/api/admin-activity?what=everything", undefined, BOSS)).status).toBe(400); expect((await api("GET", "/api/admin-activity?from=2026-02-31", undefined, BOSS)).status).toBe(400); }); it("records settings, people, keys and budgets with who changed them, never a secret", async () => { expect((await api("PUT", "/api/config", { signIn: { admins: [BOSS], members: [ADA, "carol@example.test"] } }, BOSS)).status).toBe(200); expect((await api("PUT", "/api/config", { anthropic: { key: SECRET } }, BOSS)).status).toBe(200); expect((await api("PUT", "/api/config", { budgets: { monthlyUsd: 75 } }, BOSS)).status).toBe(200); expect((await api("PUT", "/api/config", { decisions: { retentionDays: 400 } })).status).toBe(200); // the owner, on this machine expect((await api("PUT", "/api/config", { profile: { name: "Ops desk" } }, undefined, { "x-openmausbot-cli": "1" })).status).toBe(200); const people = (await activity("?what=people"))[0]; expect(people).toMatchObject({ type: "admin", who: BOSS, what: "people", action: "people.update", changed: ["signIn.members"], before: { "signIn.members": [ADA] }, after: { "signIn.members": [ADA, "carol@example.test"] } }); const engine = (await activity("?what=engine"))[0]; expect(engine).toMatchObject({ who: BOSS, action: "engine.update", changed: ["anthropic.key"], after: { "anthropic.key": "[hidden]" } }); const budget = (await activity("?what=budget"))[0]; expect(budget).toMatchObject({ who: BOSS, changed: ["budgets.monthlyUsd"], after: { "budgets.monthlyUsd": 75 } }); const config = await activity("?what=config"); expect(config[0]).toMatchObject({ who: "Command line", changed: ["profile.name"], after: { "profile.name": "Ops desk" } }); expect(config[1]).toMatchObject({ who: "This computer", changed: ["decisions.retentionDays"], after: { "decisions.retentionDays": 400 } }); const dir = join(data, "admin-activity"); for (const name of readdirSync(dir)) { expect(readFileSync(join(dir, name), "utf8")).not.toContain("activity-fixture-secret"); expect(statSync(join(dir, name)).mode & 0o777).toBe(0o600); } }); it("records MCP servers, webhooks, bots, a bot's audience and sessions", async () => { const mcp = await api("POST", "/api/mcp/servers", { name: "fixture", command: process.execPath, args: ["--version", "--api-key", ARG_SECRET], env: { FIXTURE_TOKEN: MCP_SECRET } }, BOSS); expect(mcp.status, JSON.stringify(mcp.body)).toBe(201); const bot = await api("POST", "/api/bots", { name: "Audit Owl" }, BOSS); expect(bot.status).toBe(201); const botId = bot.body.bot.id as string; expect((await api("PATCH", `/api/bots/${botId}`, { visibility: { people: [ADA] } }, BOSS)).status).toBe(200); expect((await api("PATCH", `/api/bots/${botId}`, { parkDirectMessages: true }, BOSS)).status).toBe(200); expect((await api("PATCH", `/api/bots/${botId}`, { pinned: true }, ADA)).status).toBe(200); expect((await api("PATCH", `/api/bots/${botId}`, { visibility: { people: ["nope"] } }, BOSS)).status).toBe(400); const signOut = await api("POST", "/api/instances/no-such-engine/auth/sign-out", {}, BOSS); expect(signOut.status, JSON.stringify(signOut.body)).toBeGreaterThanOrEqual(400); const hook = await api("POST", "/api/webhooks", { name: "Audit feed", botId, prompt: "Read it.", enabled: false }, BOSS); expect(hook.status).toBe(201); expect((await api("DELETE", `/api/webhooks/${hook.body.webhook.id}`, undefined, BOSS)).status).toBe(200); expect((await api("DELETE", `/api/auth/sessions/${oldPhone}`, undefined, BOSS)).status).toBe(200); expect((await api("POST", "/api/auth/pairing", { label: "Reception iPad", scopes: ["client"] }, BOSS)).status).toBe(200); expect((await api("DELETE", `/api/bots/${botId}`, undefined, BOSS)).status).toBe(200); const rows = (await activity("?what=all")).filter((entry) => entry.type === "admin"); const actions = rows.map((entry) => entry.action); for (const action of ["mcp.update", "bot.create", "visibility.update", "bot.update", "webhook.create", "webhook.delete", "session.revoke", "pairing.create", "bot.delete"]) { expect(actions, action).toContain(action); } expect(actions.filter((action) => action === "bot.update")).toHaveLength(1); expect(actions).not.toContain("engine.auth-sign-out"); expect(actions.filter((action) => action === "visibility.update")).toHaveLength(1); const audience = rows.find((entry) => entry.action === "visibility.update"); expect(audience).toMatchObject({ who: BOSS, target: { kind: "bot", id: botId, name: "Audit Owl" }, before: { visibility: "everyone" }, after: { visibility: { people: [ADA] } } }); const mcpRow = rows.find((entry) => entry.action === "mcp.update"); expect(JSON.stringify(mcpRow)).not.toContain(MCP_SECRET); expect(JSON.stringify(mcpRow)).not.toContain(ARG_SECRET); expect(JSON.stringify(mcpRow)).toContain("--api-key"); expect(JSON.stringify(mcpRow)).toContain("FIXTURE_TOKEN"); expect(rows.find((entry) => entry.action === "session.revoke")).toMatchObject({ target: { id: oldPhone, name: "Old phone" } }); expect(rows.find((entry) => entry.action === "pairing.create")).toMatchObject({ after: { label: "Reception iPad", scopes: ["client"] } }); expect(rows.find((entry) => entry.action === "bot.delete")).toMatchObject({ target: { id: botId }, before: { visibility: { people: [ADA] } } }); }); it("shows answered cards with admin actions, filtered by who, what and when, and exports them", async () => { const all = await activity(); const approval = all.find((entry) => entry.type === "approval"); expect(approval).toMatchObject({ who: ADA, what: "user-approved", bot: "Ops", tool: "Bash" }); expect(all.some((entry) => entry.what === "auto-approved")).toBe(false); expect((await activity("?what=decisions")).map((entry) => entry.what).sort()).toEqual(["auto-approved", "user-approved"]); expect((await activity("?what=approvals")).map((entry) => entry.who)).toEqual([ADA]); expect((await activity(`?who=${encodeURIComponent("ADA@")}`)).map((entry) => entry.type)).toEqual(["approval"]); expect((await activity("?who=command")).map((entry) => entry.action)).toEqual(["config.update"]); const times = all.map((entry) => entry.at); expect([...times].sort().reverse()).toEqual(times); expect(await activity("?from=2020-01-01&to=2020-01-31")).toEqual([]); const csv = await api("GET", "/api/admin-activity.csv?what=all", undefined, BOSS); expect(csv.status).toBe(200); expect(csv.headers.get("content-type")).toMatch(/text\/csv/); expect(csv.text.split("\n")[0]).toBe("time,type,who,what,action,target,changed,before,after,bot,tool,summary,thread"); expect(csv.text).toContain("visibility.update"); expect(csv.text).not.toContain("activity-fixture-secret"); expect(csv.text).not.toContain(ARG_SECRET); }); it("puts each concurrent change down to the admin who made it", async () => { const bot = (await api("POST", "/api/bots", { name: "Race Finch" }, BOSS)).body.bot as { id: string }; // Boss's request is slow: its body arrives only after the owner on this // machine has made (and finished) a change of their own to the same thing. const slowBot = slowRequest("PATCH", `/api/bots/${bot.id}`, { parkDirectMessages: true }, BOSS); await new Promise((r) => setTimeout(r, 300)); expect((await api("PATCH", `/api/bots/${bot.id}`, { composio: false })).status).toBe(200); slowBot.release(); expect((await slowBot.response).status).toBe(200); const slowConfig = slowRequest("PUT", "/api/config", { profile: { name: "Race desk" } }, BOSS); await new Promise((r) => setTimeout(r, 300)); expect((await api("PUT", "/api/config", { decisions: { retentionDays: 365 } })).status).toBe(200); slowConfig.release(); expect((await slowConfig.response).status).toBe(200); const rows = (await activity("?what=all")).filter((entry) => entry.type === "admin"); const botRows = rows.filter((entry) => entry.action === "bot.update" && entry.target?.id === bot.id); expect(botRows.map((entry) => [entry.who, entry.changed]).sort()).toEqual([ [BOSS, ["parkDirectMessages"]], ["This computer", ["composio"]], ].sort()); const configRows = rows.filter((entry) => entry.action === "config.update" && (JSON.stringify(entry.after).includes("Race desk") || JSON.stringify(entry.after).includes("365"))); expect(configRows.map((entry) => [entry.who, entry.changed]).sort()).toEqual([ [BOSS, ["profile.name"]], ["This computer", ["decisions.retentionDays"]], ].sort()); }); it("records only while the workspace is shared, including the change that ends or starts sharing", async () => { expect((await api("PUT", "/api/config", { signIn: { admins: [BOSS], members: [] } }, BOSS)).status).toBe(200); expect((await activity("?what=people"))[0]).toMatchObject({ who: BOSS, changed: ["signIn.members"] }); // (an unused chat-only pairing code from earlier also counts as sharing) for (const pairing of (await api("GET", "/api/auth/pairing", undefined, BOSS)).body.pairings ?? []) { expect((await api("DELETE", `/api/auth/pairing/${pairing.id}`, undefined, BOSS)).status).toBe(200); } expect((await api("PUT", "/api/config", { profile: { name: "Solo desk" } }, BOSS)).status).toBe(200); expect((await api("POST", "/api/bots", { name: "Solo Wren" }, BOSS)).status).toBe(201); let rows = await activity("?what=all"); expect(JSON.stringify(rows)).not.toContain("Solo desk"); expect(JSON.stringify(rows)).not.toContain("Solo Wren"); expect((await api("GET", "/api/admin-activity", undefined, BOSS)).body.recording).toBe(false); const opened = await api("POST", "/api/auth/pairing", { label: "Front desk phone", scopes: ["client"] }, BOSS); expect(opened.status).toBe(200); const paired = await api("POST", "/api/auth/pair", { code: opened.body.code, label: "Front desk phone" }); expect(paired.status, JSON.stringify(paired.body)).toBe(200); const phone = paired.body.session.id as string; expect((await api("GET", "/api/admin-activity", undefined, BOSS)).body.recording).toBe(true); expect((await api("DELETE", `/api/auth/sessions/${phone}`, undefined, BOSS)).status).toBe(200); expect((await api("GET", "/api/admin-activity", undefined, BOSS)).body.recording).toBe(false); rows = await activity("?what=session"); expect(rows.map((entry) => entry.action)).toEqual(expect.arrayContaining(["pairing.create", "session.revoke"])); expect(rows.find((entry) => entry.action === "session.revoke")).toMatchObject({ who: BOSS, target: { id: phone, name: "Front desk phone" } }); }); });