Blame · Line-by-line history
agent-runtime.test.ts
Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.
| a6d8fd5 | 1 | /** |
| 2 | * Block K1 — Agent runtime unit tests. | |
| 3 | * | |
| 4 | * Covers the pure helpers (state machine + log truncation) plus the | |
| 5 | * runSandboxed primitive via real subprocesses. DB helpers are intentionally | |
| 6 | * not tested here — they hit Neon and are covered by integration flow. | |
| 7 | */ | |
| 8 | ||
| 9 | import { describe, it, expect } from "bun:test"; | |
| 10 | import { | |
| 11 | type AgentRunStatus, | |
| 12 | canTransition, | |
| 13 | isTerminalStatus, | |
| 14 | runSandboxed, | |
| 15 | truncateError, | |
| 16 | truncateLog, | |
| 17 | __internal, | |
| 18 | } from "../lib/agent-runtime"; | |
| 19 | ||
| 20 | const ALL_STATUSES: AgentRunStatus[] = [ | |
| 21 | "queued", | |
| 22 | "running", | |
| 23 | "succeeded", | |
| 24 | "failed", | |
| 25 | "killed", | |
| 26 | "timeout", | |
| 27 | ]; | |
| 28 | ||
| 29 | describe("agent-runtime — isTerminalStatus", () => { | |
| 30 | it("treats queued + running as non-terminal", () => { | |
| 31 | expect(isTerminalStatus("queued")).toBe(false); | |
| 32 | expect(isTerminalStatus("running")).toBe(false); | |
| 33 | }); | |
| 34 | ||
| 35 | it("treats succeeded / failed / killed / timeout as terminal", () => { | |
| 36 | expect(isTerminalStatus("succeeded")).toBe(true); | |
| 37 | expect(isTerminalStatus("failed")).toBe(true); | |
| 38 | expect(isTerminalStatus("killed")).toBe(true); | |
| 39 | expect(isTerminalStatus("timeout")).toBe(true); | |
| 40 | }); | |
| 41 | ||
| 42 | it("is exhaustive over AgentRunStatus", () => { | |
| 43 | // Every status value must have a defined answer (no undefined bubbling). | |
| 44 | for (const s of ALL_STATUSES) { | |
| 45 | expect(typeof isTerminalStatus(s)).toBe("boolean"); | |
| 46 | } | |
| 47 | }); | |
| 48 | }); | |
| 49 | ||
| 50 | describe("agent-runtime — canTransition", () => { | |
| 51 | it("allows the happy path queued → running → succeeded", () => { | |
| 52 | expect(canTransition("queued", "running")).toBe(true); | |
| 53 | expect(canTransition("running", "succeeded")).toBe(true); | |
| 54 | }); | |
| 55 | ||
| 56 | it("allows running → failed | timeout | killed", () => { | |
| 57 | expect(canTransition("running", "failed")).toBe(true); | |
| 58 | expect(canTransition("running", "timeout")).toBe(true); | |
| 59 | expect(canTransition("running", "killed")).toBe(true); | |
| 60 | }); | |
| 61 | ||
| 62 | it("allows queued → killed (operator cancel before start)", () => { | |
| 63 | expect(canTransition("queued", "killed")).toBe(true); | |
| 64 | }); | |
| 65 | ||
| 66 | it("forbids going backward from any terminal state", () => { | |
| 67 | for (const terminal of ["succeeded", "failed", "killed", "timeout"] as const) { | |
| 68 | for (const to of ALL_STATUSES) { | |
| 69 | expect(canTransition(terminal, to)).toBe(false); | |
| 70 | } | |
| 71 | } | |
| 72 | }); | |
| 73 | ||
| 74 | it("forbids self-transitions", () => { | |
| 75 | for (const s of ALL_STATUSES) { | |
| 76 | expect(canTransition(s, s)).toBe(false); | |
| 77 | } | |
| 78 | }); | |
| 79 | ||
| 80 | it("forbids skipping running (queued → succeeded/failed/timeout)", () => { | |
| 81 | expect(canTransition("queued", "succeeded")).toBe(false); | |
| 82 | expect(canTransition("queued", "failed")).toBe(false); | |
| 83 | expect(canTransition("queued", "timeout")).toBe(false); | |
| 84 | }); | |
| 85 | ||
| 86 | it("forbids running → queued (no re-queue)", () => { | |
| 87 | expect(canTransition("running", "queued")).toBe(false); | |
| 88 | }); | |
| 89 | }); | |
| 90 | ||
| 91 | describe("agent-runtime — truncateLog", () => { | |
| 92 | it("returns a concatenation when well under the cap", () => { | |
| 93 | const out = truncateLog("abc", "def", 1024); | |
| 94 | expect(out).toBe("abcdef"); | |
| 95 | }); | |
| 96 | ||
| 97 | it("returns exactly the combined string at the cap boundary", () => { | |
| 98 | const existing = "a".repeat(500); | |
| 99 | const addition = "b".repeat(500); | |
| 100 | const out = truncateLog(existing, addition, 1000); | |
| 101 | expect(out.length).toBe(1000); | |
| 102 | expect(out).toBe(existing + addition); | |
| 103 | }); | |
| 104 | ||
| 105 | it("prepends the sentinel and keeps the last maxBytes when over the cap", () => { | |
| 106 | const existing = "x".repeat(2000); | |
| 107 | const addition = "y".repeat(500); | |
| 108 | const out = truncateLog(existing, addition, 1000); | |
| 109 | expect(out.startsWith(__internal.LOG_TRUNCATED_SENTINEL)).toBe(true); | |
| 110 | // Tail length == maxBytes; we keep the rightmost characters of existing+addition. | |
| 111 | const tail = out.slice(__internal.LOG_TRUNCATED_SENTINEL.length); | |
| 112 | expect(tail.length).toBe(1000); | |
| 113 | // Since addition ('y'*500) sits at the very end, the last 500 chars must be 'y'. | |
| 114 | expect(tail.endsWith("y".repeat(500))).toBe(true); | |
| 115 | // And the middle should be the tail of the 'x' run. | |
| 116 | expect(tail.startsWith("x".repeat(500))).toBe(true); | |
| 117 | }); | |
| 118 | ||
| 119 | it("does not double the sentinel on subsequent truncations", () => { | |
| 120 | // First truncation. | |
| 121 | const first = truncateLog("x".repeat(2000), "", 1000); | |
| 122 | expect(first.startsWith(__internal.LOG_TRUNCATED_SENTINEL)).toBe(true); | |
| 123 | // Feed the already-truncated log back in with more content that forces | |
| 124 | // another truncation. | |
| 125 | const second = truncateLog(first, "z".repeat(1500), 1000); | |
| 126 | expect(second.startsWith(__internal.LOG_TRUNCATED_SENTINEL)).toBe(true); | |
| 127 | // Only one sentinel should appear in total. | |
| 128 | const occurrences = second.split(__internal.LOG_TRUNCATED_SENTINEL).length - 1; | |
| 129 | expect(occurrences).toBe(1); | |
| 130 | // Most of the tail should be 'z'. | |
| 131 | expect(second.endsWith("z".repeat(500))).toBe(true); | |
| 132 | }); | |
| 133 | ||
| 134 | it("handles empty existing and empty addition safely", () => { | |
| 135 | expect(truncateLog("", "", 100)).toBe(""); | |
| 136 | expect(truncateLog("", "hi", 100)).toBe("hi"); | |
| 137 | expect(truncateLog("hi", "", 100)).toBe("hi"); | |
| 138 | }); | |
| 139 | }); | |
| 140 | ||
| 141 | describe("agent-runtime — truncateError", () => { | |
| 142 | it("leaves short strings alone", () => { | |
| 143 | expect(truncateError("boom")).toBe("boom"); | |
| 144 | }); | |
| 145 | ||
| 146 | it("trims long strings and appends a marker", () => { | |
| 147 | const long = "e".repeat(10_000); | |
| 148 | const out = truncateError(long, 4096); | |
| 149 | expect(out.length).toBeLessThanOrEqual(4096 + 32); // body + marker | |
| 150 | expect(out.startsWith("e".repeat(4096))).toBe(true); | |
| 151 | expect(out).toContain("truncated"); | |
| 152 | }); | |
| 153 | ||
| 154 | it("handles empty / null-ish input", () => { | |
| 155 | expect(truncateError("")).toBe(""); | |
| 156 | // @ts-expect-error — runtime guard against null callers. | |
| 157 | expect(truncateError(null)).toBe(""); | |
| 158 | }); | |
| 159 | }); | |
| 160 | ||
| 161 | describe("agent-runtime — runSandboxed", () => { | |
| 162 | it("runs a successful command and captures stdout", async () => { | |
| 163 | const res = await runSandboxed("/bin/echo", ["hello world"], { | |
| 164 | cwd: "/tmp", | |
| 165 | timeoutMs: 2000, | |
| 166 | }); | |
| 167 | expect(res.exitCode).toBe(0); | |
| 168 | expect(res.timedOut).toBe(false); | |
| 169 | expect(res.stdout).toContain("hello world"); | |
| 170 | expect(res.stderr).toBe(""); | |
| 171 | }); | |
| 172 | ||
| 173 | it("returns a non-zero exit code for failing commands", async () => { | |
| 174 | // `false` always exits 1. | |
| 175 | const res = await runSandboxed("/bin/sh", ["-c", "exit 7"], { | |
| 176 | cwd: "/tmp", | |
| 177 | timeoutMs: 2000, | |
| 178 | }); | |
| 179 | expect(res.exitCode).toBe(7); | |
| 180 | expect(res.timedOut).toBe(false); | |
| 181 | }); | |
| 182 | ||
| 183 | it("kills long-running processes on timeout", async () => { | |
| 184 | const started = Date.now(); | |
| 185 | const res = await runSandboxed("/bin/sleep", ["10"], { | |
| 186 | cwd: "/tmp", | |
| 187 | timeoutMs: 200, | |
| 188 | }); | |
| 189 | const elapsed = Date.now() - started; | |
| 190 | expect(res.timedOut).toBe(true); | |
| 191 | // 200ms timeout + up to 5s SIGTERM grace before SIGKILL; sleep(1) responds | |
| 192 | // to SIGTERM immediately so in practice well under a second. | |
| 193 | expect(elapsed).toBeLessThan(6000); | |
| 194 | expect(res.stderr).toContain("timeout"); | |
| 195 | }, 10_000); | |
| 196 | ||
| 197 | it("caps stdout at the configured stream cap", async () => { | |
| 198 | // Write ~200 KB of 'a's to stdout via a tiny python-free shell loop. | |
| 199 | // `yes` is simpler: it spams a string forever. Combined with head | |
| 200 | // we get deterministic size without relying on Python/Node. | |
| 201 | const res = await runSandboxed( | |
| 202 | "/bin/sh", | |
| 203 | ["-c", "yes aaaaaaaaaa | head -c 204800"], | |
| 204 | { cwd: "/tmp", timeoutMs: 5000, stdoutCapBytes: 64 * 1024 } | |
| 205 | ); | |
| 206 | expect(res.exitCode).toBe(0); | |
| 207 | // Capped stream = 64 KB content + '\n[... truncated ...]' suffix. | |
| 208 | expect(res.stdout.length).toBeGreaterThanOrEqual(64 * 1024); | |
| 209 | expect(res.stdout.length).toBeLessThan(64 * 1024 + 64); | |
| 210 | expect(res.stdout).toContain("truncated"); | |
| 211 | }, 10_000); | |
| 212 | ||
| 213 | it("reports spawn failure without throwing", async () => { | |
| 214 | const res = await runSandboxed( | |
| 215 | "/nonexistent/definitely-not-a-real-binary-xyz", | |
| 216 | [], | |
| 217 | { cwd: "/tmp", timeoutMs: 1000 } | |
| 218 | ); | |
| 219 | // Bun either returns exitCode=null + stderr message, or exit!=0 — both | |
| 220 | // are acceptable "failure surfaced" outcomes. What must NOT happen is a | |
| 221 | // throw. Assert the call resolved and returned a result object. | |
| 222 | expect(typeof res.timedOut).toBe("boolean"); | |
| 223 | expect(res.exitCode === null || res.exitCode !== 0).toBe(true); | |
| 224 | }); | |
| 225 | ||
| 226 | it("passes a minimal env by default (no process.env leakage)", async () => { | |
| 227 | // Set a secret-looking var in the parent process and ensure it does NOT | |
| 228 | // leak into the sandbox's environment when the caller passes no env. | |
| 229 | process.env.GLUECRON_TEST_SECRET = "leak-me-please"; | |
| 230 | const res = await runSandboxed( | |
| 231 | "/bin/sh", | |
| 232 | ["-c", "echo \"S=${GLUECRON_TEST_SECRET:-unset}\""], | |
| 233 | { cwd: "/tmp", timeoutMs: 2000 } | |
| 234 | ); | |
| 235 | delete process.env.GLUECRON_TEST_SECRET; | |
| 236 | expect(res.exitCode).toBe(0); | |
| 237 | expect(res.stdout).toContain("S=unset"); | |
| 238 | }); | |
| 239 | ||
| 240 | it("honours a caller-supplied env verbatim", async () => { | |
| 241 | const res = await runSandboxed( | |
| 242 | "/bin/sh", | |
| 243 | ["-c", "echo \"V=$FOO\""], | |
| 244 | { | |
| 245 | cwd: "/tmp", | |
| 246 | timeoutMs: 2000, | |
| 247 | env: { PATH: process.env.PATH ?? "/usr/bin:/bin", FOO: "bar" }, | |
| 248 | } | |
| 249 | ); | |
| 250 | expect(res.exitCode).toBe(0); | |
| 251 | expect(res.stdout).toContain("V=bar"); | |
| 252 | }); | |
| 253 | }); |