Runner: deny background tool tasks (nohup + log polling instead); kill leftover workspace processes after each session
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
3 changed files +58 −0
modified
services/research-runner/src/policy.test.ts
+4 −0
@@ -58,6 +58,10 @@ describe("platform policy hook", () => { | ||
| 58 | 58 | expect(isDeny(await call("Bash", { command })), command).toBe(true); |
| 59 | 59 | } |
| 60 | 60 | expect(isDeny(await call("Bash", { command: "ls", dangerouslyDisableSandbox: true }))).toBe(true); |
| 61 | + expect(isDeny(await call("Bash", { command: "python3 solve.py", run_in_background: true }))).toBe(true); | |
| 62 | + expect(isDeny(await call("Bash", { command: "nohup python3 solve.py > results/solve.log 2>&1 &" }))).toBe( | |
| 63 | + false, | |
| 64 | + ); | |
| 61 | 65 | }); |
| 62 | 66 | it("allows ordinary research commands", async () => { |
| 63 | 67 | for (const command of [ |
modified
services/research-runner/src/policy.ts
+6 −0
@@ -191,6 +191,12 @@ export function createPolicyHook(opts: PolicyOptions): HookCallback { | ||
| 191 | 191 | if (tool === "Bash" || tool === "PowerShell") { |
| 192 | 192 | const cmd = String(ti.command ?? ""); |
| 193 | 193 | if (ti.dangerouslyDisableSandbox === true) return deny(input, "sandbox bypass requested", ti); |
| 194 | + if (ti.run_in_background === true) | |
| 195 | + return deny( | |
| 196 | + input, | |
| 197 | + "background tool tasks keep the stage open indefinitely; run long jobs with `nohup <cmd> > results/<name>.log 2>&1 &` (bounded with a timeout) and poll the log file instead", | |
| 198 | + { command: cmd.slice(0, 200) }, | |
| 199 | + ); | |
| 194 | 200 | for (const p of DENIED_COMMAND_PATTERNS) |
| 195 | 201 | if (p.re.test(cmd)) return deny(input, p.reason, { command: cmd.slice(0, 300) }); |
| 196 | 202 | if (cmd.length > 20_000) return deny(input, "command too long", {}); |
modified
services/research-runner/src/runner.ts
+48 −0
@@ -394,6 +394,7 @@ export class ResearchRunner { | ||
| 394 | 394 | clearInterval(cancelPoll); |
| 395 | 395 | } |
| 396 | 396 | result.denied = denied.n; |
| 397 | + await this.killLeftoverProcesses(spec.key); | |
| 397 | 398 | this.spent += result.costUsd; |
| 398 | 399 | this.turns += result.turns; |
| 399 | 400 | this.emit( |
@@ -406,6 +407,53 @@ export class ResearchRunner { | ||
| 406 | 407 | return result; |
| 407 | 408 | } |
| 408 | 409 | |
| 410 | + /** Kills processes (owned by this user) whose command line references this workspace — leftover solvers, servers, watchers. */ | |
| 411 | + private async killLeftoverProcesses(stage: StageKey): Promise<void> { | |
| 412 | + try { | |
| 413 | + const out = await new Promise<string>((resolve) => { | |
| 414 | + const ps = spawn("/bin/ps", ["-axo", "pid=,command="], { stdio: ["ignore", "pipe", "ignore"] }); | |
| 415 | + let buf = ""; | |
| 416 | + ps.stdout.on("data", (d) => { | |
| 417 | + buf += d.toString(); | |
| 418 | + }); | |
| 419 | + ps.on("close", () => resolve(buf)); | |
| 420 | + ps.on("error", () => resolve("")); | |
| 421 | + }); | |
| 422 | + const mine = out | |
| 423 | + .split("\n") | |
| 424 | + .map((l) => l.trim()) | |
| 425 | + .filter((l) => l.includes(this.ws) && !/claude-agent-sdk|dist\/main\.js/.test(l)) | |
| 426 | + .map((l) => Number(l.split(/\s+/)[0])) | |
| 427 | + .filter((pid) => Number.isInteger(pid) && pid !== process.pid); | |
| 428 | + for (const pid of mine) { | |
| 429 | + try { | |
| 430 | + process.kill(pid, "SIGTERM"); | |
| 431 | + } catch { | |
| 432 | + /* gone */ | |
| 433 | + } | |
| 434 | + } | |
| 435 | + if (mine.length) { | |
| 436 | + await new Promise((r) => setTimeout(r, 3000)); | |
| 437 | + for (const pid of mine) { | |
| 438 | + try { | |
| 439 | + process.kill(pid, "SIGKILL"); | |
| 440 | + } catch { | |
| 441 | + /* gone */ | |
| 442 | + } | |
| 443 | + } | |
| 444 | + this.emit( | |
| 445 | + "process.cleanup", | |
| 446 | + `terminated ${mine.length} leftover process(es) after the ${stage} session`, | |
| 447 | + { pids: mine.slice(0, 20) }, | |
| 448 | + "warn", | |
| 449 | + stage, | |
| 450 | + ); | |
| 451 | + } | |
| 452 | + } catch { | |
| 453 | + /* best effort */ | |
| 454 | + } | |
| 455 | + } | |
| 456 | + | |
| 409 | 457 | private async stageContext(extra: Partial<StageContext> = {}): Promise<StageContext> { |
| 410 | 458 | return { |
| 411 | 459 | filesPresent: await listFiles(this.ws), |
| 412 | 460 | |