mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-04 11:01:16 +01:00
Default new OpenCode agents to Paperclip Runner (#17120)
Default new OpenCode agents to Paperclip Runner on qualified macOS and Linux x64 targets. Preserve saved runner choices, explicit Legacy selection, and unsupported-target fallback. Pi remains a separate change. Verified the complete browser path with a real OpenRouter connection: create the new agent, assign a task, observe its response and Done status, and reload to confirm persistence. CI passes and Greptile is 5/5 with no unresolved findings. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
0a5f1546ee
commit
39e44a9d9b
30 files changed
+373
-98
No files matched your search
@@ -74,9 +74,55 @@ describe("server-owned Codex runner selection", () => {
|
||||
expect(resolveNewAgentRunner(resolved)).toEqual(resolved);
|
||||
expect(input.adapterConfig).toHaveProperty("search", false);
|
||||
});
|
||||
it.each(["opencode_local", "grok_local", "cursor", "pi_local", "process", "external"])("keeps %s default unchanged", adapterType => {
|
||||
it.each(["grok_local", "cursor", "pi_local", "process", "external"])("keeps %s default unchanged", adapterType => {
|
||||
expect(resolveNewAgentRunner({ adapterType, target, adapterConfig: { custom: "preserved" } })).toEqual({ adapterType, adapterConfig: { custom: "preserved" } });
|
||||
});
|
||||
it.each(qualifiedTargets.filter(target => !(target.platform === "linux" && target.architecture === "arm64")))("defaults OpenCode to its native provider on $platform/$architecture", target => {
|
||||
const adapterConfig = { model: "openrouter/anthropic/claude-sonnet-4.6", dangerouslySkipPermissions: true, command: "opencode", env: { OPENROUTER_API_KEY: { type: "secret_ref", secretId: "connection" } }, cwd: "/workspace", instructionsFilePath: "/workspace/AGENTS.md" };
|
||||
const selected = resolveNewAgentRunner({ adapterType: "opencode_local", adapterConfig, target });
|
||||
expect(agentRunnerAvailability("opencode_local", target).defaultRunner).toBe("paperclip");
|
||||
expect(selected).toEqual({ adapterType: "paperclip_runner", adapterConfig: { provider: "opencode", model: adapterConfig.model, env: adapterConfig.env, cwd: adapterConfig.cwd, instructionsFilePath: adapterConfig.instructionsFilePath, opencodePermissionMode: "allow", lifecycleMode: "per_turn" } });
|
||||
expect(resolveNewAgentRunner(selected)).toEqual(selected);
|
||||
expect(resolveNewAgentRunner({ ...selected, runner: "legacy" })).toMatchObject({ adapterType: "opencode_local", adapterConfig: { model: adapterConfig.model, dangerouslySkipPermissions: true } });
|
||||
});
|
||||
it.each([{ driver: "local", platform: "win32", architecture: "x64" }, { driver: "local", platform: "linux", architecture: "arm64", libc: "glibc" as const, glibcVersion: "2.39" }, { driver: "ssh", platform: "linux", architecture: "arm64", libc: "glibc" as const, glibcVersion: "2.39" }, { driver: "sandbox", platform: "linux", architecture: "arm64", libc: "glibc" as const, glibcVersion: "2.39" }, { driver: "local", platform: "linux", architecture: "arm64", libc: "musl" as const }, { driver: "local", platform: "linux", architecture: "x64", libc: "glibc" as const, glibcVersion: "2.38" }, { driver: "sandbox" }])("keeps OpenCode legacy on unqualified $driver/$platform targets", target => {
|
||||
const input = { adapterType: "opencode_local", adapterConfig: { model: "custom/model", command: "custom-opencode" }, target };
|
||||
expect(agentRunnerAvailability("opencode_local", target)).toEqual({ supportedRunners: ["legacy"], defaultRunner: "legacy" });
|
||||
expect(resolveNewAgentRunner(input)).toEqual({ adapterType: input.adapterType, adapterConfig: input.adapterConfig });
|
||||
expect(() => resolveNewAgentRunner({ ...input, runner: "paperclip" })).toThrow("unavailable");
|
||||
});
|
||||
it.each([{ command: "custom-opencode" }, { variant: "high" }, { extraArgs: ["--agent", "plan"] }, { args: ["--attach", "http://localhost"] }, { dangerouslySkipPermissions: false }])("rejects unsupported OpenCode settings instead of discarding them: %j", adapterConfig => {
|
||||
const input = { adapterType: "opencode_local", adapterConfig: { model: "custom/model", ...adapterConfig }, target };
|
||||
expect(() => resolveNewAgentRunner(input)).toThrow("Legacy runner");
|
||||
expect(resolveNewAgentRunner({ ...input, runner: "legacy" })).toEqual({ adapterType: input.adapterType, adapterConfig: input.adapterConfig });
|
||||
});
|
||||
it.each([undefined, "", "model", "provider/"])("requires an explicit OpenCode provider/model: %s", model => {
|
||||
expect(() => resolveNewAgentRunner({ adapterType: "opencode_local", adapterConfig: { model }, target })).toThrow("provider/model");
|
||||
});
|
||||
it("stores an inactive native OpenCode setup draft before model selection, but cannot hire it", () => {
|
||||
const input = { adapterType: "opencode_local", target };
|
||||
expect(resolveNewAgentRunner({ ...input, setupOnly: true })).toEqual({ adapterType: "paperclip_runner", adapterConfig: { provider: "opencode", model: "", opencodePermissionMode: "allow", lifecycleMode: "per_turn" } });
|
||||
expect(() => resolveNewAgentRunner(input)).toThrow("provider/model");
|
||||
});
|
||||
it("honors native OpenCode permissions and refuses to discard them on return to legacy", () => {
|
||||
const selected = resolveNewAgentRunner({ adapterType: "opencode_local", adapterConfig: { model: "custom/model", opencodePermissionMode: "ask" }, target });
|
||||
expect(selected.adapterConfig.opencodePermissionMode).toBe("ask");
|
||||
expect(() => resolveNewAgentRunner({ ...selected, runner: "legacy" })).toThrow("opencodePermissionMode");
|
||||
});
|
||||
it("resolves OpenCode against the company sandbox qualification and preserves disabled/overridden adapters", async () => {
|
||||
const input = { adapterType: "opencode_local", adapterConfig: { model: "custom/model" } };
|
||||
state.settings = { defaultEnvironmentId: "sandbox" };
|
||||
state.getEnvironment.mockResolvedValue({ id: "sandbox", driver: "sandbox", status: "active", config: { provider: "daytona" } });
|
||||
expect((await resolveNewAgentRunnerForCompany({} as never, "company", input)).adapterType).toBe("opencode_local");
|
||||
state.sandboxQualification.mockResolvedValue({ platform: "linux", architecture: "x64", libc: "glibc", glibcVersion: "2.39" });
|
||||
expect((await resolveNewAgentRunnerForCompany({} as never, "company", input)).adapterType).toBe("paperclip_runner");
|
||||
state.overridden.add("opencode_local");
|
||||
expect(resolveNewAgentRunner({ ...input, target }).adapterType).toBe("opencode_local");
|
||||
state.overridden.clear(); state.disabled = ["paperclip_runner"];
|
||||
expect(resolveNewAgentRunner({ ...input, target }).adapterType).toBe("opencode_local");
|
||||
state.disabled = ["opencode_local"];
|
||||
expect(() => resolveNewAgentRunner({ ...input, target })).toThrow("OpenCode is disabled");
|
||||
});
|
||||
it.each(qualifiedTargets)("allows an explicit legacy runner and native to legacy round trip on $platform/$architecture", target => {
|
||||
expect(resolveNewAgentRunner({ adapterType: "codex_local", target, runner: "legacy", adapterConfig: { extraArgs: ["--search"] } }).adapterType).toBe("codex_local");
|
||||
expect(resolveNewAgentRunner({ adapterType: "paperclip_runner", runner: "legacy", adapterConfig: { provider: "codex", model: "gpt-5.6-sol", lifecycleMode: "per_turn", codexPermissionMode: "never", modelReasoningEffort: "high" } })).toEqual({ adapterType: "codex_local", adapterConfig: { model: "gpt-5.6-sol", modelReasoningEffort: "high", dangerouslyBypassApprovalsAndSandbox: true } });
|
||||
@@ -379,7 +425,8 @@ describe("Codex creation runner contract", () => {
|
||||
it("graduates only Codex and retains saved provider identity", () => {
|
||||
expect(paperclipRunnerProfileForHarness("codex_local")).toEqual({ provider: "codex" });
|
||||
expect(paperclipRunnerProfileForHarness("claude_local")).toEqual({ provider: "acpx", acpxAgent: "claude" });
|
||||
for (const harness of ["opencode_local", "grok_local", "cursor", "pi_local", "process"]) expect(paperclipRunnerProfileForHarness(harness)).toBeUndefined();
|
||||
expect(paperclipRunnerProfileForHarness("opencode_local")).toEqual({ provider: "opencode" });
|
||||
for (const harness of ["grok_local", "cursor", "pi_local", "process"]) expect(paperclipRunnerProfileForHarness(harness)).toBeUndefined();
|
||||
expect(agentHarnessType("paperclip_runner", { provider: "codex" })).toBe("codex_local");
|
||||
expect(agentHarnessType("paperclip_runner", { provider: "acpx", acpxAgent: "claude" })).toBe("claude_local");
|
||||
expect(agentHarnessType("paperclip_runner", { provider: "unknown" })).toBe("unknown");
|
||||
|
||||
@@ -17,11 +17,13 @@ export interface AgentRunnerSelection {
|
||||
adapterConfig?: Record<string, unknown>;
|
||||
runner?: AgentRunnerChoice;
|
||||
target?: RunnerTarget;
|
||||
/** Server-owned inactive setup drafts may precede model selection. Never use for a hire or run. */
|
||||
setupOnly?: boolean;
|
||||
}
|
||||
|
||||
function runnerAdaptersAvailable(harness: string, driver: string): boolean {
|
||||
const disabled = new Set(getDisabledAdapterTypes());
|
||||
return ["codex_local", "claude_local"].includes(harness)
|
||||
return ["codex_local", "claude_local", "opencode_local"].includes(harness)
|
||||
&& !disabled.has(harness) && !disabled.has("paperclip_runner")
|
||||
&& !!findActiveServerAdapter(harness) && !!findActiveServerAdapter("paperclip_runner")
|
||||
&& !hasActiveAdapterOverride(harness) && !hasActiveAdapterOverride("paperclip_runner")
|
||||
@@ -43,6 +45,25 @@ function incompatible(fields: string[]): never {
|
||||
throw unprocessable(`Paperclip Runner cannot honor these settings: ${fields.join(", ")}. Remove them or select Legacy runner in Advanced.`, { code: "agent_runner_config_incompatible", fields });
|
||||
}
|
||||
|
||||
function resolveOpenCodeRunnerConfig(config: Record<string, unknown>): Record<string, unknown> {
|
||||
const unsupported = ["extraArgs", "args", "variant", "modelReasoningEffort", "reasoningEffort", "effort"]
|
||||
.filter(key => config[key] !== undefined && config[key] !== "" && !(Array.isArray(config[key]) && config[key].length === 0));
|
||||
if (config.command && config.command !== "opencode") unsupported.push("command");
|
||||
// Legacy false inherits local OpenCode permission rules, which the isolated Runner cannot retain.
|
||||
if (config.dangerouslySkipPermissions !== undefined && config.dangerouslySkipPermissions !== true) unsupported.push("dangerouslySkipPermissions");
|
||||
if (config.model !== undefined && typeof config.model !== "string") unsupported.push("model");
|
||||
if (unsupported.length) incompatible(unsupported);
|
||||
const translated = { ...config };
|
||||
for (const key of ["command", "extraArgs", "args", "variant", "modelReasoningEffort", "reasoningEffort", "effort", "dangerouslySkipPermissions"]) delete translated[key];
|
||||
return {
|
||||
...translated,
|
||||
provider: "opencode",
|
||||
model: typeof config.model === "string" ? config.model.trim() : "",
|
||||
opencodePermissionMode: config.opencodePermissionMode ?? "allow",
|
||||
lifecycleMode: config.lifecycleMode ?? "per_turn",
|
||||
};
|
||||
}
|
||||
|
||||
function resolveClaudeRunnerConfig(config: Record<string, unknown>): Record<string, unknown> {
|
||||
const translated = { ...config };
|
||||
const legacyPermission = [config.permissionMode, config.acpPermissionMode].find(value => typeof value === "string" && value.trim()) as string | undefined;
|
||||
@@ -82,12 +103,19 @@ export function resolveNewAgentRunner(input: AgentRunnerSelection): { adapterTyp
|
||||
let config = { ...input.adapterConfig };
|
||||
const harness = agentHarnessType(adapterType, config);
|
||||
const native = adapterType === "paperclip_runner";
|
||||
if (["codex_local", "claude_local"].includes(adapterType) && getDisabledAdapterTypes().includes(adapterType)) throw unprocessable(`${adapterType === "claude_local" ? "Claude" : "Codex"} is disabled on this instance. Enable its adapter or choose another harness.`, { code: "agent_runner_unavailable" });
|
||||
if (["codex_local", "claude_local", "opencode_local"].includes(adapterType) && getDisabledAdapterTypes().includes(adapterType)) throw unprocessable(`${adapterType === "opencode_local" ? "OpenCode" : adapterType === "claude_local" ? "Claude" : "Codex"} is disabled on this instance. Enable its adapter or choose another harness.`, { code: "agent_runner_unavailable" });
|
||||
if (native && (input.runner === undefined || input.runner === "paperclip" || (harness !== "codex_local" && input.runner !== "legacy"))) return { adapterType, adapterConfig: normalizeLegacyRunnerProvider(config) };
|
||||
const availability = agentRunnerAvailability(harness, input.target);
|
||||
const choice = input.runner ?? "auto";
|
||||
const useNative = choice === "paperclip" || (choice === "auto" && availability.defaultRunner === "paperclip");
|
||||
if (!useNative) {
|
||||
if (harness === "opencode_local") {
|
||||
if (!native) return { adapterType, adapterConfig: config };
|
||||
if (config.opencodePermissionMode !== undefined && config.opencodePermissionMode !== "allow") incompatible(["opencodePermissionMode"]);
|
||||
if (config.lifecycleMode !== undefined && config.lifecycleMode !== "per_turn") incompatible(["lifecycleMode"]);
|
||||
for (const key of ["provider", "opencodePermissionMode", "lifecycleMode", "idleTimeoutMs"]) delete config[key];
|
||||
return { adapterType: "opencode_local", adapterConfig: { ...config, dangerouslySkipPermissions: true } };
|
||||
}
|
||||
if (harness === "claude_local") {
|
||||
if (!native) return { adapterType, adapterConfig: config };
|
||||
if (config.acpxPermissionMode !== undefined && config.acpxPermissionMode !== "approve-all") incompatible(["acpxPermissionMode"]);
|
||||
@@ -110,6 +138,16 @@ export function resolveNewAgentRunner(input: AgentRunnerSelection): { adapterTyp
|
||||
? "Paperclip Runner is unavailable for this sandbox. Choose an immutable OCI image digest and run its environment connection probe to qualify the OS, architecture and libc, or select Legacy runner in Advanced."
|
||||
: "Paperclip Runner is unavailable for this harness and environment. Select Legacy runner in Advanced.", { code: "agent_runner_unavailable" });
|
||||
if (native) return { adapterType, adapterConfig: normalizeLegacyRunnerProvider(config) };
|
||||
if (harness === "opencode_local") {
|
||||
config = resolveOpenCodeRunnerConfig(config);
|
||||
if (input.setupOnly && !config.model) return { adapterType: "paperclip_runner", adapterConfig: config };
|
||||
try { resolvePaperclipRunnerNativeProviderInput({ backend: "opencode_server", adapterConfig: config }); }
|
||||
catch (error) {
|
||||
if (error instanceof PaperclipRunnerProviderProfileError) throw unprocessable(error.message, { code: error.code });
|
||||
throw error;
|
||||
}
|
||||
return { adapterType: "paperclip_runner", adapterConfig: config };
|
||||
}
|
||||
if (harness === "claude_local") {
|
||||
config = resolveClaudeRunnerConfig(config);
|
||||
try { resolvePaperclipRunnerNativeProviderInput({ backend: "acpx_runtime", adapterConfig: config }); }
|
||||
@@ -174,7 +212,7 @@ export function resolveNewAgentRunner(input: AgentRunnerSelection): { adapterTyp
|
||||
}
|
||||
|
||||
export async function resolveNewAgentRunnerForCompany(db: Db, companyId: string, input: AgentRunnerSelection & { defaultEnvironmentId?: string | null }) {
|
||||
if (!["codex_local", "claude_local"].includes(agentHarnessType(input.adapterType ?? "process", input.adapterConfig))
|
||||
if (!["codex_local", "claude_local", "opencode_local"].includes(agentHarnessType(input.adapterType ?? "process", input.adapterConfig))
|
||||
|| input.runner === "legacy" || (input.adapterType === "paperclip_runner" && (input.runner === undefined || input.runner === "paperclip"))) return resolveNewAgentRunner(input);
|
||||
const target = await resolveAgentRunnerTargetForCompany(db, companyId, input.defaultEnvironmentId);
|
||||
return resolveNewAgentRunner({ ...input, target });
|
||||
@@ -211,7 +249,7 @@ export async function resolveAgentRunnerTargetForCompany(db: Db, companyId: stri
|
||||
const qualified = await readSandboxPlatformQualification(db, environment, companyId);
|
||||
if (qualified) target = { driver, ...qualified };
|
||||
}
|
||||
if (driver === "ssh" && environment && ["codex_local", "claude_local"].some(harness => runnerAdaptersAvailable(harness, driver))) {
|
||||
if (driver === "ssh" && environment && ["codex_local", "claude_local", "opencode_local"].some(harness => runnerAdaptersAvailable(harness, driver))) {
|
||||
const parsed = await resolveEnvironmentDriverConfigForRuntime(db, companyId, environment);
|
||||
if (parsed.driver !== "ssh") throw unprocessable("The selected SSH environment is unavailable.");
|
||||
let output: string;
|
||||
|
||||
@@ -1762,7 +1762,7 @@ export function builtInAgentService(db: Db) {
|
||||
const adapterConfig = resolvedInput.adapterConfig ?? existing.adapterConfig;
|
||||
assertAdapterAllowed(definition, adapterType, adapterConfig);
|
||||
const selectedHarness = agentHarnessType(adapterType, adapterConfig);
|
||||
const sameQualifiedHarness = ["codex_local", "claude_local"].includes(selectedHarness)
|
||||
const sameQualifiedHarness = ["codex_local", "claude_local", "opencode_local"].includes(selectedHarness)
|
||||
&& agentHarnessType(existing.adapterType, existing.adapterConfig) === selectedHarness;
|
||||
const runner = resolvedInput.runner ?? (sameQualifiedHarness && existing.metadata?.paperclipBuiltInSetupRequired !== true
|
||||
? agentRunner(existing.adapterType) : undefined);
|
||||
|
||||
@@ -5307,7 +5307,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
||||
"OpenAI Dot is experimental and disabled on this instance.",
|
||||
{ code: "paperclip_runner_dot_disabled" },
|
||||
);
|
||||
} else if (config.provider !== "codex" && config.provider !== undefined && agentHarnessType("paperclip_runner", config) !== "claude_local" && experimental.enableNativeRunner !== true) {
|
||||
} else if (config.provider !== "codex" && config.provider !== "opencode" && config.provider !== undefined && agentHarnessType("paperclip_runner", config) !== "claude_local" && experimental.enableNativeRunner !== true) {
|
||||
throw unprocessable(
|
||||
"Paperclip Runner is experimental and disabled on this instance.",
|
||||
{ code: "paperclip_runner_rollout_disabled" },
|
||||
@@ -5651,7 +5651,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
||||
runner = agentRunner(existingImportAgent.adapterType);
|
||||
}
|
||||
}
|
||||
const retainedNative = runner === "paperclip" && ["codex_local", "claude_local"].includes(selectedHarness)
|
||||
const retainedNative = runner === "paperclip" && ["codex_local", "claude_local", "opencode_local"].includes(selectedHarness)
|
||||
? [manifestAgent, existingImportAgent].find((agent) => agent?.adapterType === "paperclip_runner"
|
||||
&& agentHarnessType(agent.adapterType, agent.adapterConfig) === selectedHarness)
|
||||
: undefined;
|
||||
|
||||
@@ -90,11 +90,17 @@ describe("resolveNativeRuntimeMode", () => {
|
||||
})).toMatchObject({ kind: "native", profile: { backend: "grok_bot_mcp" } });
|
||||
});
|
||||
|
||||
it("still rejects experimental providers while the rollout flag is disabled", () => {
|
||||
it("admits OpenCode without the experimental flag", () => {
|
||||
expect(resolveNativeRuntimeMode({ ...eligible, enabled: false,
|
||||
adapterConfig: { provider: "opencode", model: "openrouter/anthropic/claude-sonnet-4.6" },
|
||||
})).toMatchObject({ kind: "native", profile: { backend: "opencode_server" } });
|
||||
});
|
||||
|
||||
it("still rejects Pi while the rollout flag is disabled", () => {
|
||||
expect(() => resolveNativeRuntimeMode({
|
||||
...eligible,
|
||||
enabled: false,
|
||||
adapterConfig: { provider: "opencode", model: "openrouter/deepseek/deepseek-v4-flash-0731" },
|
||||
adapterConfig: { provider: "acpx", acpxAgent: "pi", model: "openrouter/deepseek/deepseek-v4-flash-0731" },
|
||||
})).toThrow(expect.objectContaining({
|
||||
code: "paperclip_runner_rollout_disabled",
|
||||
}));
|
||||
@@ -244,7 +250,7 @@ describe("resolveNativeRuntimeMode", () => {
|
||||
|
||||
it("keeps a persisted experimental run native while the flag rejects a fresh start", () => {
|
||||
const disabled = { ...eligible, enabled: false,
|
||||
adapterConfig: { provider: "opencode", model: "openrouter/deepseek/deepseek-v4-flash-0731" },
|
||||
adapterConfig: { provider: "acpx", acpxAgent: "pi", model: "openrouter/deepseek/deepseek-v4-flash-0731" },
|
||||
};
|
||||
expect(resolveHeartbeatNativeRuntimeMode({
|
||||
...disabled,
|
||||
@@ -252,7 +258,7 @@ describe("resolveNativeRuntimeMode", () => {
|
||||
runtimeMode: "native",
|
||||
runtimeModeReason: "eligible_opt_in",
|
||||
runtimeModeResolvedAt: new Date(),
|
||||
driverKind: "opencode_server",
|
||||
driverKind: "acpx_runtime",
|
||||
},
|
||||
})).toEqual(expect.objectContaining({
|
||||
kind: "native",
|
||||
@@ -516,7 +522,7 @@ describe("resolveHeartbeatRuntimeMode compatibility", () => {
|
||||
...compatibilityInput,
|
||||
enabled: false,
|
||||
adapterType: "paperclip_runner",
|
||||
adapterConfig: { provider: "opencode", model: "opencode/gpt-5" },
|
||||
adapterConfig: { provider: "acpx", acpxAgent: "pi", model: "openrouter/anthropic/claude-sonnet-4.6" },
|
||||
})).toThrow(NativeRunnerSelectionError);
|
||||
});
|
||||
it("admits native Claude without enabling other ACPX providers", () => {
|
||||
|
||||
@@ -138,7 +138,7 @@ export function resolveNativeRuntimeMode(input: {
|
||||
if (input.dotEnabled !== true) throw ineligible("paperclip_runner_dot_disabled", "Enable OpenAI Dot and Assistant connections (MCP) in experimental settings before assigning new work.");
|
||||
} else if (runnerProfile.provider === "grok_bot" && (input.grokEnabled !== true || !input.enabled)) {
|
||||
throw ineligible("paperclip_runner_grok_disabled", "Enable Grok Bot, Paperclip Runner, and Assistant connections (MCP) before assigning new work.");
|
||||
} else if (runnerProfile.provider !== "codex"
|
||||
} else if (runnerProfile.provider !== "codex" && runnerProfile.provider !== "opencode"
|
||||
&& !(runnerProfile.provider === "acpx"
|
||||
&& (runnerProfile.acpxAgent === "claude" || runnerProfile.acpxAgent === "copilot"))
|
||||
&& !input.enabled) {
|
||||
|
||||
Reference in new issue
Block a user