fix(opencode): isolate the native runner probe's OpenCode home (#19469)

## 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 <cell>`).
`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 <noreply@anthropic.com>
This commit is contained in:
DottaandClaude Opus 5.5 authored and GitHub committed 2026-11-06 09:27:13 -06:00
1 parent 18ff71bca3
commit 0252421d5c
2 files changed
+52 -2

No files matched your search

@@ -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<string, string> }])[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<string, string> }];
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 } } });
@@ -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) {