From 0252421d5cfd31f594468c6756f9b06d63ffb639 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Fri, 6 Nov 2026 09:27:13 -0600 Subject: [PATCH] fix(opencode): isolate the native runner probe's OpenCode home (#19469) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Before a native OpenCode agent can run a task, agent setup runs a harness test. The test sends one "Respond with hello." prompt through `opencode run`. > - The native OpenCode driver gives every OpenCode process its own `HOME` and XDG data, cache, and state directories. The harness test did not. It used the host user's `HOME`, so it shared `~/.local/share/opencode/opencode.db` with every other OpenCode process on the host. > - OpenCode keeps one SQLite database for each data directory. When two OpenCode processes start at the same time on one database, one process can fail in about one second with `Error: Unexpected error / database is locked`. > - The harness test then reports `opencode_hello_probe_failed`. Three failures pause agent verification, so the agent's first task stays queued and never starts. > - This pull request gives each local native harness test its own temporary OpenCode home, the same as the native driver. > - The benefit is that a native OpenCode agent passes setup when other OpenCode processes run on the same host, and its first task starts. ## Linked Issues or Issue Description No public issue exists. This is the bug description. Refs #14393 (open, related): it isolates the OpenCode data directory for the legacy `opencode_local` execution path. This pull request covers a different path: the native runner harness test. **What happened?** Two native OpenCode runner end-to-end cells failed before any provider turn and before their planned control-plane restart: `stock-harness.runner-opencode.local.continuity-restart` ("Timed out waiting for chat turn settles to waiting") and `local-session-integrity.runner-opencode.local.structured-question-restart-resume` ("Timed out waiting for pending user question"). In both cells, the server log shows `Agent lifecycle operation failed ... phase: verifying ... failureCode: harness_test_failed` three times. The run stayed `queued` with `startedAt: null`. The second cell failed two attempts 2 seconds apart, which is shorter than one successful hello turn. **Steps to reproduce** 1. Run several native OpenCode harness tests at the same time with the host `HOME` (or run one while another OpenCode process starts). 2. Some `opencode run` processes exit 1 within about one second with `database is locked`. 3. Agent verification records `harness_test_failed`, and after three failures it pauses. **Expected behavior** The harness test uses an isolated OpenCode home, the same as the native runtime that it qualifies. Other OpenCode processes on the host do not affect it. ## What Changed - `packages/adapters/opencode-local/src/server/test.ts`: for a local `paperclip_runner` probe, create a temporary home and set `HOME`, `XDG_DATA_HOME`, `XDG_CACHE_HOME`, and `XDG_STATE_HOME` inside it. Also set `OPENCODE_DISABLE_MODELS_FETCH` and `OPENCODE_DISABLE_DEFAULT_PLUGINS`, as the native driver does, so a fresh cache does not fetch a catalog or install default plugins. Remove the directory after the probe. - The legacy `opencode_local` adapter keeps the host home, because its `opencode auth login` credentials live there. Remote (sandbox) probes do not change. - `packages/adapters/opencode-local/src/server/test.remote.test.ts`: add a regression for two concurrent native probes (each gets its own data directory, and the directory is removed), and a sibling case that the legacy adapter keeps the host home. ## Verification - Red, unit: the new concurrent-probe test failed on the unfixed code (`expected 'user' to match /^paperclip-opencode-native-probe-home-/`). It passes with the fix. `cd packages/adapters/opencode-local && npx vitest run src/server` gives 89/89 passed. `tsc --noEmit` passes. - Red, real process: the unfixed `testEnvironment` against the real pinned OpenCode 1.18.34 and OpenRouter, 12 concurrent probes, 3 rounds: 2 of 36 probes failed with `opencode_hello_probe_failed` (`database is locked`). A plain shell reproduction with 6 concurrent `opencode run` processes on the shared home: 2 of 6 failed with `database is locked`. - Green, real process: the fixed `testEnvironment`, same 12 x 3 rounds: 36 of 36 passed with `opencode_hello_probe_passed`. - End-to-end reruns of both failing cells: two rounds at this revision with the real pinned OpenCode and OpenRouter (`pnpm test:e2e:runner -- --max-automatic-retries 0 --id `). `stock-harness.runner-opencode.local.continuity-restart`: 2 of 2 passed. `local-session-integrity.runner-opencode.local.structured-question-restart-resume`: round 1 passed. Round 2 passed agent verification, asked the question, and crossed the restart. After the user answered, the continuation run failed with a different error: `runner_local_connect_failed: PRP command run.attach timed out`. That failure is after the harness test and is not changed by this pull request. It is tracked as a separate signature. In all four reruns, agent verification passed on the first attempt. ## Risks - Low risk. The change applies only to local native OpenCode harness tests. The native runtime already uses an isolated home, so the test now matches the runtime that it qualifies. - A native probe no longer reads OpenCode credentials from the host user's home. This is intended: native OpenCode agents receive provider credentials through the agent environment, and the native driver does not read the host home either. - Each probe starts with a cold cache. With the catalog fetch and default plugins disabled, a probe took 4 to 6 seconds in local tests, the same as before. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, used through Claude Code with tool use (shell, file edits, test execution). ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 --- .../src/server/test.remote.test.ts | 32 +++++++++++++++++++ .../opencode-local/src/server/test.ts | 22 +++++++++++-- 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/packages/adapters/opencode-local/src/server/test.remote.test.ts b/packages/adapters/opencode-local/src/server/test.remote.test.ts index b9a63a48b97..e83848e3f15 100644 --- a/packages/adapters/opencode-local/src/server/test.remote.test.ts +++ b/packages/adapters/opencode-local/src/server/test.remote.test.ts @@ -119,6 +119,38 @@ describe("opencode remote environment diagnostics", () => { }, ); + it("gives each concurrent native probe its own OpenCode data directory", async () => { + vi.stubEnv("HOME", "/host/user"); + vi.stubEnv("XDG_DATA_HOME", "/host/user/.local/share"); + const probe = () => testEnvironment({ companyId: "company-1", adapterType: "paperclip_runner", + config: { model: "openrouter/deepseek/deepseek-v4-flash-0731", env: { XDG_CONFIG_HOME: configHome } } }); + const results = await Promise.all([probe(), probe()]); + expect(results.map(result => result.status)).toEqual(["pass", "pass"]); + const envs = runAdapterExecutionTargetProcess.mock.calls.map(call => + (call as unknown as [unknown, unknown, unknown, unknown, { env: Record }])[4].env); + expect(envs).toHaveLength(2); + for (const env of envs) { + expect(path.basename(env.HOME)).toMatch(/^paperclip-opencode-native-probe-home-/); + for (const key of ["XDG_DATA_HOME", "XDG_CACHE_HOME", "XDG_STATE_HOME"]) { + expect(path.dirname(env[key])).toBe(env.HOME); + } + expect(env).toMatchObject({ OPENCODE_DISABLE_MODELS_FETCH: "true", OPENCODE_DISABLE_DEFAULT_PLUGINS: "true" }); + await expect(stat(env.HOME)).rejects.toMatchObject({ code: "ENOENT" }); + } + expect(envs[0].XDG_DATA_HOME).not.toBe(envs[1].XDG_DATA_HOME); + }); + + it("keeps the host OpenCode home for the legacy local adapter, which owns its login", async () => { + vi.stubEnv("HOME", "/host/user"); + vi.stubEnv("XDG_DATA_HOME", "/host/user/.local/share"); + vi.mocked(discoverOpenCodeModels).mockResolvedValueOnce([{ id: "provider/model", label: "Model" }]); + vi.mocked(ensureOpenCodeModelConfiguredAndAvailable).mockResolvedValueOnce([{ id: "provider/model", label: "Model" }]); + await testEnvironment({ companyId: "company-1", adapterType: "opencode_local", + config: { model: "provider/model", env: { XDG_CONFIG_HOME: configHome } } }); + const call = runAdapterExecutionTargetProcess.mock.calls[0] as unknown as [unknown, unknown, unknown, unknown, { env: Record }]; + expect(call[4].env).toMatchObject({ HOME: "/host/user", XDG_DATA_HOME: "/host/user/.local/share" }); + }); + it("rejects a malformed native model before starting the probe", async () => { const result = await testEnvironment({ companyId: "company-1", adapterType: "paperclip_runner", config: { model: "missing-provider", env: { XDG_CONFIG_HOME: configHome } } }); diff --git a/packages/adapters/opencode-local/src/server/test.ts b/packages/adapters/opencode-local/src/server/test.ts index 7b33a957827..aad64db7234 100644 --- a/packages/adapters/opencode-local/src/server/test.ts +++ b/packages/adapters/opencode-local/src/server/test.ts @@ -144,6 +144,7 @@ export async function testEnvironment( // `fs.mkdtemp` directory leaks on the early-throw path. let preparedRuntimeWorkspaceLocalDir: string | null = null; let nativeProbeWorkspaceLocalDir: string | null = null; + let nativeProbeHomeLocalDir: string | null = null; try { let runtimeTarget: AdapterExecutionTarget | null = target ?? null; let runtimeCwd = cwd; @@ -153,6 +154,23 @@ export async function testEnvironment( nativeProbeWorkspaceLocalDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-opencode-native-probe-")); runtimeCwd = nativeProbeWorkspaceLocalDir; } + // The native runner gives every OpenCode process its own home and data + // directory. The probe must too: OpenCode keeps one SQLite database per + // data directory, and concurrent processes sharing the host user's + // database fail with "database is locked". + if (!targetIsRemote && ctx.adapterType === "paperclip_runner") { + nativeProbeHomeLocalDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-opencode-native-probe-home-")); + Object.assign(preparedRuntimeConfig.env, { + HOME: nativeProbeHomeLocalDir, + XDG_DATA_HOME: path.join(nativeProbeHomeLocalDir, "data"), + XDG_CACHE_HOME: path.join(nativeProbeHomeLocalDir, "cache"), + XDG_STATE_HOME: path.join(nativeProbeHomeLocalDir, "state"), + // Match the native driver: a fresh cache must not fetch a catalog or + // install default plugins before the pinned model answers. + OPENCODE_DISABLE_MODELS_FETCH: "true", + OPENCODE_DISABLE_DEFAULT_PLUGINS: "true", + }); + } if (targetIsRemote) { preparedRuntimeWorkspaceLocalDir = await fs.mkdtemp(path.join(os.tmpdir(), `paperclip-opencode-envtest-${runId}-`)); const preparedExecutionTargetRuntime = await prepareAdapterExecutionTargetRuntime({ @@ -460,8 +478,8 @@ export async function testEnvironment( } } } finally { - if (nativeProbeWorkspaceLocalDir) { - await fs.rm(nativeProbeWorkspaceLocalDir, { recursive: true, force: true }).catch(() => {}); + for (const dir of [nativeProbeWorkspaceLocalDir, nativeProbeHomeLocalDir]) { + if (dir) await fs.rm(dir, { recursive: true, force: true }).catch(() => {}); } await restoreWorkspace?.(); if (!restoreWorkspace && preparedRuntimeWorkspaceLocalDir) {