Validate remote model probes on execution target (OpenCode) (#5119)

> **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
This commit is contained in:
Devin Foley
2026-05-03 13:34:09 -07:00
committed by GitHub
parent 856c6cb192
commit d22e790bd4
3 changed files with 186 additions and 26 deletions
@@ -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<string, string>, 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<AdapterExecutionContext["executionTarget"]>;
command: string;
model: string;
cwd: string;
env: Record<string, string>;
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<AdapterExec
includeRuntimeKeys: ["HOME"],
resolvedCommand,
});
const timeoutSec = asNumber(config.timeoutSec, 0);
const graceSec = asNumber(config.graceSec, 20);
if (!executionTargetIsRemote) {
await ensureOpenCodeModelConfiguredAndAvailable({
@@ -257,20 +321,17 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
});
}
const timeoutSec = asNumber(config.timeoutSec, 0);
const graceSec = asNumber(config.graceSec, 20);
const extraArgs = (() => {
const fromExtraArgs = asStringArray(config.extraArgs);
if (fromExtraArgs.length > 0) return fromExtraArgs;
return asStringArray(config.args);
})();
const effectiveExecutionCwd = adapterExecutionTargetRemoteCwd(executionTarget, cwd);
let restoreRemoteWorkspace: (() => Promise<void>) | null = null;
let localSkillsDir: string | null = null;
let remoteRuntimeRootDir: string | null = null;
let paperclipBridge: Awaited<ReturnType<typeof startAdapterExecutionTargetPaperclipBridge>> = 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<AdapterExec
{ cwd, env: preparedRuntimeConfig.env, timeoutSec, graceSec, onLog },
);
}
await ensureRemoteOpenCodeModelConfiguredAndAvailable({
runId,
executionTarget,
command,
model,
cwd,
env: preparedRuntimeConfig.env,
timeoutSec,
graceSec,
});
}
if (executionTargetIsRemote && adapterExecutionTargetUsesPaperclipBridge(executionTarget)) {
paperclipBridge = await startAdapterExecutionTargetPaperclipBridge({