forked from farhoodlabs/paperclip
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:
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user