feat(exec-events): bead-8 - add exec events tests
This commit is contained in:
parent
97c3c5f7f7
commit
69f4e77dff
166
src/agents/bash-tools.exec.exec-events.test.ts
Normal file
166
src/agents/bash-tools.exec.exec-events.test.ts
Normal file
@ -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<typeof import("../infra/exec-approvals.js")>();
|
||||||
|
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<void>,
|
||||||
|
) {
|
||||||
|
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);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
88
src/infra/exec-events.test.ts
Normal file
88
src/infra/exec-events.test.ts
Normal file
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user