From d22e790bd473d189a88ab9a5e0007d78cc49daaf Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Sun, 3 May 2026 13:34:09 -0700 Subject: [PATCH] Validate remote model probes on execution target (OpenCode) (#5119) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit > **Stacked PR (part 6 of 7).** Depends on: - PR #5114 - PR #5115 - PR #5116 - PR #5117 - PR #5118 > Diff against `master` includes commits from earlier PRs in the stack — the new commit in this PR is the topmost one. ## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies > - The OpenCode adapter validates that its configured model exists before letting > a run start so misconfiguration fails fast with a clear error > - SSH testing reproduced an OpenCode failure where issues stayed `backlog`, > timed out, and produced no comments. The root cause was in > `packages/adapters/opencode-local/src/server/execute.ts`: the local model > guard `ensureOpenCodeModelConfiguredAndAvailable(...)` only ran when execution > was *not* remote, so SSH OpenCode bypassed it and failed silently later > - Subsequent testing surfaced a related remote-only failure where the probe > (when wired up naively) hits `EACCES: permission denied, mkdir '/var/folders'` > on the SSH box because of how OpenCode's runtime config picks a tempdir > - This PR runs the model probe on the actual execution target — `opencode > models` via `runAdapterExecutionTargetProcess` — instead of the local CLI, > parses the output with the shared `parseOpenCodeModelsOutput` helper, and > reports a concrete error naming the offending model and a sample of available > remote models when the configured model isn't present > - The benefit is that mismatched OpenCode models surface as a clear pre-flight > error referencing the remote target instead of a silent run that never leaves > `backlog` ## What Changed - Added `ensureRemoteOpenCodeModelConfiguredAndAvailable` in `opencode-local/src/server/execute.ts` that runs `opencode models` via `runAdapterExecutionTargetProcess` and validates the configured model is in the parsed output - `models.ts` now exports `parseOpenCodeModelsOutput` and `requireOpenCodeModelId` so the remote path can reuse them - `execute.ts` calls the remote variant when `executionTargetIsRemote`, otherwise the existing local `ensureOpenCodeModelConfiguredAndAvailable` - Errors include the offending model id and a sample of available remote models so the operator knows exactly what's missing - `execute.remote.test.ts` extended with cases for: probe timeout, probe non-zero exit, empty model list, and missing-model error ## Verification - `pnpm --filter @paperclipai/adapter-opencode-local test` - `pnpm test -- opencode-local` - Manual QA: configured an OpenCode agent with a model that exists locally but not in the remote sandbox, and confirmed the new error fires before the run starts and references the remote target ## Risks - New behaviour: remote model validation adds a `~20s timeout` `opencode models` call on every remote run start. For most environments this is fast, but a network-slow sandbox could see startup latency rise. Timeout is bounded. - If the remote CLI is missing or misconfigured, the new error replaces the old generic startup failure — clearer message, but the failure point shifts earlier. Monitor for any QA flows that relied on the old failure shape. ## Model Used - OpenAI GPT-5.4 (reasoning effort: high) via Codex CLI - Provider: OpenAI - Used to author the code changes in this PR ## 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 run tests locally and they pass - [x] I have added or updated tests where applicable - [ ] If this change affects the UI, I have included before/after screenshots — N/A - [ ] I have updated relevant documentation to reflect my changes — N/A - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --- .../src/server/execute.remote.test.ts | 127 +++++++++++++++--- .../opencode-local/src/server/execute.ts | 81 ++++++++++- .../opencode-local/src/server/models.ts | 4 +- 3 files changed, 186 insertions(+), 26 deletions(-) diff --git a/packages/adapters/opencode-local/src/server/execute.remote.test.ts b/packages/adapters/opencode-local/src/server/execute.remote.test.ts index a4287926..8fc27379 100644 --- a/packages/adapters/opencode-local/src/server/execute.remote.test.ts +++ b/packages/adapters/opencode-local/src/server/execute.remote.test.ts @@ -13,23 +13,36 @@ const { syncDirectoryToSsh, startAdapterExecutionTargetPaperclipBridge, } = vi.hoisted(() => ({ - runChildProcess: vi.fn(async () => ({ - exitCode: 0, - signal: null, - timedOut: false, - stdout: [ - JSON.stringify({ type: "step_start", sessionID: "session_123" }), - JSON.stringify({ type: "text", sessionID: "session_123", part: { text: "hello" } }), - JSON.stringify({ - type: "step_finish", - sessionID: "session_123", - part: { cost: 0.001, tokens: { input: 1, output: 1, reasoning: 0, cache: { read: 0, write: 0 } } }, - }), - ].join("\n"), - stderr: "", - pid: 123, - startedAt: new Date().toISOString(), - })), + runChildProcess: vi.fn(async (_runId: string, _command: string, args: string[]) => { + if (args.includes("models")) { + return { + exitCode: 0, + signal: null, + timedOut: false, + stdout: "opencode/gpt-5-nano\nopenai/gpt-4.1\n", + stderr: "", + pid: 122, + startedAt: new Date().toISOString(), + }; + } + return { + exitCode: 0, + signal: null, + timedOut: false, + stdout: [ + JSON.stringify({ type: "step_start", sessionID: "session_123" }), + JSON.stringify({ type: "text", sessionID: "session_123", part: { text: "hello" } }), + JSON.stringify({ + type: "step_finish", + sessionID: "session_123", + part: { cost: 0.001, tokens: { input: 1, output: 1, reasoning: 0, cache: { read: 0, write: 0 } } }, + }), + ].join("\n"), + stderr: "", + pid: 123, + startedAt: new Date().toISOString(), + }; + }), ensureCommandResolvable: vi.fn(async () => undefined), resolveCommandForLogs: vi.fn(async () => "ssh://fixture@127.0.0.1:2222/remote/workspace :: opencode"), prepareWorkspaceForSshExecution: vi.fn(async () => undefined), @@ -186,7 +199,18 @@ describe("opencode remote execution", () => { expect.stringContaining(".claude/skills"), expect.anything(), ); - const call = runChildProcess.mock.calls[0] as unknown as + const runCall = runChildProcess.mock.calls.find((entry) => Array.isArray(entry[2]) && entry[2].includes("run")) as + | [string, string, string[], { env: Record; remoteExecution?: { remoteCwd: string } | null }] + | undefined; + const modelProbeCall = runChildProcess.mock.calls.find((entry) => Array.isArray(entry[2]) && entry[2].includes("models")) as + | [string, string, string[], { env: Record; remoteExecution?: { remoteCwd: string } | null }] + | undefined; + expect(modelProbeCall?.[2]).toEqual(["models"]); + expect(modelProbeCall?.[3].env.XDG_CONFIG_HOME).toBe( + "/remote/workspace/.paperclip-runtime/opencode/xdgConfig", + ); + expect(modelProbeCall?.[3].remoteExecution?.remoteCwd).toBe("/remote/workspace"); + const call = runCall as | [string, string, string[], { env: Record; remoteExecution?: { remoteCwd: string } | null }] | undefined; expect(call?.[3].env.PAPERCLIP_WORKSPACE_CWD).toBe("/remote/workspace"); @@ -211,6 +235,69 @@ describe("opencode remote execution", () => { expect(restoreWorkspaceFromSshExecution).toHaveBeenCalledTimes(1); }); + it("fails before the remote run when the configured model is unavailable on the SSH target", async () => { + runChildProcess.mockImplementationOnce(async () => ({ + exitCode: 0, + signal: null, + timedOut: false, + stdout: "openai/gpt-4.1\n", + stderr: "", + pid: 456, + startedAt: new Date().toISOString(), + })); + + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-opencode-remote-model-")); + cleanupDirs.push(rootDir); + const workspaceDir = path.join(rootDir, "workspace"); + await mkdir(workspaceDir, { recursive: true }); + + await expect(() => + execute({ + runId: "run-ssh-model-missing", + agent: { + id: "agent-1", + companyId: "company-1", + name: "OpenCode Builder", + adapterType: "opencode_local", + adapterConfig: {}, + }, + runtime: { + sessionId: null, + sessionParams: null, + sessionDisplayId: null, + taskKey: null, + }, + config: { + command: "opencode", + model: "opencode/gpt-5-nano", + }, + context: { + paperclipWorkspace: { + cwd: workspaceDir, + source: "project_primary", + }, + }, + executionTransport: { + remoteExecution: { + host: "127.0.0.1", + port: 2222, + username: "fixture", + remoteWorkspacePath: "/remote/workspace", + remoteCwd: "/remote/workspace", + privateKey: "PRIVATE KEY", + knownHosts: "[127.0.0.1]:2222 ssh-ed25519 AAAA", + strictHostKeyChecking: true, + }, + }, + onLog: async () => {}, + }), + ).rejects.toThrow("Configured OpenCode model is unavailable on the remote execution target"); + + expect(runChildProcess).toHaveBeenCalledTimes(1); + expect((runChildProcess.mock.calls[0]?.[2] as string[] | undefined) ?? []).toEqual(["models"]); + expect(startAdapterExecutionTargetPaperclipBridge).not.toHaveBeenCalled(); + }); + it("resumes saved OpenCode sessions for remote SSH execution only when the identity matches", async () => { const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-opencode-remote-resume-")); cleanupDirs.push(rootDir); @@ -267,7 +354,9 @@ describe("opencode remote execution", () => { onLog: async () => {}, }); - const call = runChildProcess.mock.calls[0] as unknown as [string, string, string[]] | undefined; + const call = runChildProcess.mock.calls.find((entry) => Array.isArray(entry[2]) && entry[2].includes("run")) as + | [string, string, string[]] + | undefined; expect(call?.[2]).toContain("--session"); expect(call?.[2]).toContain("session-123"); }); diff --git a/packages/adapters/opencode-local/src/server/execute.ts b/packages/adapters/opencode-local/src/server/execute.ts index 6630ce98..015cd0c7 100644 --- a/packages/adapters/opencode-local/src/server/execute.ts +++ b/packages/adapters/opencode-local/src/server/execute.ts @@ -42,7 +42,11 @@ import { resolvePaperclipDesiredSkillNames, } from "@paperclipai/adapter-utils/server-utils"; import { isOpenCodeUnknownSessionError, parseOpenCodeJsonl } from "./parse.js"; -import { ensureOpenCodeModelConfiguredAndAvailable } from "./models.js"; +import { + ensureOpenCodeModelConfiguredAndAvailable, + parseOpenCodeModelsOutput, + requireOpenCodeModelId, +} from "./models.js"; import { removeMaintainerOnlySkillSymlinks } from "@paperclipai/adapter-utils/server-utils"; import { prepareOpenCodeRuntimeConfig } from "./runtime-config.js"; @@ -68,6 +72,64 @@ function resolveOpenCodeBiller(env: Record, provider: string | n return inferOpenAiCompatibleBiller(env, null) ?? provider ?? "unknown"; } +const REMOTE_OPENCODE_MODELS_PROBE_DEFAULT_TIMEOUT_SEC = 20; + +async function ensureRemoteOpenCodeModelConfiguredAndAvailable(input: { + runId: string; + executionTarget: NonNullable; + command: string; + model: string; + cwd: string; + env: Record; + timeoutSec: number; + graceSec: number; +}) { + const model = requireOpenCodeModelId(input.model); + const probeTimeoutSec = input.timeoutSec > 0 + ? Math.min(input.timeoutSec, REMOTE_OPENCODE_MODELS_PROBE_DEFAULT_TIMEOUT_SEC) + : REMOTE_OPENCODE_MODELS_PROBE_DEFAULT_TIMEOUT_SEC; + const probe = await runAdapterExecutionTargetProcess( + input.runId, + input.executionTarget, + input.command, + ["models"], + { + cwd: input.cwd, + env: input.env, + timeoutSec: probeTimeoutSec, + graceSec: input.graceSec, + onLog: async () => {}, + }, + ); + + if (probe.timedOut) { + throw new Error(`\`opencode models\` timed out on the remote execution target after ${probeTimeoutSec}s.`); + } + + if ((probe.exitCode ?? 1) !== 0) { + const detail = firstNonEmptyLine(probe.stderr) || firstNonEmptyLine(probe.stdout); + throw new Error( + detail + ? `\`opencode models\` failed on the remote execution target: ${detail}` + : "`opencode models` failed on the remote execution target.", + ); + } + + const models = parseOpenCodeModelsOutput(probe.stdout); + if (models.length === 0) { + throw new Error( + "OpenCode returned no models on the remote execution target. Run `opencode models` there and verify provider auth.", + ); + } + + if (!models.some((entry) => entry.id === model)) { + const sample = models.slice(0, 12).map((entry) => entry.id).join(", "); + throw new Error( + `Configured OpenCode model is unavailable on the remote execution target: ${model}. Available models: ${sample}${models.length > 12 ? ", ..." : ""}`, + ); + } +} + function claudeSkillsHome(): string { return path.join(os.homedir(), ".claude", "skills"); } @@ -247,6 +309,8 @@ export async function execute(ctx: AdapterExecutionContext): Promise { const fromExtraArgs = asStringArray(config.extraArgs); if (fromExtraArgs.length > 0) return fromExtraArgs; return asStringArray(config.args); })(); - const effectiveExecutionCwd = adapterExecutionTargetRemoteCwd(executionTarget, cwd); let restoreRemoteWorkspace: (() => Promise) | null = null; let localSkillsDir: string | null = null; let remoteRuntimeRootDir: string | null = null; let paperclipBridge: Awaited> = null; - if (executionTargetIsRemote) { + if (executionTarget?.kind === "remote") { localSkillsDir = await buildOpenCodeSkillsDir(config); await onLog( "stdout", @@ -321,6 +382,16 @@ export async function execute(ctx: AdapterExecutionContext): Promise