mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-24 01:41:42 +02:00
Native sandbox launch preparation stages fresh harness files and immutable runtime context in sequence. Overlap them only under the verified concurrent-sync capability, settle every started transfer before launch or cleanup, and preserve serial SSH behavior and error priority. A matched real-Daytona comparison reduces staging from 1,552 ms to 1,017 ms. The larger active-run difference includes model variation and is not attributed to this change. Add an explicit single-attempt diagnostic and synchronize stale Pi evidence metadata with its shipped profile. Validation: 764 native runner/tracing cases, 2,764 support cases, repository typecheck/build, fresh exact-head full CI/E2E/security and Apex 5/5 pass. Observability remains a silent no-op when no endpoint is configured. Co-Authored-By: Paperclip <noreply@paperclip.ing>
182 lines
9.0 KiB
TypeScript
182 lines
9.0 KiB
TypeScript
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import { nativeRuntimeContextFixture } from "./runtime-context.test-fixture.js";
|
|
import { stageRemoteLaunchAssets } from "./remote-launch-assets.js";
|
|
|
|
function deferred() {
|
|
let resolve!: () => void;
|
|
const promise = new Promise<void>((done) => { resolve = done; });
|
|
return { promise, resolve };
|
|
}
|
|
|
|
const roots: string[] = [];
|
|
afterEach(() => {
|
|
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
|
|
});
|
|
|
|
function fixture(options: { transport?: "sandbox" | "ssh"; concurrent?: boolean } = {}) {
|
|
const root = mkdtempSync(join(tmpdir(), "native-launch-assets-"));
|
|
roots.push(root);
|
|
mkdirSync(join(root, "codex-home"));
|
|
writeFileSync(join(root, "codex-home", "auth.json"), "fresh test auth");
|
|
writeFileSync(join(root, "codex-home", "config.toml"), "fresh test config");
|
|
const calls: string[] = [];
|
|
const source = nativeRuntimeContextFixture();
|
|
const target = nativeRuntimeContextFixture();
|
|
source.instructions.bundle.rootPath = join(root, "instructions");
|
|
target.instructions.bundle.rootPath = "/session/filesystem/context/instructions";
|
|
source.skills = [{ key: "test", runtimeName: "test", versionId: null, bundle: { ...source.instructions.bundle, rootPath: join(root, "skill") } }] as typeof source.skills;
|
|
target.skills = [{ ...source.skills[0]!, bundle: { ...source.skills[0]!.bundle, rootPath: "/session/filesystem/context/skills/0-digest" } }];
|
|
const stageFile = vi.fn(async (input: { sourcePath: string; targetPath: string; mode: number }) => {
|
|
calls.push(`file:${input.targetPath}`);
|
|
});
|
|
const stageDirectory = vi.fn(async (input: { sourcePath: string; targetPath: string; mode: number }) => {
|
|
calls.push(`directory:${input.targetPath}`);
|
|
});
|
|
const execute = vi.fn(async (_input: { command: string; args?: string[]; bypassSession?: boolean; timeoutMs?: number }) => {
|
|
calls.push("mkdir:codex-home");
|
|
return { exitCode: 0, signal: null, pid: null, startedAt: null, timedOut: false, stdout: "", stderr: "" };
|
|
});
|
|
const input: Parameters<typeof stageRemoteLaunchAssets>[0] = {
|
|
root,
|
|
transport: options.transport ?? "sandbox",
|
|
runner: { execute, allowConcurrentSyncOperations: options.concurrent },
|
|
persistenceDirectories: [{ name: "codex-home", location: "filesystem", targetPath: "/session/filesystem/codex-home" }],
|
|
runtimeContext: { source, target, filesystemRoot: "/session/filesystem" },
|
|
caBundleMapping: { sourcePath: join(root, "ca.pem"), targetPath: "/session/bin/ca.pem" },
|
|
stageFile,
|
|
stageDirectory,
|
|
};
|
|
return { input, root, calls, stageFile, stageDirectory, execute };
|
|
}
|
|
|
|
describe("remote launch asset staging", () => {
|
|
it("retains serial paths, private launch modes and immutable context modes without an opt-in", async () => {
|
|
const f = fixture();
|
|
await stageRemoteLaunchAssets(f.input);
|
|
expect(f.calls).toEqual([
|
|
"mkdir:codex-home",
|
|
"file:/session/filesystem/codex-home/auth.json",
|
|
"file:/session/filesystem/codex-home/config.toml",
|
|
"directory:/session/filesystem/context/instructions",
|
|
"directory:/session/filesystem/context/skills/0-digest",
|
|
"file:/session/filesystem/runtime-context.json",
|
|
"file:/session/bin/ca.pem",
|
|
]);
|
|
expect(f.stageFile.mock.calls.map(([input]) => input.mode)).toEqual([0o600, 0o600, 0o600, 0o600]);
|
|
expect(f.stageDirectory.mock.calls.map(([input]) => input.mode)).toEqual([0o555, 0o555]);
|
|
expect(f.execute.mock.calls[0]?.[0]).toMatchObject({ bypassSession: true, timeoutMs: 10_000 });
|
|
expect(f.execute.mock.calls[0]?.[0]?.args?.[1]).toContain("install -d -m 0700");
|
|
expect(readFileSync(f.stageFile.mock.calls[0]![0].sourcePath, "utf8")).toBe("fresh test auth");
|
|
});
|
|
|
|
it("stops serial staging before context and CA writes when launch material fails", async () => {
|
|
const f = fixture();
|
|
f.stageFile.mockRejectedValueOnce(new Error("launch failure"));
|
|
await expect(stageRemoteLaunchAssets(f.input)).rejects.toThrow("launch failure");
|
|
expect(f.stageDirectory).not.toHaveBeenCalled();
|
|
expect(f.stageFile).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("fails closed before any transfer when the persistence path is unavailable", async () => {
|
|
const f = fixture();
|
|
f.input.persistenceDirectories = [{ name: "codex-home", location: "filesystem", targetPath: null }];
|
|
await expect(stageRemoteLaunchAssets(f.input)).rejects.toThrow("bootstrap_target_unavailable");
|
|
expect(f.execute).not.toHaveBeenCalled();
|
|
expect(f.stageDirectory).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("stops before CA staging when the immutable context fails", async () => {
|
|
const f = fixture();
|
|
f.stageDirectory.mockRejectedValueOnce(new Error("context failure"));
|
|
await expect(stageRemoteLaunchAssets(f.input)).rejects.toThrow("context failure");
|
|
expect(f.stageFile.mock.calls.map(([input]) => input.targetPath)).toEqual([
|
|
"/session/filesystem/codex-home/auth.json", "/session/filesystem/codex-home/config.toml",
|
|
]);
|
|
});
|
|
|
|
it("does not require a context or launch credential files for preconfigured homes", async () => {
|
|
const f = fixture();
|
|
f.input.persistenceDirectories = [];
|
|
f.input.runtimeContext = null;
|
|
f.input.caBundleMapping = null;
|
|
await stageRemoteLaunchAssets(f.input);
|
|
expect(f.calls).toEqual([]);
|
|
});
|
|
|
|
it("overlaps disjoint launch material and context only with verified sandbox concurrency", async () => {
|
|
const f = fixture({ concurrent: true });
|
|
const started = deferred(), release = deferred();
|
|
f.stageFile.mockImplementationOnce(async () => { started.resolve(); await release.promise; });
|
|
const pending = stageRemoteLaunchAssets(f.input);
|
|
await started.promise;
|
|
try {
|
|
expect(f.stageDirectory).toHaveBeenCalled();
|
|
expect(f.stageFile.mock.calls.some(([input]) => input.targetPath === "/session/bin/ca.pem")).toBe(false);
|
|
} finally { release.resolve(); await pending; }
|
|
expect(f.calls.at(-1)).toBe("file:/session/bin/ca.pem");
|
|
});
|
|
|
|
it.each([
|
|
{ transport: "ssh" as const, concurrent: true },
|
|
{ transport: "sandbox" as const, concurrent: false },
|
|
{ transport: "sandbox" as const },
|
|
])("keeps unsupported transports serial: %j", async (options) => {
|
|
const f = fixture(options);
|
|
const started = deferred(), release = deferred();
|
|
f.stageFile.mockImplementationOnce(async () => { started.resolve(); await release.promise; });
|
|
const pending = stageRemoteLaunchAssets(f.input);
|
|
await started.promise;
|
|
try { expect(f.stageDirectory).not.toHaveBeenCalled(); }
|
|
finally { release.resolve(); await pending; }
|
|
expect(f.stageDirectory).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("settles the context transfer before exposing a launch-material failure", async () => {
|
|
const f = fixture({ concurrent: true });
|
|
const contextStarted = deferred(), releaseContext = deferred();
|
|
const failure = new Error("launch failed first");
|
|
f.stageFile.mockRejectedValueOnce(failure);
|
|
f.stageDirectory.mockImplementationOnce(async () => { contextStarted.resolve(); await releaseContext.promise; });
|
|
let settled = false;
|
|
let observed: unknown;
|
|
const pending = stageRemoteLaunchAssets(f.input).then(() => { settled = true; }, error => { settled = true; observed = error; });
|
|
await contextStarted.promise;
|
|
try {
|
|
await new Promise<void>(resolve => setImmediate(resolve));
|
|
expect(settled).toBe(false);
|
|
} finally { releaseContext.resolve(); await pending; }
|
|
expect(observed).toBe(failure);
|
|
expect(f.stageFile.mock.calls.some(([input]) => input.targetPath === "/session/bin/ca.pem")).toBe(false);
|
|
});
|
|
|
|
it("settles launch material before exposing a context failure", async () => {
|
|
const f = fixture({ concurrent: true });
|
|
const launchStarted = deferred(), releaseLaunch = deferred();
|
|
const failure = new Error("context failed first");
|
|
f.stageFile.mockImplementationOnce(async () => { launchStarted.resolve(); await releaseLaunch.promise; });
|
|
f.stageDirectory.mockRejectedValueOnce(failure);
|
|
let settled = false;
|
|
let observed: unknown;
|
|
const pending = stageRemoteLaunchAssets(f.input).then(() => { settled = true; }, error => { settled = true; observed = error; });
|
|
await launchStarted.promise;
|
|
try {
|
|
await new Promise<void>(resolve => setImmediate(resolve));
|
|
expect(settled).toBe(false);
|
|
} finally { releaseLaunch.resolve(); await pending; }
|
|
expect(observed).toBe(failure);
|
|
expect(f.stageFile.mock.calls.some(([input]) => input.targetPath === "/session/bin/ca.pem")).toBe(false);
|
|
});
|
|
|
|
it("retains launch-material error priority when both parallel transfers fail", async () => {
|
|
const f = fixture({ concurrent: true });
|
|
const failure = new Error("launch priority");
|
|
f.stageFile.mockRejectedValueOnce(failure);
|
|
f.stageDirectory.mockRejectedValueOnce(new Error("context failure"));
|
|
await expect(stageRemoteLaunchAssets(f.input)).rejects.toBe(failure);
|
|
expect(f.stageDirectory).toHaveBeenCalledOnce();
|
|
});
|
|
});
|