Bound initial computer home uploads and exclude runtime state

Stream initial personal files through bounded chunks into private staging and atomically publish without replacing an existing home. Keep the home-level runtime directory out of instruction enumeration and seeding while preserving nested user content.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-10 11:00:36 -05:00
1 parent 421f7f0600
commit cebfa8b09f
8 files changed
+204 -32

No files matched your search

@@ -1,14 +1,16 @@
import {
existsSync,
mkdtempSync,
readFileSync,
readdirSync,
statSync,
mkdtempSync,
realpathSync,
rmSync,
symlinkSync,
writeFileSync,
mkdirSync,
} from "node:fs";
import { createHash } from "node:crypto";
import { createHash, randomUUID } from "node:crypto";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { spawnSync } from "node:child_process";
@@ -172,6 +174,37 @@ describe("confined computer files", () => {
error: "invalid",
});
});
it("publishes bounded seed chunks atomically and aborts partial homes", () => {
const f = fixture(); const seedId = randomUUID();
expect(f.call({ action: "seed-begin", seedId })).toEqual({ started: true });
const chunk = Buffer.alloc(1024 * 1024, 19);
for (const offset of [0, chunk.length]) expect(f.call({ action: "seed-chunk", seedId, path: "memory/data", offset, base64: chunk.toString("base64") })).toEqual({});
expect(existsSync(f.root)).toBe(false);
const staging = readdirSync(f.temp).find(name => name.startsWith(".paperclip-seed-"))!;
expect(statSync(join(f.temp, staging)).mode & 0o777).toBe(0o700);
expect(statSync(join(f.temp, staging, "tree", "memory", "data")).mode & 0o777).toBe(0o600);
expect(f.call({ action: "seed-chunk", seedId, path: "memory/data", offset: 0, base64: "" })).toEqual({ error: "conflict" });
expect(f.call({ action: "seed-commit", seedId })).toEqual({ seeded: true });
expect(readFileSync(join(f.root, "memory", "data"))).toEqual(Buffer.concat([chunk, chunk]));
expect(readdirSync(f.temp)).toEqual(["home"]);
expect(f.call({ action: "seed-begin", seedId: randomUUID() })).toEqual({ started: false });
const g = fixture(); const partial = randomUUID();
g.call({ action: "seed-begin", seedId: partial });
expect(g.call({ action: "seed-chunk", seedId: partial, path: "../escape", offset: 0, base64: "" })).toEqual({ error: "invalid" });
expect(g.call({ action: "seed-chunk", seedId: partial, path: "large", offset: 0, base64: Buffer.alloc(1024 * 1024 + 1).toString("base64") })).toEqual({ error: "invalid" });
expect(g.call({ action: "seed-abort", seedId: partial })).toEqual({});
expect(existsSync(g.root)).toBe(false);
expect(readdirSync(g.temp)).toEqual([]);
});
it("does not replace a home created while its initial upload was in progress", () => {
const f = fixture(); const seedId = randomUUID();
f.call({ action: "seed-begin", seedId });
f.call({ action: "seed-chunk", seedId, path: "initial", offset: 0, base64: Buffer.from("staged").toString("base64") });
mkdirSync(f.root);
expect(f.call({ action: "seed-commit", seedId })).toEqual({ seeded: false });
expect(readdirSync(f.root)).toEqual([]);
expect(readdirSync(f.temp)).toEqual(["home"]);
});
it("hashes and deletes files over the read limit while fencing changed bytes", () => {
const f = fixture();
f.call({ action: "seed", files: {} });
@@ -107,6 +107,70 @@ def directory(parts,create=False,start=None):
os.close(fd);fd=nxt
return fd
except BaseException:os.close(fd);raise
# Initial uploads stay in a private sibling until a no-replace rename publishes them.
# Each request carries at most one bounded chunk, never the whole home.
if act in ('seed-begin','seed-chunk','seed-commit','seed-abort'):
import uuid,ctypes,errno
token=p.get('seedId','')
try:
if str(uuid.UUID(token))!=token:fail('invalid')
except (ValueError,TypeError,AttributeError):fail('invalid')
parentfd=directory(os.path.dirname(root).split('/'),create=True)
name=os.path.basename(root);staging='.paperclip-seed-'+name+'-'+token
stagefd=None;lockfd=None
try:
if act=='seed-abort':
try:shutil.rmtree(staging,dir_fd=parentfd)
except FileNotFoundError:pass
print('{}');sys.exit(0)
if act=='seed-begin':
try:
existing=os.stat(name,dir_fd=parentfd,follow_symlinks=False)
if not stat.S_ISDIR(existing.st_mode):fail('invalid')
print(json.dumps({'started':False}));sys.exit(0)
except FileNotFoundError:pass
os.mkdir(staging,0o700,dir_fd=parentfd)
stagefd=os.open(staging,os.O_RDONLY|os.O_DIRECTORY|os.O_NOFOLLOW,dir_fd=parentfd)
lockfd=os.open('lock',os.O_CREAT|os.O_RDWR|os.O_NOFOLLOW,0o600,dir_fd=stagefd)
fcntl.flock(lockfd,fcntl.LOCK_EX)
if act=='seed-begin':
os.mkdir('tree',0o700,dir_fd=stagefd)
print(json.dumps({'started':True}))
elif act=='seed-chunk':
relative=p.get('path','');safe(relative)
parts=[part for part in relative.split('/') if part not in ('','.')]
if not parts:fail('invalid')
data=base64.b64decode(p['base64'],validate=True);offset=p.get('offset')
if len(data)>1024*1024 or type(offset)!=int or offset<0 or offset+len(data)>256*1024*1024:fail('invalid')
destfd=directory(['tree']+parts[:-1],create=True,start=stagefd)
try:fd=os.open(parts[-1],os.O_WRONLY|os.O_CREAT|os.O_NOFOLLOW,0o600,dir_fd=destfd)
finally:os.close(destfd)
with os.fdopen(fd,'r+b') as f:
info=os.fstat(f.fileno())
if not stat.S_ISREG(info.st_mode) or info.st_nlink!=1:fail('invalid')
if info.st_size!=offset:fail('conflict')
f.seek(offset);f.write(data)
print('{}')
else:
libc=ctypes.CDLL(None,use_errno=True)
if hasattr(libc,'renameat2'):status=libc.renameat2(stagefd,b'tree',parentfd,os.fsencode(name),1)
elif hasattr(libc,'renameatx_np'):status=libc.renameatx_np(stagefd,b'tree',parentfd,os.fsencode(name),4)
else:fail('invalid')
if status!=0:
code=ctypes.get_errno()
if code not in (errno.EEXIST,errno.ENOTEMPTY):raise OSError(code,os.strerror(code))
if not stat.S_ISDIR(os.stat(name,dir_fd=parentfd,follow_symlinks=False).st_mode):fail('invalid')
print(json.dumps({'seeded':status==0}))
except FileNotFoundError:fail('not_found')
except (OSError,ValueError):fail('invalid')
finally:
if lockfd is not None:os.close(lockfd)
if stagefd is not None:os.close(stagefd)
if act=='seed-commit':
try:shutil.rmtree(staging,dir_fd=parentfd)
except FileNotFoundError:pass
os.close(parentfd)
sys.exit(0)
try:rootfd=directory(root.split('/'))
except FileNotFoundError:fail('not_found')
except OSError:fail('invalid')
@@ -545,6 +545,32 @@ with open(output,'wb') as out,open(error,'wb') as err:
rmSync(temp, { recursive: true, force: true });
}
});
it("bounds initial seed requests and aborts a failed upload without publishing", async () => {
const f = fixture();
await f.attach();
const files = await f.service.files({ ...f.scope, agentId: "agent" });
vi.mocked(f.backend.remote).mockImplementation(async (_record, input) => {
if (input.action === "seed-begin") return { started: true };
if (input.action === "seed-commit") return { seeded: true };
return {};
});
await expect(files.seedBytes({ "large.bin": Buffer.alloc(2 * 1024 * 1024 + 1) })).resolves.toEqual({ seeded: true });
const chunks = vi.mocked(f.backend.remote).mock.calls.map(([, input]) => input).filter(input => input.action === "seed-chunk");
expect(chunks).toHaveLength(3);
expect(chunks.map(input => input.offset)).toEqual([0, 1024 * 1024, 2 * 1024 * 1024]);
expect(chunks.every(input => Buffer.from(input.base64 as string, "base64").length <= 1024 * 1024)).toBe(true);
vi.mocked(f.backend.remote).mockClear();
await expect(files.seedFiles((async function* () {
yield { path: "first", offset: 0, bytes: Buffer.from("first") };
throw new Error("source disappeared");
})())).rejects.toThrow("source disappeared");
const actions = vi.mocked(f.backend.remote).mock.calls.map(([, input]) => input.action);
expect(actions).toEqual(["seed-begin", "seed-chunk", "seed-abort"]);
vi.mocked(f.backend.remote).mockResolvedValue({ started: false });
let read = false;
await expect(files.seedFiles((async function* () { read = true; yield { path: "unused", offset: 0, bytes: Buffer.alloc(0) }; })())).resolves.toEqual({ seeded: false });
expect(read).toBe(false);
});
it("realizes probe cwd before readiness without consuming the initial personal-home seed", async () => {
const f = fixture();
const temp = realpathSync(mkdtempSync(join(tmpdir(), "computer-probe-")));
@@ -687,9 +687,33 @@ finally:
base64: bytes.toString("base64"),
expectedSha256,
});
const seedFiles = async (chunks: AsyncIterable<{ path: string; offset: number; bytes: Buffer }>): Promise<{ seeded: boolean }> => {
const seedId = randomUUID();
const started = await remote({ action: "seed-begin", seedId });
if (!started.started) return { seeded: false };
try {
for await (const chunk of chunks) {
if (chunk.bytes.length > 1024 * 1024) throw new ComputerError("invalid", "Initial home chunk exceeds its size limit");
await remote({ action: "seed-chunk", seedId, path: chunk.path, offset: chunk.offset, base64: chunk.bytes.toString("base64") });
}
return await remote({ action: "seed-commit", seedId });
} catch (error) {
await remote({ action: "seed-abort", seedId }).catch(() => undefined);
throw error;
}
};
const seedBytes = async (files: Record<string, Buffer>): Promise<{ seeded: boolean }> => seedFiles((async function* () {
for (const [path, bytes] of Object.entries(files)) {
for (let offset = 0; offset < Math.max(1, bytes.length); offset += 1024 * 1024) {
yield { path, offset, bytes: bytes.subarray(offset, offset + 1024 * 1024) };
}
}
})());
return {
root,
readBytes,
seedFiles,
seedBytes,
writeBytes,
async read(path: string) {
const value = await readBytes(path);
@@ -729,29 +753,8 @@ finally:
): Promise<{ sha256: string }> {
return remote({ action: "move", path: from, to, expectedSha256 });
},
async seedBytes(
files: Record<string, Buffer>,
): Promise<{ seeded: boolean }> {
return remote({
action: "seed",
files: Object.fromEntries(
Object.entries(files).map(([key, value]) => [
key,
value.toString("base64"),
]),
),
});
},
async seed(files: Record<string, string>): Promise<{ seeded: boolean }> {
return remote({
action: "seed",
files: Object.fromEntries(
Object.entries(files).map(([key, value]) => [
key,
Buffer.from(value).toString("base64"),
]),
),
});
return seedBytes(Object.fromEntries(Object.entries(files).map(([path, content]) => [path, Buffer.from(content)])));
},
};
}