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) {