From 47b4bc1d4ea61e4b92b9d6a3bb36067e4b06490e Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 10 Oct 2026 13:59:39 -0500 Subject: [PATCH] fix: enforce file operation deadlines inside the remote process group Apply GNU timeout after SSH admission deducts queue time. Verify a delayed remote-shaped Python write is killed independently of local SSH termination. Co-Authored-By: Paperclip --- .../modules/computers/adapters/boat.test.ts | 30 ++++++++++++++++++- server/src/modules/computers/adapters/boat.ts | 16 ++++++++-- 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/server/src/modules/computers/adapters/boat.test.ts b/server/src/modules/computers/adapters/boat.test.ts index 0f1ea78a08..1c1cd506c4 100644 --- a/server/src/modules/computers/adapters/boat.test.ts +++ b/server/src/modules/computers/adapters/boat.test.ts @@ -56,12 +56,40 @@ describe("Boat transport validation", () => { })); try { await backend.remote(scoped, { action: "read", path: "AGENTS.md" }, { deadlineMs: 1_120_000 }); - expect(execute).toHaveBeenCalledWith(expect.objectContaining({ timeoutMs: 30_000 })); + expect(execute).toHaveBeenCalledWith(expect.objectContaining({ + command: "timeout", args: expect.arrayContaining(["--signal=KILL", "30s", "python3"]), timeoutMs: 30_000, + })); clock = 1_120_000; await expect(backend.remote(scoped, { action: "write" }, { deadlineMs: clock })).rejects.toThrow("timed out"); expect(execute).toHaveBeenCalledOnce(); } finally { time.mockRestore(); sshFactory.mockReset(); } }); + it("kills the remote Python group at the file deadline, independent of SSH closure", async () => { + const temp = realpathSync(mkdtempSync(join(tmpdir(), "boat-file-deadline-"))); + const lateWrite = join(temp, "late-write"); + let pid: number | undefined; + const scoped = { ...record, id: randomUUID(), providerId: `bx_${randomUUID()}` }; + const execute = vi.fn(async (input: { command: string; args: string[]; stdin?: string }) => { + expect(input.command).toBe("timeout"); + const args = [...input.args]; + args[args.length - 1] = `import os,time;print(os.getpid(),flush=True);time.sleep(2);open(${JSON.stringify(lateWrite)},'w').write('late')`; + const result = spawnSync(input.command, args, { encoding: "utf8", input: input.stdin }); + pid = Number(result.stdout.trim()); + expect(pid).toBeGreaterThan(0); + return { exitCode: result.status, stdout: result.stdout, stderr: result.stderr, timedOut: false, signal: result.signal, pid: null, startedAt: "" }; + }); + sshFactory.mockReturnValue({ execute }); + const backend = boatBackend(async () => "fixture", vi.fn(async () => json({ hostKey: "ssh-ed25519 AAAA", sshEndpoint: "fixture.invalid:2222" }))); + try { + await expect(backend.remote(scoped, { action: "read" }, { deadlineMs: Date.now() + 300 })).rejects.toThrow("Computer operation failed"); + await vi.waitFor(() => expect(() => process.kill(pid!, 0)).toThrow(), { timeout: 2000 }); + expect(existsSync(lateWrite)).toBe(false); + expect(execute).toHaveBeenCalledOnce(); + } finally { + sshFactory.mockReset(); + rmSync(temp, { recursive: true, force: true }); + } + }); it("expires only the file waiter while shared SSH initialization remains usable", async () => { vi.useFakeTimers(); const scoped = { ...record, id: randomUUID(), providerId: `bx_${randomUUID()}` }; diff --git a/server/src/modules/computers/adapters/boat.ts b/server/src/modules/computers/adapters/boat.ts index 0cf1eeddb2..9f35158b06 100644 --- a/server/src/modules/computers/adapters/boat.ts +++ b/server/src/modules/computers/adapters/boat.ts @@ -340,7 +340,7 @@ export function boatBackend( } return pending; } - async function runner(record: ComputerRecord, options?: { control?: boolean }): Promise { + async function runner(record: ComputerRecord, options?: { control?: boolean; remoteTimeout?: boolean }): Promise { const raw = await rawRunner(record); let admission = transportAdmission.get(record.providerId); if (!admission) { @@ -348,7 +348,17 @@ export function boatBackend( transportAdmission.set(record.providerId, admission); } const execute = admission; - return { ...raw, execute: (input) => execute(raw, input, options?.control === true) }; + const bounded = options?.remoteTimeout ? { + ...raw, + execute: (input: CommandInput) => raw.execute({ + ...input, + // Admission has already deducted queue time. Bound the remote process + // group as well as the local SSH child; socket closure is not stop proof. + command: "timeout", + args: ["--signal=KILL", `${input.timeoutMs! / 1000}s`, input.command, ...(input.args ?? [])], + }), + } : raw; + return { ...raw, execute: (input) => execute(bounded, input, options?.control === true) }; } async function fileTransport( record: ComputerRecord, @@ -356,7 +366,7 @@ export function boatBackend( options?: ComputerOperationOptions, ) { remaining(options, 120_000); - const pending = runner(record, { control }); + const pending = runner(record, { control, remoteTimeout: options?.deadlineMs !== undefined }); if (options?.deadlineMs === undefined) return pending; // Initialization is shared with unrelated owners. Expire this waiter only; // the cached promise retains its normal success/eviction behavior.