From af93abd90a513ff4410b35b47947c0cc6edef249 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 10 Oct 2026 10:23:59 -0500 Subject: [PATCH 1/2] Use isolated computer storage paths during restart reattachment Co-Authored-By: Paperclip --- .../services/native-runtime/native-session-executor.test.ts | 4 ++++ .../src/services/native-runtime/native-session-executor.ts | 6 +----- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/server/src/services/native-runtime/native-session-executor.test.ts b/server/src/services/native-runtime/native-session-executor.test.ts index 350cbbb3b1b..c552f8352bd 100644 --- a/server/src/services/native-runtime/native-session-executor.test.ts +++ b/server/src/services/native-runtime/native-session-executor.test.ts @@ -488,12 +488,16 @@ describe("remote controller restart adoption", () => { const { sandboxLeaseAcquisition, ...common } = sandbox; const inspectProcess = vi.fn(async () => ({ running: true, claim: { nonce: "process-claim" } })); const target = { ...common, transport: "computer", listenerPort: 45101, + fileAuthority: { kind: "remote-persistent", root: "/workspace", placementId: "workspace", agentHome: "/home/user/agent" }, resourceAuthority: { kind: "computer-owner", computerId: "computer", ownerId: "owner", generation: 7 }, inspectProcess }; const computerClaim = { ...claim, remote: { ...claim.remote, providerLeaseId: "owner", computerOwner: { computerId: "computer", ownerId: "owner", generation: 7, listenerPort: 45101 } } }; const adopted = await verifyRemoteRunnerReattachment({ claim: computerClaim, target: target as never, identity, runId: "run", normalizedSessionId: "session" }); expect(adopted.pid).toBe(123); + const storage = remoteRunnerStorageRoots(target as never, "session"); + expect(execute.mock.calls[0]![0].args.join(" ")).toContain(`${storage.sessionRoot}/runner/runner-state.json`); + expect(execute.mock.calls.some(([request]) => request.args.includes(`${storage.sessionRoot}/runner/runner-process.identity`))).toBe(true); expect(inspectProcess).toHaveBeenCalledOnce(); const readCount = execute.mock.calls.length; await expect(verifyRemoteRunnerReattachment({ claim: { ...computerClaim, remote: { diff --git a/server/src/services/native-runtime/native-session-executor.ts b/server/src/services/native-runtime/native-session-executor.ts index ecd9244fe84..c5bf41a377e 100644 --- a/server/src/services/native-runtime/native-session-executor.ts +++ b/server/src/services/native-runtime/native-session-executor.ts @@ -10877,11 +10877,7 @@ export async function verifyRemoteRunnerReattachment(input: { } const runner = target.runner; const stateDirectory = posix.join( - target.remoteCwd, - ".paperclip-runtime", - "paperclip-runner", - "sessions", - createHash("sha256").update(input.normalizedSessionId).digest("hex"), + remoteRunnerStorageRoots(target, input.normalizedSessionId).sessionRoot, "runner", ); const runnerState = await readRemoteRunnerState({ runner, stateDirectory }); From a9882c9dee151b75cf882db25eabf097c9ea60b6 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 10 Oct 2026 10:28:14 -0500 Subject: [PATCH 2/2] Keep owned stdin valid until command completion on Boat Boat invalidates descriptors for unlinked backing files, causing Node startup to abort. Retain the0600 stdin spool until normal cleanup, and reap abandoned command spools only after exact-owner retirement stops its cgroup. Co-Authored-By: Paperclip --- server/src/modules/computers/adapters/boat.ts | 8 +++++++- server/src/modules/computers/application/service.test.ts | 8 ++++++++ server/src/modules/computers/application/service.ts | 5 +++-- 3 files changed, 18 insertions(+), 3 deletions(-) diff --git a/server/src/modules/computers/adapters/boat.ts b/server/src/modules/computers/adapters/boat.ts index c6a34faaf20..f74f9eb67ad 100644 --- a/server/src/modules/computers/adapters/boat.ts +++ b/server/src/modules/computers/adapters/boat.ts @@ -196,7 +196,7 @@ export function boatBackend( return value; } const processProgram = String.raw` -import os,sys,json,fcntl,subprocess +import os,sys,json,fcntl,subprocess,shutil p=json.load(sys.stdin);base='/home/user/.paperclip-owners';os.makedirs(base,exist_ok=True) owner=p['owner'];oid=owner['id'];root=os.path.join(base,oid);os.makedirs(root,exist_ok=True) with open(os.path.join(root,'lock'),'a') as lock: @@ -215,6 +215,12 @@ with open(os.path.join(root,'lock'),'a') as lock: subprocess.run(['systemctl','--user','stop',slice,unit],capture_output=True) state=subprocess.run(['systemctl','--user','show',slice,'--property=ActiveState','--value'],capture_output=True,text=True).stdout.strip() if state not in ('inactive','failed',''):raise RuntimeError('process retirement unconfirmed') + # Reap only this retired owner's command spools after its cgroup is stopped. + # This covers controller crashes and SSH timeouts that skipped caller cleanup. + for entry in os.scandir(root): + if entry.name.startswith('command-') and entry.is_dir(follow_symlinks=False): + try:shutil.rmtree(entry.path) + except FileNotFoundError:pass print('{}') elif p['action']=='inspect': claim=owner.get('process');marker=os.path.join(root,'claim.json');actual=json.load(open(marker)) if os.path.exists(marker) else None diff --git a/server/src/modules/computers/application/service.test.ts b/server/src/modules/computers/application/service.test.ts index 80be803a887..5de8a1d5989 100644 --- a/server/src/modules/computers/application/service.test.ts +++ b/server/src/modules/computers/application/service.test.ts @@ -490,6 +490,14 @@ with open(output,'wb') as out,open(error,'wb') as err: cwd: temp, stdin: "foreground-input\n", }); + const stdinProbe = await binding.runner.execute({ + command: "python3", + args: ["-c", "import os,sys; assert os.fstat(0).st_nlink > 0, 'stdin backing file was unlinked before child exit'; assert os.fstat(0).st_mode & 0o777 == 0o600; print(sys.stdin.read(),end='')"], + cwd: temp, + stdin: "persistent-stdin", + }); + expect(stdinProbe.exitCode).toBe(0); + expect(stdinProbe.stdout).toBe("persistent-stdin"); backgroundPid = Number(result.stdout.trim()); expect(result.exitCode).toBe(7); expect(result.stderr).toBe("foreground-input\n"); diff --git a/server/src/modules/computers/application/service.ts b/server/src/modules/computers/application/service.ts index 947506a1c0f..292836dee0d 100644 --- a/server/src/modules/computers/application/service.ts +++ b/server/src/modules/computers/application/service.ts @@ -209,7 +209,7 @@ export function createComputerService( "-c", String.raw` import os,sys,json,fcntl,subprocess,time,tempfile,shutil -p=json.load(sys.stdin);root='/home/user/.paperclip-owners/'+p['owner'];os.makedirs(root,exist_ok=True) +p=json.load(sys.stdin);os.umask(0o077);root='/home/user/.paperclip-owners/'+p['owner'];os.makedirs(root,exist_ok=True) # A receipt reports foreground completion independently from cgroup lifetime. # ExitType=cgroup keeps nohup/setsid descendants owned until owner retirement. supervisor=""" @@ -220,7 +220,8 @@ os.unlink(payload) i=p['input'] try: with open(p['stdin'],'rb') as source: - os.unlink(p['stdin']) + # Boat's persistent filesystem invalidates unlinked stdin descriptors. + # Keep the private backing file until the command's normal finally cleanup. child=subprocess.Popen([i['command']]+i.get('args',[]),stdin=source) code=child.wait() except Exception as error: