mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 17:33:51 +02: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
@@ -762,9 +762,40 @@ describe("agent routes adapter validation", () => {
|
||||
expect(mockAgentService.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(["auto", "legacy"] as const)("creates OpenCode through the public route with %s selection and the experimental flag off", async runner => {
|
||||
const selection = await import("../services/agent-runner-selection.js");
|
||||
vi.spyOn(selection, "resolveNewAgentRunnerForCompany").mockImplementation(async (_db, _companyId, input) =>
|
||||
selection.resolveNewAgentRunner({ ...input, target: { driver: "local", platform: "darwin", architecture: "arm64" } }));
|
||||
const app = await createApp();
|
||||
const model = "openrouter/anthropic/claude-sonnet-4.6";
|
||||
const response = await requestApp(app, baseUrl => request(baseUrl).post("/api/companies/company-1/agents").send({ name: "OpenCode", adapterType: "opencode_local", runner, adapterConfig: { model } }));
|
||||
expect(response.status, JSON.stringify(response.body)).toBe(201);
|
||||
expect(mockAgentService.create.mock.calls.at(-1)?.[1]).toMatchObject({ adapterType: runner === "legacy" ? "opencode_local" : "paperclip_runner", adapterConfig: runner === "legacy" ? { model } : { provider: "opencode", model, opencodePermissionMode: "allow" } });
|
||||
});
|
||||
|
||||
it("allows model-free OpenCode only as an inactive setup draft", async () => {
|
||||
const selection = await import("../services/agent-runner-selection.js");
|
||||
vi.spyOn(selection, "resolveNewAgentRunnerForCompany").mockImplementation(async (_db, _companyId, input) =>
|
||||
selection.resolveNewAgentRunner({ ...input, target: { driver: "local", platform: "darwin", architecture: "arm64" } }));
|
||||
const originalCreate = mockAgentService.create.getMockImplementation()!;
|
||||
mockAgentService.create.mockImplementation(async (...args) => ({ ...await originalCreate(...args), lifecycleHolds: ["setup"] }));
|
||||
const app = await createApp();
|
||||
const input = { name: "OpenCode draft", adapterType: "opencode_local", adapterConfig: {} };
|
||||
const draft = await requestApp(app, baseUrl => request(baseUrl).post("/api/companies/company-1/agents")
|
||||
.send({ ...input, setupId: "00000000-0000-4000-8000-000000000092" }));
|
||||
expect(draft.status, JSON.stringify(draft.body)).toBe(201);
|
||||
expect(mockAgentService.create.mock.calls.at(-1)?.[1]).toMatchObject({ adapterType: "paperclip_runner", adapterConfig: { provider: "opencode", model: "" } });
|
||||
expect(mockAgentService.create.mock.calls.at(-1)?.[2]).toMatchObject({ setupOnly: true, runnerResolved: true });
|
||||
mockAgentService.create.mockClear();
|
||||
const active = await requestApp(app, baseUrl => request(baseUrl).post("/api/companies/company-1/agents")
|
||||
.send({ ...input, setupOnly: true }));
|
||||
expect(active.status).toBe(422);
|
||||
expect(mockAgentService.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps other native provider selection behind its existing flag", async () => {
|
||||
const app = await createApp();
|
||||
const response = await requestApp(app, baseUrl => request(baseUrl).post("/api/companies/company-1/agents").send({ name: "Native OpenCode", adapterType: "paperclip_runner", adapterConfig: { provider: "opencode", model: "openrouter/deepseek/deepseek-v4-flash-0731" } }));
|
||||
const response = await requestApp(app, baseUrl => request(baseUrl).post("/api/companies/company-1/agents").send({ name: "Native Pi", adapterType: "paperclip_runner", adapterConfig: { provider: "acpx", acpxAgent: "pi", model: "openrouter/deepseek/deepseek-v4-flash-0731" } }));
|
||||
expect(response.status).toBe(422);
|
||||
expect(response.body.details).toMatchObject({ code: "paperclip_runner_rollout_disabled" });
|
||||
});
|
||||
|
||||
@@ -154,7 +154,7 @@ describe("agent-created hires use managed AI connections", () => {
|
||||
});
|
||||
|
||||
it.each([
|
||||
["opencode_local", {}, "PAPERCLIP_OPENCODE_PROVIDERS"],
|
||||
["opencode_local", { model: "anthropic/claude-sonnet-5" }, "PAPERCLIP_OPENCODE_PROVIDERS"],
|
||||
["opencode_local", { model: "anthropic/claude-sonnet-5" }, "OPENCODE_AUTH_JSON"],
|
||||
["paperclip_runner", { provider: "opencode", model: "anthropic/claude-sonnet-5" }, "OPENCODE_CONFIG_CONTENT"],
|
||||
] as const)("%s hires with %j apply the host boundary to explicit %s auth", async (adapterType, config, key) => {
|
||||
|
||||
@@ -129,6 +129,10 @@ const mockInstanceSettingsService = vi.hoisted(() => ({
|
||||
const mockRunSshCommand = vi.hoisted(() => vi.fn());
|
||||
|
||||
function registerModuleMocks() {
|
||||
vi.doMock("../lib/agent-runner-platform.js", () => ({
|
||||
localAgentRunnerTarget: () => ({ driver: "local", platform: "linux", architecture: "x64", libc: "glibc", glibcVersion: "2.39" }),
|
||||
localPaperclipRunnerSupported: () => true,
|
||||
}));
|
||||
vi.doMock("@paperclipai/adapter-utils/ssh", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@paperclipai/adapter-utils/ssh")>()),
|
||||
runSshCommand: mockRunSshCommand,
|
||||
@@ -1461,7 +1465,7 @@ describe("agent permission routes", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("seeds opencode agent creation with the static default model without live discovery", async () => {
|
||||
it("seeds explicit legacy opencode creation with the static default model without live discovery", async () => {
|
||||
mockEnsureOpenCodeModelConfiguredAndAvailable.mockRejectedValue(
|
||||
new Error("`opencode models` should not be called during creation"),
|
||||
);
|
||||
@@ -1480,6 +1484,7 @@ describe("agent permission routes", () => {
|
||||
name: "OpenCode Builder",
|
||||
role: "engineer",
|
||||
adapterType: "opencode_local",
|
||||
runner: "legacy",
|
||||
adapterConfig: {},
|
||||
}));
|
||||
|
||||
@@ -1526,8 +1531,9 @@ describe("agent permission routes", () => {
|
||||
expect(mockAgentService.create).toHaveBeenCalledWith(
|
||||
companyId,
|
||||
expect.objectContaining({
|
||||
adapterType: "opencode_local",
|
||||
adapterType: "paperclip_runner",
|
||||
adapterConfig: expect.objectContaining({
|
||||
provider: "opencode",
|
||||
model: "anthropic/claude-sonnet-4-5",
|
||||
}),
|
||||
}),
|
||||
@@ -1831,7 +1837,7 @@ describe("agent permission routes", () => {
|
||||
|
||||
for (const adapterCase of sshCapableAdapterCases) {
|
||||
it(`allows creating a ${adapterCase.adapterType} agent with an SSH default environment`, async () => {
|
||||
const expectedAdapterType = ["codex_local", "claude_local"].includes(adapterCase.adapterType) ? "paperclip_runner" : adapterCase.adapterType;
|
||||
const expectedAdapterType = ["codex_local", "claude_local", "opencode_local"].includes(adapterCase.adapterType) ? "paperclip_runner" : adapterCase.adapterType;
|
||||
const environmentId = "33333333-3333-4333-8333-333333333333";
|
||||
mockEnvironmentService.getById.mockResolvedValue({
|
||||
id: environmentId,
|
||||
@@ -1873,8 +1879,9 @@ describe("agent permission routes", () => {
|
||||
}),
|
||||
{ runnerResolved: true, aiConnectionInstall: undefined, createdByUserId: "board-user", responsibleUserId: "board-user", claudeLogin: { storedSessionId: null, ownerUserId: "board-user", applyExistingWithoutClaim: false } },
|
||||
);
|
||||
if (["codex_local", "claude_local"].includes(adapterCase.adapterType)) {
|
||||
if (["codex_local", "claude_local", "opencode_local"].includes(adapterCase.adapterType)) {
|
||||
if (adapterCase.adapterType === "codex_local") expect(mockAgentService.create.mock.calls[0][1].adapterConfig).toMatchObject({ provider: "codex" });
|
||||
else if (adapterCase.adapterType === "opencode_local") expect(mockAgentService.create.mock.calls[0][1].adapterConfig).toMatchObject({ provider: "opencode", model: adapterCase.adapterConfig.model });
|
||||
else expect(mockAgentService.create.mock.calls[0][1].adapterConfig).toMatchObject({ provider: "acpx", acpxAgent: "claude", model: "claude-opus-5-5" });
|
||||
const { AGENT_RUNNER_SSH_PLATFORM_COMMAND } = await import("../services/agent-runner-selection.js");
|
||||
expect(mockRunSshCommand).toHaveBeenCalledWith(expect.objectContaining({ host: "qa.example.invalid" }), AGENT_RUNNER_SSH_PLATFORM_COMMAND, { timeoutMs: 10_000 });
|
||||
|
||||
@@ -6106,7 +6106,7 @@ describe("company portability", () => {
|
||||
expect(() => resolvePaperclipRunnerProviderProfile(createdConfig)).toThrow(expect.objectContaining({ code: "paperclip_runner_dot_config_invalid" }));
|
||||
});
|
||||
|
||||
it("rejects runner imports while disabled and accepts the same selection when enabled", async () => {
|
||||
it("accepts native OpenCode imports without the experimental flag", async () => {
|
||||
const portability = companyPortabilityService({} as any);
|
||||
const exported = await portability.exportBundle("company-1", {
|
||||
include: { company: false, agents: true, projects: false, issues: false },
|
||||
@@ -6134,11 +6134,11 @@ describe("company portability", () => {
|
||||
},
|
||||
};
|
||||
|
||||
await expect(portability.importBundle(request, "user-1")).rejects.toMatchObject({
|
||||
status: 422,
|
||||
details: { code: "paperclip_runner_rollout_disabled" },
|
||||
});
|
||||
expect(agentSvc.create).not.toHaveBeenCalled();
|
||||
await portability.importBundle(request, "user-1");
|
||||
expect(agentSvc.create).toHaveBeenCalledWith("company-1", expect.objectContaining({
|
||||
adapterType: "paperclip_runner",
|
||||
adapterConfig: expect.objectContaining({ provider: "opencode" }),
|
||||
}), { createdByUserId: "user-1" });
|
||||
|
||||
instanceSettingsSvc.getExperimental.mockResolvedValue({ enableNativeRunner: true });
|
||||
await portability.importBundle({
|
||||
|
||||
@@ -109,7 +109,7 @@ describe("durable Dot Runner integration", () => {
|
||||
expect(() => resolveNativeRuntimeMode(input)).toThrow("experimental settings");
|
||||
expect(resolveNativeRuntimeMode({ ...input, enabled: false, dotEnabled: true })).toMatchObject({ kind: "native", profile: { backend: "openai_dot_mcp" } });
|
||||
expect(() => resolveNativeRuntimeMode({ ...input, enabled: false, dotEnabled: true,
|
||||
adapterConfig: { provider: "opencode", model: "openrouter/deepseek/deepseek-v4-flash-0731" },
|
||||
adapterConfig: { provider: "acpx", acpxAgent: "pi", model: "anthropic/claude-sonnet-4-6" },
|
||||
})).toThrow("Paperclip Runner is experimental and disabled");
|
||||
expect(resolveHeartbeatNativeRuntimeMode({ ...input, enabled: false, dotEnabled: false,
|
||||
persisted: { runtimeMode: "native", runtimeModeReason: null, runtimeModeResolvedAt: new Date(), driverKind: "openai_dot_mcp" },
|
||||
|
||||
@@ -849,11 +849,12 @@ describe("P6-31 Section 18.13 executable status-authority corpus", () => {
|
||||
&& (fixture.covers.migrationRows ?? []).includes("MIG-08")
|
||||
) {
|
||||
const experimentalAdapterConfig = {
|
||||
provider: "opencode",
|
||||
model: "openrouter/deepseek/deepseek-v4-flash-0731",
|
||||
provider: "acpx",
|
||||
acpxAgent: "pi",
|
||||
model: "anthropic/claude-sonnet-4-6",
|
||||
};
|
||||
const runtimeConfig = {
|
||||
nativeRunner: { mode: "native", backend: "opencode_server", protocolVersion: 1 },
|
||||
nativeRunner: { mode: "native", backend: "acpx_runtime", protocolVersion: 1 },
|
||||
};
|
||||
const enabled = options.disableLiveEntrypoint === "rollout";
|
||||
const activeResolution = resolveHeartbeatNativeRuntimeMode({
|
||||
|
||||
@@ -58,7 +58,7 @@ describeEmbeddedPostgres("teams catalog install with no caller adapter overrides
|
||||
return companyId;
|
||||
}
|
||||
|
||||
async function listAdapterTypesByName(companyId: string) {
|
||||
async function listAdapterTypesByName(companyId: string, expectedOpenCodeName?: string) {
|
||||
const rows = await db
|
||||
.select({
|
||||
name: agents.name,
|
||||
@@ -70,7 +70,9 @@ describeEmbeddedPostgres("teams catalog install with no caller adapter overrides
|
||||
.from(agents)
|
||||
.where(eq(agents.companyId, companyId));
|
||||
for (const row of rows.filter(row => row.adapterType === "paperclip_runner")) {
|
||||
expect(row.adapterConfig).toMatchObject({ provider: "acpx", acpxAgent: "claude" });
|
||||
expect(row.adapterConfig).toMatchObject(row.name === expectedOpenCodeName
|
||||
? { provider: "opencode", model: "anthropic/claude-opus-4" }
|
||||
: { provider: "acpx", acpxAgent: "claude" });
|
||||
}
|
||||
return new Map(rows.map((row) => [row.name, row]));
|
||||
}
|
||||
@@ -138,10 +140,10 @@ describeEmbeddedPostgres("teams catalog install with no caller adapter overrides
|
||||
},
|
||||
});
|
||||
|
||||
const byName = await listAdapterTypesByName(companyId);
|
||||
const byName = await listAdapterTypesByName(companyId, "CTO");
|
||||
expect(byName.size).toBe(3);
|
||||
const ctoRow = Array.from(byName.values()).find((row) => row.role === "engineering-manager" || row.name === "CTO");
|
||||
expect(ctoRow?.adapterType).toBe("opencode_local");
|
||||
expect(ctoRow?.adapterType).toBe("paperclip_runner");
|
||||
const otherAdapters = Array.from(byName.values())
|
||||
.filter((row) => row !== ctoRow)
|
||||
.map((row) => row.adapterType);
|
||||
|
||||
@@ -1863,7 +1863,7 @@ export function agentRoutes(
|
||||
if (experimental.enableMuse !== true || experimental.enableNativeRunner !== true) throw unprocessable("Muse is experimental and disabled on this instance.", { code: "paperclip_runner_muse_disabled" });
|
||||
} else if (asRecord(config)?.provider === "grok_bot" && (experimental.enableGrokBot !== true || experimental.enablePublicMcp !== true || experimental.enableNativeRunner !== true)) {
|
||||
throw unprocessable("Enable Grok Bot, Assistant connections (MCP), and Paperclip Runner before connecting this agent.", { code: "paperclip_runner_grok_disabled" });
|
||||
} else if (!([undefined, "codex"].includes(asRecord(config)?.provider as string | undefined)) &&
|
||||
} else if (!([undefined, "codex", "opencode"].includes(asRecord(config)?.provider as string | undefined)) &&
|
||||
!(asRecord(config)?.provider === "acpx" && asRecord(config)?.acpxAgent === "copilot") &&
|
||||
agentHarnessType("paperclip_runner", asRecord(config) ?? {}) !== "claude_local" &&
|
||||
experimental.enableNativeRunner !== true) {
|
||||
@@ -2165,6 +2165,7 @@ export function agentRoutes(
|
||||
adapterType: string | null | undefined;
|
||||
adapterConfig: Record<string, unknown>;
|
||||
constraintAdapterConfig?: Record<string, unknown>;
|
||||
setupOnly?: boolean;
|
||||
}): Promise<Record<string, unknown>> {
|
||||
const normalizedAdapterConfig = await secretsSvc.normalizeAdapterConfigForPersistence(
|
||||
input.companyId,
|
||||
@@ -2174,7 +2175,11 @@ export function agentRoutes(
|
||||
adapterType: input.adapterType ?? null,
|
||||
},
|
||||
);
|
||||
await assertAdapterConfigConstraints(
|
||||
const incompleteOpenCodeSetup = input.setupOnly && input.adapterType === "paperclip_runner"
|
||||
&& normalizedAdapterConfig.provider === "opencode"
|
||||
&& (normalizedAdapterConfig.model === undefined || normalizedAdapterConfig.model === "");
|
||||
// Inactive drafts can precede model selection. Test/hire always validate the complete profile.
|
||||
if (!incompleteOpenCodeSetup) await assertAdapterConfigConstraints(
|
||||
input.companyId,
|
||||
input.adapterType,
|
||||
input.constraintAdapterConfig
|
||||
@@ -4716,7 +4721,7 @@ export function agentRoutes(
|
||||
assertNoAgentLocalAdapterHostCommandMutation(req, createInput.adapterType ?? "process", requestedCreateConfig, await callerUsesAiConnectionPool(req, companyId));
|
||||
assertNoAgentProcessAdapterMutation(req, createInput.adapterType ?? "process", Object.keys(requestedCreateConfig).length > 0);
|
||||
assertExternalInstructionsAdmin(req, { id: agentId, companyId, name: createInput.name, adapterConfig: requestedCreateConfig });
|
||||
Object.assign(createInput, await resolveNewAgentRunnerForCompany(db, companyId, { ...createInput, runner: createRunner }));
|
||||
Object.assign(createInput, await resolveNewAgentRunnerForCompany(db, companyId, { ...createInput, runner: createRunner, setupOnly: Boolean(setupId) }));
|
||||
createInput.adapterType = await assertSelectableAdapterType(createInput.adapterType, createInput.adapterConfig);
|
||||
const rawCreateAdapterConfig = (createInput.adapterConfig ?? {}) as Record<string, unknown>;
|
||||
assertProviderTraceSettingTransition(req, createInput.runtimeConfig);
|
||||
@@ -4766,6 +4771,7 @@ export function agentRoutes(
|
||||
companyId,
|
||||
adapterType: createInput.adapterType,
|
||||
adapterConfig: desiredSkillAssignment.adapterConfig,
|
||||
setupOnly: Boolean(setupId),
|
||||
});
|
||||
const normalizedRuntimeConfig = await normalizeCreatedAgentRuntimeConfig(req, companyId, createInput.adapterType, normalizedAdapterConfig, createInput.runtimeConfig);
|
||||
await assertAgentEnvironmentSelection(companyId, createInput.adapterType, createInput.defaultEnvironmentId);
|
||||
@@ -5377,7 +5383,7 @@ export function agentRoutes(
|
||||
const config = hasOwn(patchData, "adapterConfig") ? { ...(replaceAdapterConfig ? {} : existing.adapterConfig), ...asRecord(patchData.adapterConfig) } : existing.adapterConfig;
|
||||
Object.assign(patchData, await resolveNewAgentRunnerForCompany(db, existing.companyId, {
|
||||
adapterType: typeof patchData.adapterType === "string" ? patchData.adapterType : existing.adapterType,
|
||||
adapterConfig: config, runner,
|
||||
adapterConfig: config, runner, setupOnly: existing.lifecycleHolds?.includes("setup"),
|
||||
defaultEnvironmentId: hasOwn(patchData, "defaultEnvironmentId") ? patchData.defaultEnvironmentId as string | null : existing.defaultEnvironmentId,
|
||||
}));
|
||||
}
|
||||
@@ -5502,6 +5508,7 @@ export function agentRoutes(
|
||||
companyId: existing.companyId,
|
||||
adapterType: requestedAdapterType,
|
||||
adapterConfig: effectiveAdapterConfig,
|
||||
setupOnly: existing.lifecycleHolds?.includes("setup"),
|
||||
});
|
||||
patchData.adapterConfig = syncInstructionsBundleConfigFromFilePath(existing, normalizedEffectiveAdapterConfig);
|
||||
assertExternalInstructionsAdmin(req, {
|
||||
|
||||
@@ -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