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