Dev #11

Merged
cpfarhood merged 86 commits from dev into local 2026-05-12 00:02:32 +00:00
3 changed files with 186 additions and 26 deletions
Showing only changes of commit d22e790bd4 - Show all commits
@@ -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<string, string>; 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<string, string>; 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<string, string>; 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");
});
@@ -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({
@@ -59,7 +59,7 @@ function firstNonEmptyLine(text: string): string {
);
}
function parseModelsOutput(stdout: string): AdapterModel[] {
export function parseOpenCodeModelsOutput(stdout: string): AdapterModel[] {
const parsed: AdapterModel[] = [];
for (const raw of stdout.split(/\r?\n/)) {
const line = raw.trim();
@@ -153,7 +153,7 @@ export async function discoverOpenCodeModels(input: {
throw new Error(detail ? `\`opencode models\` failed: ${detail}` : "`opencode models` failed.");
}
return sortModels(parseModelsOutput(result.stdout));
return sortModels(parseOpenCodeModelsOutput(result.stdout));
}
export async function discoverOpenCodeModelsCached(input: {