From 69f4e77dffbf25ca7a3a71fa5825d64696ea10a7 Mon Sep 17 00:00:00 2001 From: "Grace (Clawdbot)" Date: Tue, 27 Jan 2026 14:34:11 +0100 Subject: [PATCH] feat(exec-events): bead-8 - add exec events tests --- .../bash-tools.exec.exec-events.test.ts | 166 ++++++++++++++++++ src/infra/exec-events.test.ts | 88 ++++++++++ 2 files changed, 254 insertions(+) create mode 100644 src/agents/bash-tools.exec.exec-events.test.ts create mode 100644 src/infra/exec-events.test.ts diff --git a/src/agents/bash-tools.exec.exec-events.test.ts b/src/agents/bash-tools.exec.exec-events.test.ts new file mode 100644 index 000000000..99c7bc216 --- /dev/null +++ b/src/agents/bash-tools.exec.exec-events.test.ts @@ -0,0 +1,166 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { describe, expect, it, vi } from "vitest"; +import type { HooksExecConfig } from "../config/types.hooks.js"; +import { withTempHome } from "../config/test-helpers.js"; +import type { ExecApprovalsResolved } from "../infra/exec-approvals.js"; +import type { ExecEventPayload } from "../infra/exec-events.js"; + +vi.mock("../infra/exec-approvals.js", async (importOriginal) => { + const mod = await importOriginal(); + const approvals: ExecApprovalsResolved = { + path: "/tmp/exec-approvals.json", + socketPath: "/tmp/exec-approvals.sock", + token: "token", + defaults: { + security: "full", + ask: "off", + askFallback: "full", + autoAllowSkills: false, + }, + agent: { + security: "full", + ask: "off", + askFallback: "full", + autoAllowSkills: false, + }, + allowlist: [], + file: { + version: 1, + socket: { path: "/tmp/exec-approvals.sock", token: "token" }, + defaults: { + security: "full", + ask: "off", + askFallback: "full", + autoAllowSkills: false, + }, + agents: {}, + }, + }; + return { ...mod, resolveExecApprovals: () => approvals }; +}); + +type ExecEventsDeps = { + execEvents: typeof import("../infra/exec-events.js"); + execContext: typeof import("../infra/exec-events-context.js"); + bashExec: typeof import("./bash-tools.exec.js"); + registry: typeof import("./bash-process-registry.js"); +}; + +async function withExecEventsConfig( + exec: HooksExecConfig, + run: (deps: ExecEventsDeps) => Promise, +) { + await withTempHome(async (home) => { + const prevConfigPath = process.env.CLAWDBOT_CONFIG_PATH; + const configPath = path.join(home, ".clawdbot", "moltbot.json"); + process.env.CLAWDBOT_CONFIG_PATH = configPath; + await fs.mkdir(path.dirname(configPath), { recursive: true }); + await fs.writeFile( + configPath, + JSON.stringify( + { + hooks: { exec }, + }, + null, + 2, + ), + "utf8", + ); + + vi.resetModules(); + const execEvents = await import("../infra/exec-events.js"); + const execContext = await import("../infra/exec-events-context.js"); + const bashExec = await import("./bash-tools.exec.js"); + const registry = await import("./bash-process-registry.js"); + execEvents.resetExecEventsForTest(); + + try { + await run({ execEvents, execContext, bashExec, registry }); + } finally { + registry.resetProcessRegistryForTests(); + execEvents.resetExecEventsForTest(); + if (prevConfigPath === undefined) delete process.env.CLAWDBOT_CONFIG_PATH; + else process.env.CLAWDBOT_CONFIG_PATH = prevConfigPath; + vi.resetModules(); + } + }); +} + +describe("exec events emission", () => { + it("emits events for whitelisted commands", async () => { + const events: ExecEventPayload[] = []; + await withExecEventsConfig( + { + emitEvents: true, + commandWhitelist: ["node"], + outputThrottleMs: 10, + outputMaxChunkBytes: 4096, + }, + async ({ execEvents, execContext, bashExec }) => { + const unsubscribe = execEvents.onExecEvent((evt) => events.push(evt)); + const tool = bashExec.createExecTool({ + allowBackground: false, + host: "gateway", + security: "full", + ask: "off", + }); + const script = + "console.log('out-1'); console.error('err-1'); setTimeout(() => console.log('out-2'), 25)"; + const command = `node -e "${script}"`; + + const result = await execContext.runWithExecEventContext( + { + runId: "run-exec-events", + sessionKey: "main", + }, + () => tool.execute("tool-call", { command }), + ); + unsubscribe(); + + expect(result.details.status).toBe("completed"); + const eventNames = events.map((evt) => evt.event); + expect(eventNames).toContain("exec.started"); + expect(eventNames).toContain("exec.completed"); + expect(eventNames.some((evt) => evt === "exec.output")).toBe(true); + + const completed = events.find((evt) => evt.event === "exec.completed"); + expect(completed?.context?.runId).toBe("run-exec-events"); + expect(completed?.context?.sessionKey).toBe("main"); + }, + ); + }); + + it("stays silent for non-whitelisted commands", async () => { + const events: ExecEventPayload[] = []; + await withExecEventsConfig( + { + emitEvents: true, + commandWhitelist: ["node"], + outputThrottleMs: 10, + outputMaxChunkBytes: 4096, + }, + async ({ execEvents, execContext, bashExec }) => { + const unsubscribe = execEvents.onExecEvent((evt) => events.push(evt)); + const tool = bashExec.createExecTool({ + allowBackground: false, + host: "gateway", + security: "full", + ask: "off", + }); + + const result = await execContext.runWithExecEventContext( + { + runId: "run-exec-events-muted", + sessionKey: "main", + }, + () => tool.execute("tool-call", { command: "echo hi" }), + ); + unsubscribe(); + + expect(result.details.status).toBe("completed"); + expect(events).toHaveLength(0); + }, + ); + }); +}); diff --git a/src/infra/exec-events.test.ts b/src/infra/exec-events.test.ts new file mode 100644 index 000000000..1e18e297c --- /dev/null +++ b/src/infra/exec-events.test.ts @@ -0,0 +1,88 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + createThrottledExecOutputBuffer, + matchExecCommandAgainstWhitelist, + resolveExecEventsConfig, +} from "./exec-events.js"; + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("exec event whitelist matching", () => { + it("matches a direct command", () => { + const cfg = resolveExecEventsConfig(); + const result = matchExecCommandAgainstWhitelist("codex run", cfg); + expect(result).toEqual({ matched: true, commandName: "codex" }); + }); + + it("matches wrapped commands", () => { + const cfg = resolveExecEventsConfig(); + const result = matchExecCommandAgainstWhitelist("npx playwright test", cfg); + expect(result).toEqual({ matched: true, commandName: "playwright" }); + }); + + it("matches commands with env prefixes", () => { + const cfg = resolveExecEventsConfig(); + const result = matchExecCommandAgainstWhitelist("FOO=1 codex run", cfg); + expect(result).toEqual({ matched: true, commandName: "codex" }); + }); +}); + +describe("exec output throttling", () => { + it("emits at most once per throttle window", () => { + vi.useFakeTimers(); + const flushes: string[] = []; + const buffer = createThrottledExecOutputBuffer({ + throttleMs: 100, + maxChunkBytes: 1024, + maxBufferBytes: 4096, + onFlush: (chunks) => { + const combined = chunks.map((chunk) => chunk.output).join(""); + flushes.push(combined); + }, + }); + + buffer.append("stdout", "a"); + buffer.append("stdout", "b"); + buffer.append("stdout", "c"); + + expect(flushes).toHaveLength(0); + vi.advanceTimersByTime(100); + expect(flushes).toHaveLength(1); + expect(flushes[0]).toBe("abc"); + + buffer.append("stdout", "d"); + vi.advanceTimersByTime(99); + expect(flushes).toHaveLength(1); + vi.advanceTimersByTime(1); + expect(flushes).toHaveLength(2); + expect(flushes[1]).toBe("d"); + + buffer.dispose(); + }); + + it("caps chunks to the configured max bytes", () => { + const outputs: string[] = []; + const buffer = createThrottledExecOutputBuffer({ + throttleMs: 0, + maxChunkBytes: 4, + maxBufferBytes: 1024, + onFlush: (chunks) => { + for (const chunk of chunks) outputs.push(chunk.output); + }, + }); + + const payload = "abcdefghijklmnopqrstuvwxyz"; + buffer.append("stdout", payload); + buffer.flushAll(); + + expect(outputs.length).toBeGreaterThan(1); + for (const chunk of outputs) { + expect(chunk.length).toBeLessThanOrEqual(4); + } + expect(outputs.join("")).toBe(payload); + + buffer.dispose(); + }); +});