SPB Git

spb/autoscholar

Public

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>
Simon-Pierre Boucher committed (Oct 30, 2026) parent 2f4359c Browse files

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
Menu

Theme