Files
PaperClipAI/server/src/modules/computers/adapters/boat.test.ts
T

746 lines
39 KiB
TypeScript

import {
existsSync,
readFileSync,
readdirSync,
statSync,
mkdtempSync,
realpathSync,
rmSync,
symlinkSync,
writeFileSync,
mkdirSync,
} from "node:fs";
import { createHash, randomUUID } from "node:crypto";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { spawn, spawnSync } from "node:child_process";
import { remoteProgram } from "./remote-program.js";
import { afterEach, describe, it, expect, vi } from "vitest";
import { boatBackend, createBoatTransportAdmission, desktopReadinessProgram, processProgram, runnerPortInventoryProgram } from "./boat.js";
import { buildGitAuthInvocation } from "../../../services/git-credentials.js";
import type { ComputerRecord } from "../domain/ledger.js";
const sshFactory = vi.hoisted(() => vi.fn());
vi.mock("@paperclipai/adapter-utils/ssh", async importOriginal => ({
...await importOriginal<typeof import("@paperclipai/adapter-utils/ssh")>(),
createSshCommandManagedRuntimeRunner: sshFactory,
}));
const record: ComputerRecord = {
id: "computer",
companyId: "company",
environmentId: "environment",
providerId: "bx_test",
ledger: {
controllerId: "controller",
status: "attached",
secretRef: { type: "secret_ref", secretId: "secret" },
owners: [],
placements: {},
action: null,
},
};
const json = (value: unknown, status = 200, headers?: Record<string, string>) =>
new Response(JSON.stringify(value), {
status,
headers: { "content-type": "application/json", ...headers },
});
describe("Boat transport validation", () => {
it("reads outgoing and listening TCP ports across IPv4 and IPv6", () => {
const root = mkdtempSync(join(tmpdir(), "boat-port-inventory-"));
try {
writeFileSync(join(root, "range"), "32768 60999\n");
writeFileSync(join(root, "tcp"), "header\n0: 00000000:3F00 00000000:0000 0A\n");
writeFileSync(join(root, "tcp6"), "header\n0: 00000000000000000000000000000000:A87A 00000000:01BB 08\n");
const program = runnerPortInventoryProgram
.replace("/proc/sys/net/ipv4/ip_local_port_range", join(root, "range"))
.replace("/proc/net/tcp6", join(root, "tcp6"))
.replace("/proc/net/tcp", join(root, "tcp"));
const result = spawnSync("python3", ["-c", program], { encoding: "utf8" });
expect(result.status, result.stderr).toBe(0);
expect(JSON.parse(result.stdout)).toEqual({ ephemeralStart: 32768, ephemeralEnd: 60999, occupied: [16128, 43130] });
} finally { rmSync(root, { recursive: true, force: true }); }
});
it("recognizes idle as an already running computer", async () => {
const fetcher = vi.fn(async () =>
json({
sandbox: { id: "bx_test", state: "idle", snapshots: true, stop: null },
}),
);
await boatBackend(async () => "secret", fetcher).ready(record);
expect(fetcher).toHaveBeenCalledTimes(1);
});
it("rejects another physical ID and snapshot-disabled computers", async () => {
await expect(
boatBackend(
async () => "secret",
vi.fn(async () =>
json({ sandbox: { id: "bx_wrong", state: "idle", snapshots: true } }),
),
).inspect(record),
).rejects.toMatchObject({ code: "provider_error" });
await expect(
boatBackend(
async () => "secret",
vi.fn(async () =>
json({ sandbox: { id: "bx_test", state: "idle", snapshots: false } }),
),
).ready(record),
).rejects.toMatchObject({ code: "invalid" });
});
it("keeps port credentials out of the WSS URL and does not forward API authorization to the hosting origin", async () => {
const fetcher = vi.fn(async (url: any, options?: any) =>
String(url).startsWith("https://boat.dev/")
? json({
url: "https://test.on.boat.dev/?_token=private-token",
access: "private",
})
: new Response(null, {
status: 302,
headers: {
"set-cookie": "_port_auth=private-cookie; Secure; HttpOnly",
},
}),
);
const endpoint = await boatBackend(
async () => "api-secret",
fetcher,
).ingress(record, 43127, "/runner/ws");
expect(endpoint).toEqual({
url: "wss://test.on.boat.dev/runner/ws",
secretHeaders: { Cookie: "_port_auth=private-cookie" },
});
expect(fetcher.mock.calls[1]![1].headers).toBeUndefined();
expect(fetcher.mock.calls[1]![1].redirect).toBe("manual");
});
it("refuses public ports and arbitrary hosting origins", async () => {
for (const url of [
"http://test.on.boat.dev/?_token=x",
"https://evil.example/?_token=x",
])
await expect(
boatBackend(
async () => "secret",
vi.fn(async () => json({ url, access: "private" })),
).preview(record, 5173),
).rejects.toMatchObject({ code: "provider_error" });
await expect(
boatBackend(
async () => "secret",
vi.fn(async () =>
json({ url: "https://test.on.boat.dev/", access: "public" }),
),
).preview(record, 5173),
).rejects.toMatchObject({ code: "provider_error" });
});
});
const roots: string[] = [];
function fixture() {
const temp = realpathSync(mkdtempSync(join(tmpdir(), "computer-files-")));
roots.push(temp);
const root = join(temp, "home");
const call = (input: Record<string, unknown>, env = process.env) => {
const result = spawnSync(
"python3",
["-c", remoteProgram.replaceAll("/home/user/paperclip/", `${temp}/`)],
{ input: JSON.stringify({ ...input, root }), encoding: "utf8", env },
);
if (result.status !== 0) throw new Error(result.stderr);
return JSON.parse(result.stdout);
};
return { root, temp, call };
}
afterEach(() => {
for (const root of roots.splice(0))
rmSync(root, { recursive: true, force: true });
});
describe("confined computer files", () => {
it("seeds absent home only and preserves existing content", () => {
const f = fixture();
expect(
f.call({
action: "seed",
files: { "nested/one": Buffer.from("first").toString("base64") },
}),
).toEqual({ seeded: true });
expect(
f.call({
action: "seed",
files: { "nested/one": Buffer.from("overwrite").toString("base64") },
}),
).toEqual({ seeded: false });
expect(
Buffer.from(
f.call({ action: "read", path: "nested/one" }).base64,
"base64",
).toString(),
).toBe("first");
});
it("rejects traversal, symlink escapes, and oversized reads", () => {
const f = fixture();
f.call({
action: "seed",
files: { ok: Buffer.from("12345").toString("base64") },
});
mkdirSync(join(f.temp, "outside"));
writeFileSync(join(f.temp, "outside", "secret"), "private");
symlinkSync(join(f.temp, "outside"), join(f.root, "escape"));
expect(f.call({ action: "read", path: "../outside/secret" })).toEqual({
error: "invalid",
});
expect(f.call({ action: "read", path: "escape/secret" })).toEqual({
error: "invalid",
});
expect(f.call({ action: "read", path: "ok", maxBytes: 4 })).toEqual({
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-") && statSync(join(f.temp, name)).isDirectory())!;
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([".paperclip-seed-home.lock", "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([".paperclip-seed-home.lock"]);
});
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([".paperclip-seed-home.lock", "home"]);
});
it("reclaims only the expired receipted upload after an interrupted seed", () => {
const f = fixture(); const first = randomUUID(); const next = randomUUID();
const receiptPath = join(f.temp, ".paperclip-seed-home.json");
const oldStage = join(f.temp, `.paperclip-seed-home-${first}`);
const unrelated = join(f.temp, `.paperclip-seed-home-${randomUUID()}`);
mkdirSync(unrelated); writeFileSync(join(unrelated, "user-file"), "keep");
expect(f.call({ action: "seed-begin", seedId: first })).toEqual({ started: true });
expect(f.call({ action: "seed-chunk", seedId: first, path: "partial", offset: 0, base64: Buffer.alloc(1024 * 1024).toString("base64") })).toEqual({});
expect(f.call({ action: "seed-begin", seedId: next })).toEqual({ error: "conflict" });
const receipt = JSON.parse(readFileSync(receiptPath, "utf8"));
expect(receipt.seedId).toBe(first); expect(receipt.updatedAt).toBeGreaterThan(0);
writeFileSync(receiptPath, JSON.stringify({ ...receipt, updatedAt: Date.now() / 1000 - 180 }));
expect(f.call({ action: "seed-begin", seedId: next })).toEqual({ error: "conflict" });
writeFileSync(receiptPath, JSON.stringify({ ...receipt, updatedAt: 0 }));
expect(f.call({ action: "seed-begin", seedId: next })).toEqual({ started: true });
expect(existsSync(oldStage)).toBe(false);
expect(f.call({ action: "seed-chunk", seedId: first, path: "late", offset: 0, base64: "" })).toEqual({ error: "conflict" });
expect(readFileSync(join(unrelated, "user-file"), "utf8")).toBe("keep");
expect(f.call({ action: "seed-abort", seedId: first })).toEqual({});
expect(JSON.parse(readFileSync(receiptPath, "utf8")).seedId).toBe(next);
expect(f.call({ action: "seed-chunk", seedId: next, path: "complete", offset: 0, base64: Buffer.from("done").toString("base64") })).toEqual({});
expect(f.call({ action: "seed-commit", seedId: next })).toEqual({ seeded: true });
expect(readFileSync(join(f.root, "complete"), "utf8")).toBe("done");
expect(existsSync(receiptPath)).toBe(false);
});
it("cleans abandoned upload receipts on home admission but preserves unverified content", () => {
const f = fixture(); const seedId = randomUUID();
const receiptPath = join(f.temp, ".paperclip-seed-home.json");
const stage = join(f.temp, `.paperclip-seed-home-${seedId}`);
f.call({ action: "seed-begin", seedId });
f.call({ action: "seed-chunk", seedId, path: "partial", offset: 0, base64: Buffer.from("partial").toString("base64") });
mkdirSync(f.root); writeFileSync(join(f.root, "user-file"), "keep");
const receipt = JSON.parse(readFileSync(receiptPath, "utf8"));
writeFileSync(receiptPath, JSON.stringify({ ...receipt, updatedAt: 0 }));
writeFileSync(join(stage, "owner.json"), JSON.stringify({ purpose: "user-owned" }));
expect(f.call({ action: "list", limit: 1 })).toEqual({ error: "invalid" });
expect(readFileSync(join(stage, "tree", "partial"), "utf8")).toBe("partial");
writeFileSync(join(stage, "owner.json"), JSON.stringify({ purpose: receipt.purpose, root: receipt.root, seedId }));
expect(f.call({ action: "list", limit: 1 }).entries[0].name).toBe("user-file");
expect(existsSync(stage)).toBe(false); expect(existsSync(receiptPath)).toBe(false);
expect(readFileSync(join(f.root, "user-file"), "utf8")).toBe("keep");
});
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: {} });
const first = f.call({
action: "write",
path: "file",
expectedSha256: null,
base64: Buffer.from("one").toString("base64"),
});
const second = f.call({
action: "write",
path: "file",
expectedSha256: first.sha256,
base64: Buffer.from("two").toString("base64"),
});
expect(
f.call({
action: "write",
path: "file",
expectedSha256: first.sha256,
base64: "",
}),
).toEqual({ error: "conflict" });
expect(
f.call({
action: "move",
path: "file",
to: "other",
expectedSha256: second.sha256,
}),
).toEqual({ sha256: second.sha256 });
expect(f.call({ action: "list" }).entries.map((e: any) => e.name)).toEqual([
"other",
]);
expect(
f.call({ action: "remove", path: "other", expectedSha256: first.sha256 }),
).toEqual({ error: "conflict" });
});
it("bounds directory enumeration and stats files beyond the listing cap", () => {
const f = fixture();
f.call({ action: "seed", files: {} });
for (let i = 0; i < 1005; i++) writeFileSync(join(f.root, `file-${i}`), "content");
const page = f.call({ action: "list" });
expect(page.entries).toHaveLength(1000);
expect(page.truncated).toBe(true);
const omitted = Array.from({ length: 1005 }, (_, i) => `file-${i}`).find(name => !page.entries.some((entry: { name: string }) => entry.name === name))!;
expect(f.call({ action: "stat", path: omitted })).toMatchObject({ name: omitted, kind: "file", size: 7 });
expect(f.call({ action: "list", limit: 2 }).entries).toHaveLength(2);
expect(f.call({ action: "list", limit: 1001 })).toEqual({ error: "invalid" });
});
it("clones a private checkout with operation-scoped auth and leaves no credential material behind", () => {
const f = fixture();
const source = join(f.temp, "source");
mkdirSync(source);
const realGit = spawnSync("which", ["git"], { encoding: "utf8" }).stdout.trim();
for (const args of [["init"], ["-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "--allow-empty", "-m", "base"]]) {
expect(spawnSync(realGit, args, { cwd: source, encoding: "utf8" }).status).toBe(0);
}
const bin = join(f.temp, "bin");
mkdirSync(bin);
// Stand in for an authenticated GitHub transport, using real Git and the
// production URL-scoped credential helper, without any external network.
writeFileSync(join(bin, "git"), `#!${process.execPath}
const {spawnSync}=require("node:child_process");
const args=process.argv.slice(2), clone=args.indexOf("clone");
if(clone<0) process.exit(9);
if(args.some(arg=>arg.includes("scoped-fixture-token"))) process.exit(10);
const credential=spawnSync(process.env.FIXTURE_REAL_GIT,[...args.slice(0,clone),"credential","fill"],{input:"protocol=https\\nhost=github.com\\n\\n",encoding:"utf8"});
if(credential.status!==0||!credential.stdout.includes("password=scoped-fixture-token")) process.exit(11);
args[clone+2]=process.env.FIXTURE_SOURCE;
const result=spawnSync(process.env.FIXTURE_REAL_GIT,args,{encoding:"utf8"});
process.stdout.write(result.stdout||"");process.stderr.write(result.stderr||"");process.exit(result.status??1);
`, { mode: 0o700 });
const env = { ...process.env, PATH: `${bin}:${process.env.PATH}`, FIXTURE_REAL_GIT: realGit, FIXTURE_SOURCE: source };
const request = { action: "workspace", mode: "shared", repositoryUrl: "https://github.com/company/private.git" };
expect(f.call(request, env)).toEqual({ error: "command_failed" });
const gitAuth = buildGitAuthInvocation({ token: "scoped-fixture-token", source: "managed_connection", secretName: null });
const result = f.call({ ...request, gitAuth }, env);
expect(result.remoteCwd).toBe(join(f.root, "checkout"));
const config = readFileSync(join(result.remoteCwd, ".git", "config"), "utf8");
expect(config).not.toContain("scoped-fixture-token");
expect(config).not.toContain("credential");
expect(spawnSync(realGit, ["rev-parse", "HEAD"], { cwd: result.remoteCwd }).status).toBe(0);
});
it.each(["shared", "worktree"])("serializes concurrent first checkout creation for %s workspaces", async mode => {
const f = fixture();
const source = join(f.temp, "source"), bin = join(f.temp, "bin");
mkdirSync(source); mkdirSync(bin);
const git = spawnSync("which", ["git"], { encoding: "utf8" }).stdout.trim();
for (const args of [["init"], ["-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "--allow-empty", "-m", "base"]]) {
expect(spawnSync(git, args, { cwd: source, encoding: "utf8" }).status).toBe(0);
}
const started = join(f.temp, "clone-started"), release = join(f.temp, "clone-release"), clones = join(f.temp, "clones");
// Pause real Git cloning after its destination exists. The second actual
// remote program must wait instead of inspecting this unfinished checkout.
writeFileSync(join(bin, "git"), `#!${process.execPath}
const fs=require("node:fs"),{spawnSync}=require("node:child_process");
const args=process.argv.slice(2),clone=args.indexOf("clone");
if(clone>=0){
fs.appendFileSync(process.env.FIXTURE_CLONES,"clone\\n");
fs.mkdirSync(args.at(-1),{recursive:true});fs.writeFileSync(process.env.FIXTURE_STARTED,"ready");
const deadline=Date.now()+5000;
while(!fs.existsSync(process.env.FIXTURE_RELEASE)){if(Date.now()>deadline)process.exit(90);Atomics.wait(new Int32Array(new SharedArrayBuffer(4)),0,0,10);}
const url=args[clone+2];args[clone+2]=process.env.FIXTURE_SOURCE;
const result=spawnSync(process.env.FIXTURE_GIT,args,{encoding:"utf8"});
if(result.status!==0){process.stderr.write(result.stderr);process.exit(result.status??1);}
process.exit(spawnSync(process.env.FIXTURE_GIT,["-C",args.at(-1),"remote","set-url","origin",url]).status??1);
}
const result=spawnSync(process.env.FIXTURE_GIT,args,{encoding:"utf8"});
process.stdout.write(result.stdout||"");process.stderr.write(result.stderr||"");process.exit(result.status??1);
`, { mode: 0o700 });
const env = { ...process.env, PATH: `${bin}:${process.env.PATH}`, FIXTURE_GIT: git, FIXTURE_SOURCE: source,
FIXTURE_STARTED: started, FIXTURE_RELEASE: release, FIXTURE_CLONES: clones };
const call = (taskId: string) => new Promise<{ remoteCwd: string }>((resolve, reject) => {
const child = spawn("python3", ["-c", remoteProgram.replaceAll("/home/user/paperclip/", `${f.temp}/`)], { env });
let stdout = "", stderr = "";
child.stdout.on("data", value => { stdout += value; }); child.stderr.on("data", value => { stderr += value; });
child.on("error", reject); child.on("close", code => {
if (code !== 0) reject(new Error(stderr));
else { try { resolve(JSON.parse(stdout)); } catch (error) { reject(error); } }
});
child.stdin.end(JSON.stringify({ root: f.root, action: "workspace", mode, taskId, repositoryUrl: "https://github.com/company/shared.git" }));
});
const first = call("first");
await vi.waitFor(() => expect(existsSync(started)).toBe(true));
const second = call("second");
let status: unknown;
try {
status = await Promise.race([second.then(() => "completed"), new Promise(resolve => setTimeout(() => resolve("waiting"), 100))]);
} finally { writeFileSync(release, "continue"); }
const results = await Promise.all([first, second]);
expect(status).toBe("waiting");
expect(readFileSync(clones, "utf8").trim().split("\n")).toHaveLength(1);
for (const [index, result] of results.entries()) {
expect(result.remoteCwd).toBe(mode === "shared" ? join(f.root, "checkout") : join(f.root, "tasks", index === 0 ? "first" : "second"));
expect(spawnSync(git, ["rev-parse", "--show-toplevel"], { cwd: result.remoteCwd, encoding: "utf8" }).stdout.trim()).toBe(result.remoteCwd);
}
});
it("rejects substituted project locks without touching their target", () => {
const f = fixture(); mkdirSync(f.root);
const outside = join(f.temp, "outside"); writeFileSync(outside, "keep");
symlinkSync(outside, join(f.root, ".paperclip-workspace.lock"));
expect(f.call({ action: "workspace", mode: "shared" })).toEqual({ error: "invalid" });
expect(readFileSync(outside, "utf8")).toBe("keep");
});
it.each([false, true])("uses the requested worktree branch separately from its start ref (explicit base: %s)", explicitBase => {
const f = fixture();
const checkout = join(f.root, "checkout");
mkdirSync(checkout, { recursive: true });
const git = (...args: string[]) => {
const result = spawnSync("git", args, { cwd: checkout, encoding: "utf8" });
if (result.status !== 0) throw new Error(result.stderr);
return result.stdout.trim();
};
git("init");
git("-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "--allow-empty", "-m", "base");
const base = git("rev-parse", "HEAD");
git("-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "--allow-empty", "-m", "latest");
const expectedHead = explicitBase ? base : git("rev-parse", "HEAD");
const input = { action: "workspace", mode: "worktree", taskId: "issue-1", branch: "paperclip/task-issue-1", ...(explicitBase ? { baseRef: base } : {}) };
const result = f.call(input);
expect(result.remoteCwd).toBe(join(f.root, "tasks", "issue-1"));
expect(git("-C", result.remoteCwd, "symbolic-ref", "--short", "HEAD")).toBe(input.branch);
expect(git("-C", result.remoteCwd, "rev-parse", "HEAD")).toBe(expectedHead);
writeFileSync(join(result.remoteCwd, "preserved"), "user work");
expect(f.call({ ...input, branch: "another-branch" })).toEqual({ error: "conflict" });
expect(git("-C", result.remoteCwd, "status", "--short")).toContain("preserved");
});
it("roundtrips binary bytes without interpreting text", () => {
const f = fixture();
const bytes = Buffer.from([0, 255, 254, 13, 10, 128]);
f.call({ action: "seed", files: { binary: bytes.toString("base64") } });
expect(
Buffer.from(f.call({ action: "read", path: "binary" }).base64, "base64"),
).toEqual(bytes);
});
});
describe("retired owner spool cleanup after provider resume", () => {
function cleanupFixture(state = "inactive") {
const temp = realpathSync(mkdtempSync(join(tmpdir(), "computer-retired-")));
roots.push(temp);
const owners = join(temp, "owners");
const bin = join(temp, "bin");
const calls = join(temp, "systemctl-calls");
mkdirSync(bin);
writeFileSync(join(bin, "systemctl"), `#!/bin/sh
printf '%s\\n' "$*" >> '${calls}'
case "$*" in *show*) printf '%s\\n' '${state}';; esac
`, { mode: 0o700 });
const owner = (id: string, generation = 7) => {
const root = join(owners, id);
mkdirSync(join(root, "command-one"), { recursive: true });
writeFileSync(join(root, "generation"), String(generation));
writeFileSync(join(root, "command-one", "stdin"), "private command input", { mode: 0o600 });
writeFileSync(join(root, "preserve"), "ownership metadata");
return root;
};
const cleanup = (input = [{ id: "retired-owner", generation: 7 }]) => spawnSync(
"python3", ["-c", processProgram.replaceAll("/home/user/.paperclip-owners", owners)],
{ input: JSON.stringify({ action: "cleanup-retired", owners: input }), encoding: "utf8", env: { ...process.env, PATH: `${bin}:${process.env.PATH}` } },
);
return { temp, owners, calls, owner, cleanup };
}
it("reaps only recorded retired command spools and is idempotent", () => {
const f = cleanupFixture();
const retired = f.owner("retired-owner");
const active = f.owner("active-owner");
const outside = join(f.temp, "user-files");
mkdirSync(outside);
writeFileSync(join(outside, "preserve"), "user data");
symlinkSync(outside, join(retired, "command-symlink"));
expect(f.cleanup().status).toBe(0);
expect(existsSync(join(retired, "command-one"))).toBe(false);
expect(existsSync(join(retired, "retired"))).toBe(true);
expect(readFileSync(join(retired, "preserve"), "utf8")).toBe("ownership metadata");
expect(readFileSync(join(active, "command-one", "stdin"), "utf8")).toBe("private command input");
expect(readFileSync(join(outside, "preserve"), "utf8")).toBe("user data");
const calls = readFileSync(f.calls, "utf8");
expect(calls).toContain("--user stop paperclip-retired-owner.slice paperclip-retired-owner.service");
expect(calls).not.toContain("active-owner");
expect(f.cleanup().status).toBe(0);
expect(readFileSync(f.calls, "utf8")).toBe(calls);
});
it("preserves spools when retirement cannot be confirmed", () => {
const f = cleanupFixture("active");
const retired = f.owner("retired-owner");
const result = f.cleanup();
expect(result.status).not.toBe(0);
expect(result.stderr).toContain("process retirement unconfirmed");
expect(existsSync(join(retired, "command-one", "stdin"))).toBe(true);
});
it("rejects a stale generation without stopping its current process", () => {
const f = cleanupFixture();
const retired = f.owner("retired-owner", 8);
expect(JSON.parse(f.cleanup().stdout)).toEqual({ error: "conflict" });
expect(existsSync(join(retired, "command-one", "stdin"))).toBe(true);
expect(existsSync(f.calls)).toBe(false);
});
it("rejects symlink owner roots and ignores missing retired roots", () => {
const f = cleanupFixture();
const active = f.owner("active-owner");
symlinkSync(active, join(f.owners, "retired-owner"));
expect(f.cleanup().stderr).toContain("invalid retired owner directory");
expect(existsSync(join(active, "command-one", "stdin"))).toBe(true);
expect(f.cleanup([{ id: "missing-owner", generation: 7 }]).status).toBe(0);
expect(existsSync(f.calls)).toBe(false);
});
});
describe("Boat desktop input readiness", () => {
function desktopFixture(initiallyHealthy: boolean) {
const temp = realpathSync(mkdtempSync(join(tmpdir(), "computer-desktop-")));
roots.push(temp);
const runtime = join(temp, String(process.getuid!()));
const bin = join(temp, "bin");
const healthy = join(temp, "healthy");
const calls = join(temp, "repairs");
const units = join(temp, "units");
mkdirSync(runtime, { mode: 0o700 });
mkdirSync(bin);
if (initiallyHealthy) writeFileSync(healthy, "ready");
writeFileSync(join(bin, "ibus"), "#!/bin/sh\ntest \"$HOME\" = /home/user && test \"$XDG_CONFIG_HOME\" = /home/user/.config && test \"$XDG_CACHE_HOME\" = /home/user/.cache || exit 1\nprintf '%s\\n' 'unix:path=/test/ibus'\n", { mode: 0o700 });
writeFileSync(join(bin, "gdbus"), `#!/bin/sh\ntest -f '${healthy}'\n`, { mode: 0o700 });
writeFileSync(join(bin, "ibus-daemon"), `#!/bin/sh\nprintf '%s\\n' "$*" >> '${calls}'\ntouch '${healthy}'\n`, { mode: 0o700 });
writeFileSync(join(bin, "systemd-run"), `#!/usr/bin/env python3
import os,sys,json
args=sys.argv[1:]
with open(${JSON.stringify(units)},'a') as target:target.write(json.dumps(args)+'\\n')
env=dict(os.environ)
for arg in args:
if arg.startswith('--setenv='):
key,value=arg[len('--setenv='):].split('=',1);env[key]=value
command=args[args.index('--')+1:]
os.execve(command[0],command,env)
`, { mode: 0o700 });
const call = (program = desktopReadinessProgram) => spawnSync("python3", ["-c", program.replaceAll("/run/user/", `${temp}/`).replaceAll("/usr/bin/ibus-daemon", join(bin, "ibus-daemon"))], {
encoding: "utf8", env: { ...process.env, HOME: join(temp, "agent-home"), XDG_CONFIG_HOME: join(temp, "agent-config"), XDG_CACHE_HOME: join(temp, "agent-cache"), PATH: `${bin}:${process.env.PATH}` },
});
return { temp, runtime, healthy, calls, units, call };
}
it("preserves a healthy input service and emits no MCP protocol output", () => {
const f = desktopFixture(true);
const result = f.call();
expect(result.status).toBe(0);
expect(result.stdout).toBe("");
expect(existsSync(f.calls)).toBe(false);
});
it("repairs a disconnected restored socket and checks it again after another resume", () => {
const f = desktopFixture(false);
expect(f.call().status).toBe(0);
const expected = `--replace --daemonize --xim --address unix:path=${f.runtime}/paperclip-ibus/bus\n`;
expect(readFileSync(f.calls, "utf8")).toBe(expected);
const unit = JSON.parse(readFileSync(f.units, "utf8").trim());
expect(unit).toEqual(expect.arrayContaining(["--slice=app.slice", "--collect", "--property=ExitType=cgroup", "--setenv=HOME=/home/user"]));
expect(unit.find((value: string) => value.startsWith("--unit="))).toMatch(/^--unit=paperclip-desktop-input-[a-f0-9]{32}\.service$/);
expect(f.call().status).toBe(0);
expect(readFileSync(f.calls, "utf8")).toBe(expected);
rmSync(f.healthy);
expect(f.call().status).toBe(0);
expect(readFileSync(f.calls, "utf8")).toBe(expected.repeat(2));
});
it("checks input on explicit Connect but reuses credentials without daemon work during presence", async () => {
const id = randomUUID();
const scoped = { ...record, id, providerId: `bx_${id}` };
const execute = vi.fn(async () => ({ exitCode: 0, stdout: "{}", stderr: "", timedOut: false, signal: null, pid: null, startedAt: "" }));
sshFactory.mockReturnValue({ execute });
const fetcher = vi.fn(async (url: string | URL | Request) => String(url).endsWith("/sshkey")
? json({ hostKey: "ssh-ed25519 AAAA", sshEndpoint: "fixture.invalid:2222" })
: json({ desktopUrl: "https://fixture.on.boat.dev/" }));
const backend = boatBackend(async () => "fixture-key", fetcher);
const viewer = await backend.desktop(scoped);
expect(execute).toHaveBeenCalledOnce();
expect(await backend.desktop(scoped, { checkInput: false })).toEqual(viewer);
expect(execute).toHaveBeenCalledOnce();
expect(await backend.desktop(scoped)).toEqual(viewer);
expect(execute).toHaveBeenCalledTimes(2);
expect(fetcher.mock.calls.filter(([url]) => String(url).endsWith("/desktop"))).toHaveLength(1);
sshFactory.mockReset();
});
it("rejects a symlink input directory without replacing the daemon", () => {
const f = desktopFixture(false);
symlinkSync(f.temp, join(f.runtime, "paperclip-ibus"));
const result = f.call();
expect(result.status).not.toBe(0);
expect(result.stderr).toContain("invalid desktop input directory");
expect(existsSync(f.calls)).toBe(false);
});
it("executes the existing Cua MCP process only after input readiness", () => {
const f = desktopFixture(false);
const tool = boatBackend(async () => "unused").computerTool();
const cua = join(f.temp, "cua");
writeFileSync(cua, `#!/bin/sh\nprintf '%s\\n' "$*"\n`, { mode: 0o700 });
expect(tool.command).toBe("python3");
const result = f.call(tool.args[1]!.replaceAll("/opt/ascii/cua-driver/cua-driver", cua));
expect(result.status).toBe(0);
expect(result.stdout).toBe("mcp --socket /run/ascii-cua/driver.sock\n");
expect(existsSync(f.healthy)).toBe(true);
});
});
describe("Boat SSH admission", () => {
const result = { exitCode: 0, signal: null, timedOut: false, stdout: "", stderr: "", pid: null, startedAt: "" };
it("reserves lifecycle and identity capacity while bulk and long legacy commands are active", async () => {
const admission = createBoatTransportAdmission();
const completions: Array<() => void> = [];
const runner = { execute: vi.fn(async () => new Promise<typeof result>((resolve) => completions.push(() => resolve(result)))) };
const input = { command: "legacy", timeoutMs: 60_000 };
const jobs = Array.from({ length: 8 }, () => admission(runner, { ...input, stdin: "x".repeat(1024 * 1024 + 1) }, true));
jobs.push(admission(runner, input, false), admission(runner, input, false));
await Promise.resolve();
expect(runner.execute).toHaveBeenCalledTimes(6);
jobs.push(admission(runner, { command: "retire", timeoutMs: 10_000 }, true));
jobs.push(admission(runner, { command: "identity", timeoutMs: 10_000 }, true));
await Promise.resolve();
expect(runner.execute).toHaveBeenCalledTimes(8);
while (completions.length) {
completions.splice(0).forEach((finish) => finish());
await new Promise((resolve) => setTimeout(resolve, 0));
}
await Promise.all(jobs);
expect(runner.execute).toHaveBeenCalledTimes(12);
});
it("classifies checkout and file operations below reserved lifecycle capacity", async () => {
const id = randomUUID();
const scoped = { ...record, id, providerId: `bx_${id}` };
const completions: Array<() => void> = [];
const execute = vi.fn(async (_input: { stdin?: string }) => new Promise<typeof result>(resolve => {
completions.push(() => resolve({ ...result, stdout: "{}" }));
}));
sshFactory.mockReturnValue({ execute });
const backend = boatBackend(async () => "fixture-key", vi.fn(async () => json({
hostKey: "ssh-ed25519 AAAA", sshEndpoint: "fixture.invalid:2222",
})));
const jobs = Array.from({ length: 8 }, (_, index) => backend.remote(scoped, {
action: index % 2 ? "workspace" : "list", root: "/home/user/paperclip/company/project",
}));
await vi.waitFor(() => expect(execute).toHaveBeenCalledTimes(6));
jobs.push(backend.retire(scoped, { id: "owner", generation: 1, kind: "runner", phase: "retiring", port: 43127,
deadline: null, absoluteDeadline: null, process: null }));
jobs.push(backend.remote(scoped, { action: "owned-port", port: 5173, ownerId: "owner" }));
await vi.waitFor(() => expect(execute).toHaveBeenCalledTimes(8));
const actions = execute.mock.calls.map(([input]) => JSON.parse(input.stdin!).action);
expect(actions.filter(action => action === "workspace" || action === "list")).toHaveLength(6);
expect(actions).toContain("retire"); expect(actions).toContain("owned-port");
completions.splice(0).forEach(finish => finish());
await vi.waitFor(() => expect(execute).toHaveBeenCalledTimes(10));
completions.splice(0).forEach(finish => finish());
await Promise.all(jobs);
sshFactory.mockReset();
});
it("expires queued work without starting it and preserves the original remaining timeout", async () => {
vi.useFakeTimers();
try {
const admission = createBoatTransportAdmission();
const completions: Array<() => void> = [];
const runner = { execute: vi.fn(async (_input: { command: string; timeoutMs?: number }) => new Promise<typeof result>((resolve) => completions.push(() => resolve(result)))) };
const jobs = Array.from({ length: 6 }, () => admission(runner, { command: "long", timeoutMs: 60_000 }, false));
await vi.advanceTimersByTimeAsync(0);
const expired = admission(runner, { command: "expired", timeoutMs: 100 }, false);
const queued = admission(runner, { command: "queued", timeoutMs: 1000 }, false);
await vi.advanceTimersByTimeAsync(200);
expect(await expired).toMatchObject({ timedOut: true, pid: null });
expect(runner.execute).toHaveBeenCalledTimes(6);
completions.shift()!();
await vi.advanceTimersByTimeAsync(0);
expect(runner.execute).toHaveBeenLastCalledWith(expect.objectContaining({ command: "queued", timeoutMs: 800 }));
completions.splice(0).forEach((finish) => finish());
await Promise.all([...jobs, queued]);
} finally { vi.useRealTimers(); }
});
it("releases all capacity after failed or cancelled transport commands without replay", async () => {
const admission = createBoatTransportAdmission();
const runner = { execute: vi.fn(async () => { throw new Error("transport cancelled"); }) };
const settled = await Promise.allSettled(Array.from({ length: 12 }, () => admission(runner, { command: "once", timeoutMs: 1000 }, false)));
expect(settled.every((entry) => entry.status === "rejected")).toBe(true);
expect(runner.execute).toHaveBeenCalledTimes(12);
});
});
describe("remote graceful retirement fence", () => {
it("records the exact generation deadline and blocks late launch or generation reuse", () => {
const root = realpathSync(mkdtempSync(join(tmpdir(), "computer-grace-")));
roots.push(root);
const owners = join(root, "owners");
const boot = join(root, "boot");
writeFileSync(boot, "boot-id");
const code = processProgram.replaceAll("/home/user/.paperclip-owners", owners)
.replaceAll("/proc/sys/kernel/random/boot_id", boot);
const invoke = (action: string, owner: Record<string, unknown>) => {
const result = spawnSync("python3", ["-c", code], { input: JSON.stringify({ action, owner }), encoding: "utf8" });
expect(result.status, result.stderr).toBe(0);
return JSON.parse(result.stdout);
};
const owner = { id: "owned", generation: 7, phase: "retiring", retirementDeadline: "2099-01-01T00:00:00Z" };
expect(invoke("advance", owner)).toEqual({});
const marker = readFileSync(join(owners, "owned", "retiring.json"), "utf8");
expect(JSON.parse(marker).deadline).toBe(Date.parse(owner.retirementDeadline));
expect(invoke("launch", owner)).toEqual({ error: "conflict" });
expect(invoke("advance", { ...owner, phase: "active", generation: 8 })).toEqual({ error: "conflict" });
expect(invoke("advance", { ...owner, generation: 6 })).toEqual({ error: "conflict" });
expect(readFileSync(join(owners, "owned", "retiring.json"), "utf8")).toBe(marker);
});
});