diff --git a/packages/paperclip-runner/src/drivers/acpx/runtime-host.test.ts b/packages/paperclip-runner/src/drivers/acpx/runtime-host.test.ts index d7aeda29e7f..bfc6e710dbc 100644 --- a/packages/paperclip-runner/src/drivers/acpx/runtime-host.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/runtime-host.test.ts @@ -20,7 +20,10 @@ import type { VerifiedAcpxInstallation, } from "./installation-integrity.js"; import { resolveQualifiedAcpxProfile } from "./qualified-profiles.js"; -import { prepareAcpxRuntimeSandbox } from "./runtime-sandbox.js"; +import { + prepareAcpxRuntimeSandbox, + type AcpxRuntimeSandbox, +} from "./runtime-sandbox.js"; import { AcpxRuntimeHost, type AcpxRuntimeHostDependencies, @@ -30,6 +33,15 @@ import { } from "./runtime-host.js"; const temporaryDirectories: string[] = []; +// A published skill snapshot is sealed read-only by `protectStagedTree` +// (runtime-context-materializer.ts:125, :133). Only +// `releaseMaterializedNativeRuntimeSkills` restores write permission before +// removal. `hostFixture` records every sandbox's skills home here as soon as +// the sandbox exists, before the materialize step that seals it and before +// any later step in the same open() call can fail or stall past this file's +// per-test timeout. `afterEach` releases every recorded home first, so a +// forced-open directory removal never has to unlink inside a sealed tree. +const materializedSkillsHomes: string[] = []; const admissionControllers: AbortController[] = []; const pendingAdmissionOpenings = new Set>(); const pendingAdmissionCleanups = new Set>(); @@ -167,6 +179,14 @@ afterEach(async () => { } await Promise.all([...pendingAdmissionOpenings]); await Promise.all([...pendingAdmissionCleanups]); + // Release every sealed skills tree before the plain `rm` below. `rm` does + // not restore write permission, so a tree still sealed at this point would + // otherwise fail with EACCES and hide the real test failure. + await Promise.all( + materializedSkillsHomes + .splice(0) + .map((skillsHome) => releaseMaterializedNativeRuntimeSkills(skillsHome)), + ); await Promise.all( temporaryDirectories .splice(0) @@ -281,7 +301,12 @@ describe("ACPX runtime host", () => { const providerStartTurn = vi.fn(() => runtimeTurn()); let assigned = true; let expectedReference = "ASSIGNED_SKILL_MARKER"; + // Prepare the real sandbox once. Each reopen still exercises the host's + // real skill refresh, including changed references and removed assignments, + // without repeating unrelated durable directory and file writes. Sandbox + // preparation itself also has dedicated runtime-sandbox coverage. const dependencies = fixture.dependencies({ + reuseSandbox: true, openRuntime: async (options) => { skillsHome = join(options.launchEnvironment.CLAUDE_CONFIG_DIR!, "skills"); expect(await readdir(skillsHome)).toEqual(assigned ? ["assigned"] : []); @@ -1392,6 +1417,16 @@ describe("ACPX runtime host", () => { agentRuntimePackageJsonPath: null, openCommand, }), + // This test builds its own dependency object instead of + // `fixture.dependencies()`, so it must record the skills home + // itself. Command admission starts after the sandbox exists and + // Claude's skills are already materialized and sealed, and abort + // can land right there — before this test's own cleanup runs. + prepareSandbox: async (sandboxInput) => { + const sandbox = await prepareAcpxRuntimeSandbox(sandboxInput); + materializedSkillsHomes.push(join(sandbox.agentHomeDirectory, "skills")); + return sandbox; + }, openRuntime, retainAdmissionCleanup: trackAdmissionCleanup, reportRetainedCleanupFailure: vi.fn(), @@ -1705,8 +1740,15 @@ async function hostFixture() { input: Pick & Partial< Pick - >, + > & { + // Opt-in only. When true, `prepareSandbox` runs the real + // preparation once, then returns that same sandbox for every + // later open in the test. Every other test omits this flag, so + // the file still proves that a reopen re-prepares the sandbox. + reuseSandbox?: boolean; + }, ): AcpxRuntimeHostDependencies { + let reusedSandbox: AcpxRuntimeSandbox | null = null; return { verifyInstallation: async (profile) => ({ @@ -1716,6 +1758,19 @@ async function hostFixture() { openCommand: async () => command, }) satisfies VerifiedAcpxInstallation, openRuntime: input.openRuntime, + // Record the skills home the instant the sandbox exists, ahead of + // the materialize call that seals it. A test that overrides + // `prepareSandbox` for a non-Claude agent replaces this wrapper, but + // those agents never materialize skills, so nothing is lost. + prepareSandbox: async (sandboxInput) => { + if (input.reuseSandbox && reusedSandbox) return reusedSandbox; + const sandbox = await prepareAcpxRuntimeSandbox(sandboxInput); + if (sandboxInput.agent === "claude") { + materializedSkillsHomes.push(join(sandbox.agentHomeDirectory, "skills")); + } + if (input.reuseSandbox) reusedSandbox = sandbox; + return sandbox; + }, retainAdmissionCleanup: trackAdmissionCleanup, reportRetainedCleanupFailure: input.reportRetainedCleanupFailure ?? vi.fn(),