4e5ca2e20401 | atagen | 2026-09-28 11:42 UTC | 1 | import assert from "node:assert/strict"; |
4e5ca2e20401 | atagen | 2026-09-28 11:42 UTC | 2 | import { spawnSync } from "node:child_process"; |
4e5ca2e20401 | atagen | 2026-09-28 11:42 UTC | 3 | import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; |
4e5ca2e20401 | atagen | 2026-09-28 11:42 UTC | 4 | import { tmpdir } from "node:os"; |
4e5ca2e20401 | atagen | 2026-09-28 11:42 UTC | 5 | import { join } from "node:path"; |
4e5ca2e20401 | atagen | 2026-09-28 11:42 UTC | 6 | import { after, beforeEach, test } from "node:test"; |
4e5ca2e20401 | atagen | 2026-09-28 11:42 UTC | 7 | import roundabout from "../plugin/omp/index.js"; |
4e5ca2e20401 | atagen | 2026-09-28 11:42 UTC | 8 | |
4e5ca2e20401 | atagen | 2026-09-28 11:42 UTC | 9 | const temporary = mkdtempSync(join(tmpdir(), "roundabout-omp-")); |
4e5ca2e20401 | atagen | 2026-09-28 11:42 UTC | 10 | const captured = join(temporary, "input.json"); |
4e5ca2e20401 | atagen | 2026-09-28 11:42 UTC | 11 | const mockCli = process.env.ROUNDABOUT_TEST_CLI; |
4e5ca2e20401 | atagen | 2026-09-28 11:42 UTC | 12 | const handlers = new Map(); |
4e5ca2e20401 | atagen | 2026-09-28 11:42 UTC | 13 | const notifications = []; |
4e5ca2e20401 | atagen | 2026-09-28 11:42 UTC | 14 | const context = { |
4e5ca2e20401 | atagen | 2026-09-28 11:42 UTC | 15 | cwd: temporary, |
4e5ca2e20401 | atagen | 2026-09-28 11:42 UTC | 16 | hasUI: true, |
4e5ca2e20401 | atagen | 2026-09-28 11:42 UTC | 17 | agent: { kind: "main" }, |
4e5ca2e20401 | atagen | 2026-09-28 11:42 UTC | 18 | sessionManager: { getSessionId: () => "omp-session" }, |
4e5ca2e20401 | atagen | 2026-09-28 11:42 UTC | 19 | ui: { notify: (...args) => notifications.push(args) }, |
4e5ca2e20401 | atagen | 2026-09-28 11:42 UTC | 20 | }; |
4e5ca2e20401 | atagen | 2026-09-28 11:42 UTC | 21 | const bash = { toolName: "bash", input: { command: "cargo build", env: { KEEP: "yes" }, async: true } }; |
4e5ca2e20401 | atagen | 2026-09-28 11:42 UTC | 22 | |
4e5ca2e20401 | atagen | 2026-09-28 11:42 UTC | 23 | roundabout({ on: (name, handler) => handlers.set(name, handler) }); |
4e5ca2e20401 | atagen | 2026-09-28 11:42 UTC | 24 | |
4e5ca2e20401 | atagen | 2026-09-28 11:42 UTC | 25 | beforeEach(() => { |
4e5ca2e20401 | atagen | 2026-09-28 11:42 UTC | 26 | process.env.ROUNDABOUT_CLI = mockCli; |
4e5ca2e20401 | atagen | 2026-09-28 11:42 UTC | 27 | process.env.ROUNDABOUT_TEST_INPUT = captured; |
4e5ca2e20401 | atagen | 2026-09-28 11:42 UTC | 28 | delete process.env.ROUNDABOUT_TEST_OUTPUT; |
4e5ca2e20401 | atagen | 2026-09-28 11:42 UTC | 29 | delete process.env.ROUNDABOUT_TEST_EXIT; |
4e5ca2e20401 | atagen | 2026-09-28 11:42 UTC | 30 | notifications.length = 0; |
4e5ca2e20401 | atagen | 2026-09-28 11:42 UTC | 31 | rmSync(captured, { force: true }); |
4e5ca2e20401 | atagen | 2026-09-28 11:42 UTC | 32 | }); |
4e5ca2e20401 | atagen | 2026-09-28 11:42 UTC | 33 | |
4e5ca2e20401 | atagen | 2026-09-28 11:42 UTC | 34 | after(() => rmSync(temporary, { recursive: true, force: true })); |
4e5ca2e20401 | atagen | 2026-09-28 11:42 UTC | 35 | |
4e5ca2e20401 | atagen | 2026-09-28 11:42 UTC | 36 | function output(value) { |
4e5ca2e20401 | atagen | 2026-09-28 11:42 UTC | 37 | process.env.ROUNDABOUT_TEST_OUTPUT = JSON.stringify(value); |
4e5ca2e20401 | atagen | 2026-09-28 11:42 UTC | 38 | } |
4e5ca2e20401 | atagen | 2026-09-28 11:42 UTC | 39 | |
4e5ca2e20401 | atagen | 2026-09-28 11:42 UTC | 40 | function input() { |
4e5ca2e20401 | atagen | 2026-09-28 11:42 UTC | 41 | return JSON.parse(readFileSync(captured, "utf8")); |
4e5ca2e20401 | atagen | 2026-09-28 11:42 UTC | 42 | } |
4e5ca2e20401 | atagen | 2026-09-28 11:42 UTC | 43 | |
4e5ca2e20401 | atagen | 2026-09-28 11:42 UTC | 44 | test("maps build guards and preserves wrapped command arguments", async () => { |
4e5ca2e20401 | atagen | 2026-09-28 11:42 UTC | 45 | output({ hookSpecificOutput: { permissionDecision: "deny", permissionDecisionReason: "use rabt run" } }); |
4e5ca2e20401 | atagen | 2026-09-28 11:42 UTC | 46 | assert.deepEqual(await handlers.get("tool_call")(bash, context), { block: true, reason: "use rabt run" }); |
4e5ca2e20401 | atagen | 2026-09-28 11:42 UTC | 47 | assert.equal(input().event, "omp-pre-tool-use"); |
4e5ca2e20401 | atagen | 2026-09-28 11:42 UTC | 48 | assert.equal(input().input.tool_name, "Bash"); |
4e5ca2e20401 | atagen | 2026-09-28 11:42 UTC | 49 | output({ hookSpecificOutput: { updatedInput: { command: "rabt run -s m -- cargo build" } } }); |
4e5ca2e20401 | atagen | 2026-09-28 11:42 UTC | 50 | const result = await handlers.get("tool_call")({ ...bash, input: { ...bash.input, cwd: "/project" } }, context); |
4e5ca2e20401 | atagen | 2026-09-28 11:42 UTC | 51 | assert.equal(input().input.cwd, "/project"); |
4e5ca2e20401 | atagen | 2026-09-28 11:42 UTC | 52 | assert.equal(result.input.command, "rabt run -s m -- cargo build"); |
4e5ca2e20401 | atagen | 2026-09-28 11:42 UTC | 53 | assert.equal(result.input.async, true); |
4e5ca2e20401 | atagen | 2026-09-28 11:42 UTC | 54 | assert.deepEqual(result.input.env, { KEEP: "yes", ROUNDABOUT_SESSION_ID: "omp-session" }); |
4e5ca2e20401 | atagen | 2026-09-28 11:42 UTC | 55 | }); |
4e5ca2e20401 | atagen | 2026-09-28 11:42 UTC | 56 | |
4e5ca2e20401 | atagen | 2026-09-28 11:42 UTC | 57 | test("fails open for malformed output, nonzero exits and missing CLI", async () => { |
4e5ca2e20401 | atagen | 2026-09-28 11:42 UTC | 58 | process.env.ROUNDABOUT_TEST_OUTPUT = "not JSON"; |
4e5ca2e20401 | atagen | 2026-09-28 11:42 UTC | 59 | assert.equal((await handlers.get("tool_call")(bash, context)).input.command, "cargo build"); |
4e5ca2e20401 | atagen | 2026-09-28 11:42 UTC | 60 | output({ hookSpecificOutput: { permissionDecision: "deny" } }); |
4e5ca2e20401 | atagen | 2026-09-28 11:42 UTC | 61 | process.env.ROUNDABOUT_TEST_EXIT = "1"; |
4e5ca2e20401 | atagen | 2026-09-28 11:42 UTC | 62 | assert.equal((await handlers.get("tool_call")(bash, context)).block, undefined); |
4e5ca2e20401 | atagen | 2026-09-28 11:42 UTC | 63 | process.env.ROUNDABOUT_CLI = join(temporary, "missing-rabt"); |
4e5ca2e20401 | atagen | 2026-09-28 11:42 UTC | 64 | assert.equal((await handlers.get("tool_call")(bash, context)).block, undefined); |
4e5ca2e20401 | atagen | 2026-09-28 11:42 UTC | 65 | }); |
4e5ca2e20401 | atagen | 2026-09-28 11:42 UTC | 66 | |
4e5ca2e20401 | atagen | 2026-09-28 11:42 UTC | 67 | test("maps artifact policies and bounds advisory continuations", async () => { |
4e5ca2e20401 | atagen | 2026-09-28 11:42 UTC | 68 | output({ decision: "block", reason: "clean artifacts" }); |
4e5ca2e20401 | atagen | 2026-09-28 11:42 UTC | 69 | assert.deepEqual(await handlers.get("session_stop")({ stop_hook_active: true }, context), { |
4e5ca2e20401 | atagen | 2026-09-28 11:42 UTC | 70 | decision: "block", reason: "clean artifacts", |
4e5ca2e20401 | atagen | 2026-09-28 11:42 UTC | 71 | }); |
4e5ca2e20401 | atagen | 2026-09-28 11:42 UTC | 72 | output({ hookSpecificOutput: { additionalContext: "clean artifacts" } }); |
4e5ca2e20401 | atagen | 2026-09-28 11:42 UTC | 73 | assert.deepEqual(await handlers.get("session_stop")({ stop_hook_active: false }, context), { |
4e5ca2e20401 | atagen | 2026-09-28 11:42 UTC | 74 | continue: true, additionalContext: "clean artifacts", |
4e5ca2e20401 | atagen | 2026-09-28 11:42 UTC | 75 | }); |
4e5ca2e20401 | atagen | 2026-09-28 11:42 UTC | 76 | assert.equal(await handlers.get("session_stop")({ stop_hook_active: true }, context), undefined); |
4e5ca2e20401 | atagen | 2026-09-28 11:42 UTC | 77 | output({ systemMessage: "over cap" }); |
4e5ca2e20401 | atagen | 2026-09-28 11:42 UTC | 78 | await handlers.get("session_stop")({ stop_hook_active: false }, context); |
4e5ca2e20401 | atagen | 2026-09-28 11:42 UTC | 79 | assert.deepEqual(notifications, [["over cap", "warning"]]); |
4e5ca2e20401 | atagen | 2026-09-28 11:42 UTC | 80 | }); |
4e5ca2e20401 | atagen | 2026-09-28 11:42 UTC | 81 | |
4e5ca2e20401 | atagen | 2026-09-28 11:42 UTC | 82 | test("the CLI tracks OMP sessions ahead of inherited agent identities", () => { |
4e5ca2e20401 | atagen | 2026-09-28 11:42 UTC | 83 | const rabt = process.env.ROUNDABOUT_TEST_RABT; |
4e5ca2e20401 | atagen | 2026-09-28 11:42 UTC | 84 | const config = join(temporary, "config.toml"); |
4e5ca2e20401 | atagen | 2026-09-28 11:42 UTC | 85 | writeFileSync(config, '[queue]\nlinger = "1s"\n'); |
4e5ca2e20401 | atagen | 2026-09-28 11:42 UTC | 86 | const env = { |
4e5ca2e20401 | atagen | 2026-09-28 11:42 UTC | 87 | ...process.env, |
4e5ca2e20401 | atagen | 2026-09-28 11:42 UTC | 88 | ROUNDABOUT_CONFIG: config, |
4e5ca2e20401 | atagen | 2026-09-28 11:42 UTC | 89 | ROUNDABOUT_STATE_DIR: join(temporary, "state"), |
4e5ca2e20401 | atagen | 2026-09-28 11:42 UTC | 90 | ROUNDABOUT_RUNTIME_DIR: join(temporary, "run"), |
4e5ca2e20401 | atagen | 2026-09-28 11:42 UTC | 91 | ROUNDABOUT_SESSION_ID: "omp-session", |
4e5ca2e20401 | atagen | 2026-09-28 11:42 UTC | 92 | CLAUDE_CODE_SESSION_ID: "inherited-claude", |
4e5ca2e20401 | atagen | 2026-09-28 11:42 UTC | 93 | CODEX_THREAD_ID: "inherited-codex", |
4e5ca2e20401 | atagen | 2026-09-28 11:42 UTC | 94 | }; |
4e5ca2e20401 | atagen | 2026-09-28 11:42 UTC | 95 | function run(args, payload) { |
4e5ca2e20401 | atagen | 2026-09-28 11:42 UTC | 96 | const result = spawnSync(rabt, args, { env, input: payload, encoding: "utf8", timeout: 10000 }); |
4e5ca2e20401 | atagen | 2026-09-28 11:42 UTC | 97 | assert.equal(result.status, 0, result.stderr); |
4e5ca2e20401 | atagen | 2026-09-28 11:42 UTC | 98 | return result.stdout; |
4e5ca2e20401 | atagen | 2026-09-28 11:42 UTC | 99 | } |
4e5ca2e20401 | atagen | 2026-09-28 11:42 UTC | 100 | run(["expect", "-s", "s"]); |
4e5ca2e20401 | atagen | 2026-09-28 11:42 UTC | 101 | assert.equal(JSON.parse(run(["status", "--json"])).intents[0].session, "omp-session"); |
4e5ca2e20401 | atagen | 2026-09-28 11:42 UTC | 102 | run(["hook", "omp-session-end"], JSON.stringify({ session_id: "omp-session" })); |
4e5ca2e20401 | atagen | 2026-09-28 11:42 UTC | 103 | assert.equal(JSON.parse(run(["status", "--json"])).intents.length, 0); |
4e5ca2e20401 | atagen | 2026-09-28 11:42 UTC | 104 | run(["expect", "--session", "explicit", "-s", "s"]); |
4e5ca2e20401 | atagen | 2026-09-28 11:42 UTC | 105 | assert.equal(JSON.parse(run(["status", "--json"])).intents[0].session, "explicit"); |
4e5ca2e20401 | atagen | 2026-09-28 11:42 UTC | 106 | run(["expect", "--session", "explicit", "--done"]); |
4e5ca2e20401 | atagen | 2026-09-28 11:42 UTC | 107 | }); |