From 5e5fe7a708d2c87b5953ce29329248b932fc7b98 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 10 Oct 2026 14:22:43 -0500 Subject: [PATCH] fix: discard inaccessible computer write receipts safely Preserve removed, replaced, or permission-restricted temp parents while allowing unrelated file operations to continue. Keep unknown I/O failures visible. Co-Authored-By: Paperclip --- .../modules/computers/adapters/boat.test.ts | 30 +++++++++++++++++++ .../computers/adapters/remote-program.ts | 7 +++-- 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/server/src/modules/computers/adapters/boat.test.ts b/server/src/modules/computers/adapters/boat.test.ts index 8ac5e356457..e1ec76f173d 100644 --- a/server/src/modules/computers/adapters/boat.test.ts +++ b/server/src/modules/computers/adapters/boat.test.ts @@ -10,6 +10,7 @@ import { writeFileSync, mkdirSync, renameSync, + chmodSync, } from "node:fs"; import { createHash, randomUUID } from "node:crypto"; import { tmpdir } from "node:os"; @@ -288,6 +289,35 @@ describe("confined computer files", () => { expect(readFileSync(outside, "utf8")).toBe("preserve"); expect(existsSync(temp)).toBe(true); }); + it.each(["missing", "file", "symlink"])("discards an unusable receipt when its parent is %s", (replacement) => { + const f = interruptedWrite(); + renameSync(join(f.root, "nested"), join(f.root, "moved-by-user")); + const outside = join(f.temp, "outside"); mkdirSync(outside); + writeFileSync(join(outside, "notes"), "outside user content"); + if (replacement === "file") writeFileSync(join(f.root, "nested"), "replacement parent"); + if (replacement === "symlink") symlinkSync(outside, join(f.root, "nested")); + writeFileSync(join(f.root, "unrelated"), "still readable"); + expect(f.call({ action: "list", path: "" }).entries).toEqual(expect.any(Array)); + expect(Buffer.from(f.call({ action: "read", path: "unrelated" }).base64, "base64").toString()).toBe("still readable"); + expect(f.call({ action: "write", path: "new-file", expectedSha256: null, base64: Buffer.from("new content").toString("base64") }).sha256).toBeTruthy(); + expect(readFileSync(join(f.root, ".paperclip-editor.lock"), "utf8")).toBe(""); + expect(readFileSync(join(f.root, "moved-by-user/notes"), "utf8")).toBe("original"); + expect(readFileSync(join(f.root, "moved-by-user", f.receipt.path.split("/").at(-1)), "utf8")).toBe("interrupted content"); + expect(readFileSync(join(outside, "notes"), "utf8")).toBe("outside user content"); + if (replacement === "file") expect(readFileSync(join(f.root, "nested"), "utf8")).toBe("replacement parent"); + if (replacement === "symlink") expect(existsSync(join(f.root, "nested/notes"))).toBe(true); + }); + it.skipIf(process.getuid?.() === 0)("preserves an inaccessible temp parent without blocking unrelated reads", () => { + const f = interruptedWrite(); + writeFileSync(join(f.root, "unrelated"), "still readable"); + chmodSync(join(f.root, "nested"), 0); + try { + expect(Buffer.from(f.call({ action: "read", path: "unrelated" }).base64, "base64").toString()).toBe("still readable"); + expect(readFileSync(join(f.root, ".paperclip-editor.lock"), "utf8")).toBe(""); + } finally { chmodSync(join(f.root, "nested"), 0o700); } + expect(readFileSync(join(f.root, f.receipt.path), "utf8")).toBe("interrupted content"); + expect(readFileSync(join(f.root, "nested/notes"), "utf8")).toBe("original"); + }); it("leaves only an empty temp when killed before its ownership receipt", () => { const f = fixture(); f.call({ action: "seed", files: {} }); const result = spawnSync("python3", ["-c", remoteProgram diff --git a/server/src/modules/computers/adapters/remote-program.ts b/server/src/modules/computers/adapters/remote-program.ts index b4c7ee083f0..1d555baab02 100644 --- a/server/src/modules/computers/adapters/remote-program.ts +++ b/server/src/modules/computers/adapters/remote-program.ts @@ -1,6 +1,6 @@ /** Fixed remote program. Inputs travel as JSON, never interpolated into Python or shell. */ export const remoteProgram = String.raw` -import os,sys,json,hashlib,base64,tempfile,subprocess,fcntl,stat,shutil +import os,sys,json,hashlib,base64,tempfile,subprocess,fcntl,stat,shutil,errno p=json.load(sys.stdin) def fail(code): print(json.dumps({'error':code}));sys.exit(0) @@ -273,7 +273,10 @@ def recover_write(): info=os.stat(name,dir_fd=parentfd,follow_symlinks=False) if stat.S_ISREG(info.st_mode) and info.st_nlink==1 and info.st_dev==receipt.get('device') and info.st_ino==receipt.get('inode'): os.unlink(name,dir_fd=parentfd) - except FileNotFoundError:pass + except OSError as error: + # A removed/replaced/inaccessible ancestor makes this receipt unusable. Preserve + # whatever occupies that path and allow unrelated file operations to proceed. + if error.errno not in (errno.ENOENT,errno.ENOTDIR,errno.ELOOP,errno.EACCES,errno.EPERM):raise finally: if parentfd is not None:os.close(parentfd) clear_write_receipt()