diff --git a/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.test.ts b/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.test.ts index a63abc777f8..5d568eddf86 100644 --- a/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.test.ts +++ b/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.test.ts @@ -1513,7 +1513,7 @@ describe("OpenCodeServerDriver", () => { ); const isolatedHome = runtimeEnvironment.home; expect(startupFailurePending).toBe(false); - expect(dirname(isolatedHome)).toBe(sessionRoot); + expect(dirname(isolatedHome)).toBe(join(sessionRoot, "homes")); expect(basename(isolatedHome)).toMatch(/^home-/); await expect( readFile( @@ -1553,6 +1553,59 @@ describe("OpenCodeServerDriver", () => { readFile(join(instructionRoot, "sibling.md"), "utf8"), ).resolves.toBe("instruction sibling\n"); await session.close({ reason: "test" }); + await expect(readdir(join(sessionRoot, "homes"))).resolves.toEqual([]); + }); + + it("removes isolated homes left by earlier launches when a session reopens", async () => { + await chmod(fixture, 0o755); + const root = await mkdtemp(join(tmpdir(), "paperclip-opencode-legacy-home-")); + const workspace = await mkdtemp( + join(tmpdir(), "paperclip-opencode-legacy-home-workspace-"), + ); + const skillRoot = join(root, "skill-source"); + const instructionRoot = join(root, "instruction-source"); + roots.push(root, workspace); + await Promise.all([ + mkdir(skillRoot, { recursive: true }), + mkdir(instructionRoot, { recursive: true }), + ]); + await writeFile(join(skillRoot, "SKILL.md"), "# Assigned\n"); + await writeFile(join(instructionRoot, "AGENTS.md"), "instructions\n"); + const sessionRoot = join(root, "legacy"); + const legacyHome = join(sessionRoot, "home-AbC123"); + const sealedSkill = join(legacyHome, ".claude", "skills", "assigned"); + await mkdir(sealedSkill, { recursive: true }); + await mkdir(join(legacyHome, ".npm", "_cacache"), { recursive: true }); + await writeFile(join(legacyHome, ".npm", "_cacache", "entry"), "cache"); + await writeFile(join(sealedSkill, "SKILL.md"), "# Assigned\n"); + await chmod(join(sealedSkill, "SKILL.md"), 0o444); + await chmod(sealedSkill, 0o555); + await mkdir(join(sessionRoot, "data", "opencode"), { recursive: true }); + await writeFile(join(sessionRoot, "data", "opencode", "opencode.db"), "history"); + const driver = new OpenCodeServerDriver({ + model: "openrouter/deepseek/deepseek-v4-flash-0731", + runtimeDirectory: root, + command: fixture, + runtimeContext: runtimeContext(skillRoot, instructionRoot), + environment: { + PATH: process.env.PATH, + OPENROUTER_API_KEY: "fixture-key", + }, + }); + const session = await driver.openSession({ + runId: "run-legacy-home", + normalizedSessionId: "legacy", + workingDirectory: workspace, + }); + await expect(lstat(legacyHome)).rejects.toMatchObject({ code: "ENOENT" }); + await session.close({ reason: "test" }); + expect( + (await readdir(sessionRoot)).filter((entry) => entry.startsWith("home-")), + ).toEqual([]); + await expect(readdir(join(sessionRoot, "homes"))).resolves.toEqual([]); + await expect( + readFile(join(sessionRoot, "data", "opencode", "opencode.db"), "utf8"), + ).resolves.toBe("history"); }); it("resends system instructions without repeating the task envelope on recovery", async () => { diff --git a/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.ts b/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.ts index a7f80aac85e..4ab25b38b83 100644 --- a/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.ts +++ b/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.ts @@ -6,6 +6,7 @@ import { accessSync, constants, readFileSync, realpathSync, statSync } from "nod import { createRequire } from "node:module"; import { chmod, + lstat, mkdir, mkdtemp, readFile, @@ -85,7 +86,10 @@ import { type OpenCodeMcpBridge, } from "./mcp-bridge.js"; import { nativeMcpLaunchBinding } from "../native-mcp.js"; -import { materializeNativeRuntimeSkills } from "../runtime-context-materializer.js"; +import { + materializeNativeRuntimeSkills, + releaseMaterializedNativeRuntimeSkills, +} from "../runtime-context-materializer.js"; export const OPENCODE_SERVER_DRIVER_KIND = "opencode_server" as const; export const QUALIFIED_OPENCODE_VERSION = "1.18.34" as const; @@ -369,6 +373,7 @@ export class OpenCodeServerDriver implements HarnessDriver { ); await mkdir(root, { recursive: true, mode: 0o700 }); await writeFile(join(root, "workspace"), `${cwd}\n`, { mode: 0o600 }); + await removeLegacyIsolatedHomes(root); const trace = await createProviderTraceFileSink({ path: this.#options.environment?.PAPERCLIP_PROVIDER_TRACE_PATH, provider: "opencode", @@ -2281,7 +2286,9 @@ async function startRuntime(input: { // HOME contains a read-only skill snapshot, whose destination must be fresh. // Keep OpenCode's XDG data stable for provider-session recovery while giving // every launch (including retries and recovery) a new isolated HOME. - const isolatedHome = await mkdtemp(join(input.root, "home-")); + const homesRoot = join(input.root, OPENCODE_ISOLATED_HOMES_DIRECTORY); + await mkdir(homesRoot, { recursive: true, mode: 0o700 }); + const isolatedHome = await mkdtemp(join(homesRoot, "home-")); await chmod(isolatedHome, 0o700); try { await materializeNativeRuntimeSkills( @@ -2289,18 +2296,14 @@ async function startRuntime(input: { join(isolatedHome, ".claude", "skills"), ); } catch (error) { - await rm(isolatedHome, { recursive: true, force: true }).catch( - () => undefined, - ); + await removeIsolatedHome(isolatedHome).catch(() => undefined); throw error; } const bridge = await startOpenCodeMcpBridge({ tools: input.options.dynamicTools, handler: input.dispatch, }).catch(async (error) => { - await rm(isolatedHome, { recursive: true, force: true }).catch( - () => undefined, - ); + await removeIsolatedHome(isolatedHome).catch(() => undefined); throw error; }); const assignedMcp = nativeMcpLaunchBinding( @@ -2335,7 +2338,7 @@ async function startRuntime(input: { const providerProxy = modelProvider === "paperclip" && input.options.environment?.PAPERCLIP_AI_PROVIDER_URL ? await startOpenCodeProviderProxy(input.options.environment.PAPERCLIP_AI_PROVIDER_URL, input.options.environment.PAPERCLIP_AI_PROVIDER_KEY ?? "", providerModelId, input.options.environment).catch(async error => { await bridge.close().catch(() => {}); - await rm(isolatedHome, { recursive: true, force: true }).catch(() => {}); + await removeIsolatedHome(isolatedHome).catch(() => {}); throw error; }) : null; @@ -2568,9 +2571,7 @@ async function startRuntime(input: { await rm(join(configHome, "opencode", "opencode.json"), { force: true, }).catch(() => undefined); - await rm(isolatedHome, { recursive: true, force: true }).catch( - () => undefined, - ); + await removeIsolatedHome(isolatedHome).catch(() => undefined); if (closeInput.finalizeTrace !== false) { await input.trace?.finish({ reason: closeInput.reason ?? null }); } @@ -2585,9 +2586,7 @@ async function startRuntime(input: { await rm(join(configHome, "opencode", "opencode.json"), { force: true, }).catch(() => undefined); - await rm(isolatedHome, { recursive: true, force: true }).catch( - () => undefined, - ); + await removeIsolatedHome(isolatedHome).catch(() => undefined); throw error; } } @@ -2962,8 +2961,9 @@ function sanitizedEnvironmentKeys(): string[] { ]; } -function sessionRoot( - runtimeDirectory: string, +export const OPENCODE_ISOLATED_HOMES_DIRECTORY = "homes"; + +export function opencodeRuntimeSessionDirectoryName( normalizedSessionId: string, ): string { const safe = normalizedSessionId @@ -2971,7 +2971,35 @@ function sessionRoot( .slice(0, 120); if (!safe || safe === "." || safe === "..") throw new Error("Invalid normalized OpenCode session id"); - return join(resolve(runtimeDirectory), safe); + return safe; +} + +function sessionRoot( + runtimeDirectory: string, + normalizedSessionId: string, +): string { + return join( + resolve(runtimeDirectory), + opencodeRuntimeSessionDirectoryName(normalizedSessionId), + ); +} + +async function removeIsolatedHome(home: string): Promise { + const skillsParent = await lstat(join(home, ".claude")).catch(() => null); + if (skillsParent?.isDirectory()) { + await releaseMaterializedNativeRuntimeSkills( + join(home, ".claude", "skills"), + ).catch(() => undefined); + } + await rm(home, { recursive: true, force: true }); +} + +async function removeLegacyIsolatedHomes(root: string): Promise { + const entries = await readdir(root, { withFileTypes: true }).catch(() => []); + for (const entry of entries) { + if (!entry.isDirectory() || !entry.name.startsWith("home-")) continue; + await removeIsolatedHome(join(root, entry.name)).catch(() => undefined); + } } function validateWorkspace(value: string): string { diff --git a/server/src/services/native-runtime/native-session-executor.test.ts b/server/src/services/native-runtime/native-session-executor.test.ts index bfb1f4f309f..5c8c3e211f7 100644 --- a/server/src/services/native-runtime/native-session-executor.test.ts +++ b/server/src/services/native-runtime/native-session-executor.test.ts @@ -1880,13 +1880,35 @@ describe("native harness persistence profiles", () => { }); }); - it("excludes only nested Codex launch state from ACPX recovery", () => { + it("excludes per-launch OpenCode homes and re-installable plugins from OpenCode recovery", () => { + const opencode = resolveNativeHarnessPersistenceProfile({ + provider: { kind: "opencode" }, + session: { + driverKind: "opencode_server", + normalizedSessionId: "company:agent/session 1", + protocolVersion: 1, + lifecyclePolicy: { mode: "per_turn", idleTimeoutMs: null }, + }, + } as unknown as NativeExecutionInputV1); + expect( + opencode.directories.find((directory) => directory.name === "opencode"), + ).toMatchObject({ + excludeEntries: [ + "company_agent_session_1/homes", + "company_agent_session_1/config/opencode/node_modules", + ], + }); + }); + + it("excludes only nested Codex launch state and package caches from ACPX recovery", () => { const acpx = profile({ kind: "acpx", agent: "codex" }, "acpx_runtime"); const sessionDirectory = acpxRuntimeSessionDirectoryName("session"); expect( acpx.directories.find((directory) => directory.name === "acpx"), ).toMatchObject({ excludeEntries: [ + `acpx/${sessionDirectory}/home/.npm`, + `acpx/${sessionDirectory}/data/pnpm/store`, `acpx/${sessionDirectory}/codex-home/tmp`, `acpx/${sessionDirectory}/codex-home/.tmp`, `acpx/${sessionDirectory}/codex-home/auth.json`, @@ -1907,7 +1929,12 @@ describe("native harness persistence profiles", () => { { kind: "acpx", agent: "claude" }, "acpx_runtime", ).directories.find((directory) => directory.name === "acpx"), - ).toMatchObject({ excludeEntries: [] }); + ).toMatchObject({ + excludeEntries: [ + `acpx/${sessionDirectory}/home/.npm`, + `acpx/${sessionDirectory}/data/pnpm/store`, + ], + }); }); }); @@ -2623,6 +2650,100 @@ describe("remote provider checkpoint snapshots", () => { expect(await readFile(join(home, "logs", "unified.jsonl"), "utf8")).toBe("AUTH_SENTINEL"); } finally { await rm(root, { recursive: true, force: true }); } }); + it.each<{ + name: string; + provider: Record; + driverKind: string; + directory: string; + sessionPath: string; + kept: Record; + omitted: Record; + }>([ + { + name: "OpenCode", + provider: { kind: "opencode" }, + driverKind: "opencode_server", + directory: "opencode", + sessionPath: "company_agent_session_1", + kept: { + "workspace": "/workspace\n", + "data/opencode/opencode.db": "history", + "config/opencode/opencode.json": "{}", + "config/opencode/package.json": "{}", + "home-legacy/.local/state/opencode/locks/lock": "legacy", + }, + omitted: { + "homes/home-AbC123/.npm/_cacache/index": "npm-cache", + "homes/home-AbC123/.claude/skills/assigned/SKILL.md": "skill", + "config/opencode/node_modules/@opencode-ai/plugin/package.json": "plugin", + }, + }, + { + name: "ACPX", + provider: { kind: "acpx", agent: "pi" }, + driverKind: "acpx_runtime", + directory: "acpx", + sessionPath: `acpx/${acpxRuntimeSessionDirectoryName("company:agent/session 1")}`, + kept: { + "workspace": "/workspace\n", + "pi-home/sessions/session.jsonl": "history", + "data/pnpm/config.json": "{}", + "home/.profile": "profile", + }, + omitted: { + "home/.npm/_cacache/index": "npm-cache", + "data/pnpm/store/v10/files/00/blob": "pnpm-store", + }, + }, + ])("omits disposable $name package caches from the checkpoint and restores the session without them", async ({ + provider, driverKind, directory, sessionPath, kept, omitted, + }) => { + const root = await mkdtemp(join(tmpdir(), "provider-cache-checkpoint-")); + try { + const live = join(root, "live", directory); + const backup = join(root, "backup", directory); + const replacement = join(root, "replacement", directory); + const files: Record = { ...kept, ...omitted }; + for (const [path, contents] of Object.entries(files)) { + await mkdir(join(live, sessionPath, path, ".."), { recursive: true }); + await writeFile(join(live, sessionPath, path), contents); + } + const persistence = resolveNativeHarnessPersistenceProfile({ + provider, + session: { normalizedSessionId: "company:agent/session 1", driverKind }, + } as unknown as NativeExecutionInputV1); + const excludeEntries = persistence.directories.find(entry => entry.name === directory)!.excludeEntries; + const execute = async ({ command, args }: { command: string; args: string[] }) => ({ + exitCode: 0, timedOut: false, stdout: execFileSync(command, args, { encoding: "utf8" }), stderr: "", + }); + const copy = async (operations: Array<{ files: Array<{ sourcePath: string; targetPath: string }> }>) => { + for (const operation of operations) for (const file of operation.files) + await cp(file.sourcePath, file.targetPath, { recursive: true }); + }; + await syncRemoteRunnerDirectoryOut({ runner: { execute, syncOut: copy } as never, + sourcePath: live, targetPath: backup, mode: 0o700, excludeEntries }); + for (const [path, contents] of Object.entries(kept)) + expect(await readFile(join(backup, sessionPath, path), "utf8")).toBe(contents); + for (const path of Object.keys(omitted)) + await expect(access(join(backup, sessionPath, path))).rejects.toMatchObject({ code: "ENOENT" }); + for (const [path, contents] of Object.entries(omitted)) + expect(await readFile(join(live, sessionPath, path), "utf8")).toBe(contents); + + await stageRemoteRunnerDirectory({ + target: { kind: "remote", transport: "provider", remoteCwd: root, runner: { syncIn: copy } } as never, + runner: { syncIn: copy } as never, + sourcePath: backup, + targetPath: replacement, + mode: 0o700, + excludeEntries, + }); + for (const [path, contents] of Object.entries(kept)) + expect(await readFile(join(replacement, sessionPath, path), "utf8")).toBe(contents); + for (const path of Object.keys(omitted)) + await expect(access(join(replacement, sessionPath, path))).rejects.toMatchObject({ code: "ENOENT" }); + } finally { await rm(root, { recursive: true, force: true }); } + }); + it("keeps Codex session state while omitting re-downloadable remote plugins from the checkpoint", async () => { const root = await mkdtemp(join(tmpdir(), "codex-plugin-checkpoint-")); try { diff --git a/server/src/services/native-runtime/native-session-executor.ts b/server/src/services/native-runtime/native-session-executor.ts index 62118309e9d..7f67a8aa2fc 100644 --- a/server/src/services/native-runtime/native-session-executor.ts +++ b/server/src/services/native-runtime/native-session-executor.ts @@ -109,6 +109,8 @@ import { completeRetainedNativeSessionCleanup, completeTerminatedLocalNativeSessionCleanup, acpxRuntimeSessionDirectoryName, + OPENCODE_ISOLATED_HOMES_DIRECTORY, + opencodeRuntimeSessionDirectoryName, createNativeSessionBackend, createRunnerdCodexTransport, executeNativeSession, @@ -410,6 +412,14 @@ const CODEX_HOME_CHECKPOINT_EXCLUDED_ENTRIES = [ ...CODEX_HOME_NON_PERSISTENT_ENTRIES, ...CODEX_HOME_REDOWNLOADABLE_ENTRIES, ] as const; +const OPENCODE_RUNTIME_CHECKPOINT_EXCLUDED_ENTRIES = [ + OPENCODE_ISOLATED_HOMES_DIRECTORY, + "config/opencode/node_modules", +] as const; +const ACPX_RUNTIME_CHECKPOINT_EXCLUDED_ENTRIES = [ + "home/.npm", + "data/pnpm/store", +] as const; const RUNNERD_CONTROL_PLANE_STATE_SCHEMA = "paperclip.runner.durable.control-plane-state.v1"; const RUNNERD_STATE_SCHEMA = "paperclip.runner.durable.state.v1"; @@ -5646,7 +5656,10 @@ export function resolveNativeHarnessPersistenceProfile( ? { name: "opencode", location: "filesystem", - excludeEntries: [], + excludeEntries: OPENCODE_RUNTIME_CHECKPOINT_EXCLUDED_ENTRIES.map( + (entry) => + `${opencodeRuntimeSessionDirectoryName(nativeSessionKey(execution))}/${entry}`, + ), } : execution.provider.kind === "acpx" ? { @@ -5658,8 +5671,10 @@ export function resolveNativeHarnessPersistenceProfile( // nor necessary to restore. Credentials and launch-time config // are also re-materialized in the replacement sandbox. Grok // diagnostics can contain auth fields and are not session state. - excludeEntries: - execution.provider.agent === "grok" + excludeEntries: [ + ...ACPX_RUNTIME_CHECKPOINT_EXCLUDED_ENTRIES.map((entry) => + `acpx/${acpxRuntimeSessionDirectoryName(nativeSessionKey(execution))}/${entry}`), + ...(execution.provider.agent === "grok" ? ["auth.json", "auth-refresh.json", "auth-refresh.json.tmp", "config.toml", "logs"].map((entry) => `acpx/${acpxRuntimeSessionDirectoryName(nativeSessionKey(execution))}/grok-home/${entry}`) : execution.provider.agent === "codex" @@ -5667,7 +5682,8 @@ export function resolveNativeHarnessPersistenceProfile( (entry) => `acpx/${acpxRuntimeSessionDirectoryName(nativeSessionKey(execution))}/codex-home/${entry}`, ) - : [], + : []), + ], } : null; return { diff --git a/server/src/vendor/paperclip-runner/index.ts b/server/src/vendor/paperclip-runner/index.ts index 2252b5daf6b..571daf6a929 100644 --- a/server/src/vendor/paperclip-runner/index.ts +++ b/server/src/vendor/paperclip-runner/index.ts @@ -107,6 +107,10 @@ export const PAPERCLIP_EXECUTION_PROMPT_REVISION = runner.PAPERCLIP_EXECUTION_PROMPT_REVISION; export const acpxRuntimeSessionDirectoryName = runner.acpxRuntimeSessionDirectoryName; +export const OPENCODE_ISOLATED_HOMES_DIRECTORY = + runner.OPENCODE_ISOLATED_HOMES_DIRECTORY; +export const opencodeRuntimeSessionDirectoryName = + runner.opencodeRuntimeSessionDirectoryName; export const canonicalNativeRuntimeContextDigest = runner.canonicalNativeRuntimeContextDigest; export const createNativeSessionBackend = runner.createNativeSessionBackend;