fix(computers): delete large agent files without downloading

Hash remote files through confined descriptors in bounded chunks before editor mutations. Preserve the remote compare-and-swap removal check and read-size cap; cover a real 17 MiB file, stale deletion, and path confinement.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-10 10:16:15 -05:00
1 parent 03542b0845
commit ec80163f2f
6 files changed
+73 -4

No files matched your search

@@ -7,6 +7,7 @@ import {
writeFileSync,
mkdirSync,
} from "node:fs";
import { createHash } from "node:crypto";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { spawnSync } from "node:child_process";
@@ -170,6 +171,25 @@ describe("confined computer files", () => {
error: "invalid",
});
});
it("hashes and deletes files over the read limit while fencing changed bytes", () => {
const f = fixture();
f.call({ action: "seed", files: {} });
const bytes = Buffer.alloc(17 * 1024 * 1024, 7);
writeFileSync(join(f.root, "large.bin"), bytes);
const expected = createHash("sha256").update(bytes).digest("hex");
expect(f.call({ action: "read", path: "large.bin" })).toEqual({ error: "invalid" });
expect(f.call({ action: "hash", path: "large.bin" })).toEqual({ sha256: expected, size: bytes.length });
bytes[0] = 8;
writeFileSync(join(f.root, "large.bin"), bytes);
expect(f.call({ action: "remove", path: "large.bin", expectedSha256: expected })).toEqual({ error: "conflict" });
const current = f.call({ action: "hash", path: "large.bin" });
expect(current.sha256).not.toBe(expected);
expect(f.call({ action: "remove", path: "large.bin", expectedSha256: current.sha256 })).toEqual({});
expect(f.call({ action: "hash", path: "large.bin" })).toEqual({ error: "not_found" });
symlinkSync(join(f.temp, "outside"), join(f.root, "escape"));
expect(f.call({ action: "hash", path: "escape" })).toEqual({ error: "invalid" });
expect(f.call({ action: "hash", path: "../outside" })).toEqual({ error: "invalid" });
});
it("rejects stale edits and uses no-clobber move semantics", () => {
const f = fixture();
f.call({ action: "seed", files: {} });
@@ -143,6 +143,18 @@ try:
finally:os.close(parentfd)
if not stat.S_ISDIR(info.st_mode) and not stat.S_ISREG(info.st_mode):fail('invalid')
print(json.dumps({'name':name,'kind':'directory' if stat.S_ISDIR(info.st_mode) else 'file','size':info.st_size,'mtimeMs':info.st_mtime*1000}))
elif act=='hash':
parentfd,name=parent(path)
try:fd=os.open(name,os.O_RDONLY|os.O_NOFOLLOW,dir_fd=parentfd)
finally:os.close(parentfd)
with os.fdopen(fd,'rb') as f:
if not stat.S_ISREG(os.fstat(f.fileno()).st_mode):fail('invalid')
h=hashlib.sha256();size=0
while True:
block=f.read(65536)
if not block:break
h.update(block);size+=len(block)
print(json.dumps({'sha256':h.hexdigest(),'size':size}))
elif act=='read':
parentfd,name=parent(path)
try:fd=os.open(name,os.O_RDONLY|os.O_NOFOLLOW,dir_fd=parentfd)
@@ -702,6 +702,9 @@ finally:
async stat(path: string): Promise<{ name: string; kind: "file" | "directory"; size: number; mtimeMs: number }> {
return remote({ action: "stat", path });
},
async hash(path: string): Promise<{ sha256: string; size: number }> {
return remote({ action: "hash", path });
},
async remove(path: string, expectedSha256: string) {
await remote({ action: "remove", path, expectedSha256 });
},