From 0a82004bd01b77bf5b0a83dc121b544f958c38d6 Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 9 Oct 2026 09:40:12 -0500 Subject: [PATCH] fix: scope Codex runner defaults to the selected environment Preserve explicit native profiles on unqualified hosts, expose truthful discovery and recovery states, and qualify creation fixtures on Linux without changing existing agent execution. Co-Authored-By: Paperclip --- doc/plans/2026-10-06-default-agent-runner.md | 23 ++++++-- packages/adapters/codex-local/src/index.ts | 4 +- server/src/__tests__/adapter-routes.test.ts | 45 +++++++++++++++- server/src/__tests__/built-in-agents.test.ts | 20 ++++++- .../src/__tests__/company-portability.test.ts | 41 +++++++++++--- .../__tests__/onboarding-seed-route.test.ts | 11 +++- .../__tests__/plugin-managed-agents.test.ts | 19 ++++++- server/src/app.ts | 2 + server/src/routes/adapters.ts | 29 +++++++--- .../services/agent-runner-selection.test.ts | 20 ++++++- server/src/services/agent-runner-selection.ts | 16 ++++-- server/src/services/built-in-agents.ts | 10 +++- server/src/services/company-portability.ts | 14 ++++- server/src/services/plugin-managed-agents.ts | 13 ++++- tests/runner-e2e/confirmation-replies.test.ts | 4 +- ui/src/api/adapters.test.ts | 24 +++++++++ ui/src/api/adapters.ts | 9 +++- ui/src/components/AgentConfigForm.tsx | 25 +++++---- ui/src/components/CodexRunnerSelect.tsx | 16 +++++- .../components/ConfigureBuiltInAgentModal.tsx | 4 +- ui/src/components/OnboardingWizard.tsx | 9 +++- .../new-agent/AgentBasicsDialog.tsx | 2 +- ui/src/components/new-agent/NewAgentSetup.tsx | 20 +++++-- ui/src/lib/queryKeys.ts | 2 + ui/src/pages/NewAgent.test.tsx | 54 +++++++++++++++++-- 25 files changed, 378 insertions(+), 58 deletions(-) create mode 100644 ui/src/api/adapters.test.ts diff --git a/doc/plans/2026-10-06-default-agent-runner.md b/doc/plans/2026-10-06-default-agent-runner.md index 0bc3235ffd5..be83cffb156 100644 --- a/doc/plans/2026-10-06-default-agent-runner.md +++ b/doc/plans/2026-10-06-default-agent-runner.md @@ -36,8 +36,10 @@ Docker materialization, Git installer, or extra login isolation wholesale. ## Current slice: Codex defaults Authorized 2026-10-09. Canonical branch: `codex/codex-runner-default`. -Frozen master base: `4fb1ab53a` (includes both prerequisite merges). -Canonical PR and tested revision: pending preparation. +Master base: `57e977be7` (includes both prerequisite merges and Pi integration). +Canonical PR: [draft #15683](https://github.com/paperclipai/paperclip/pull/15683). +Initial CI candidate: `8ce217b71`; final source freeze follows scoped discovery +and explicit-native import preservation checks. Preview: [isolated Codex QA](http://127.0.0.1:3124), normal dev supervisor, separate empty database. Current-head live evidence is pending the source freeze and verified binary provenance; older services and artifacts are not proof. @@ -77,7 +79,7 @@ Claude's explicit native fixture is unchanged until its later default PR. | Production create/edit/onboarding UI and stories | UI worker | Implemented; 339 affected tests, UI typecheck and token gates pass; story evidence in progress | | Built-ins, plugins, approvals, imports/exports, CLI | Creation-path worker | Implemented; 102 portability, 8 approval, 3 CLI and 3 built-in unit checks pass; 72 DB checks require Linux | | Execution/dev gate, E2E gaps, packaging, live journeys | Lead | 28 runtime-selection and 122 onboarding/catalog support checks pass; actual journeys pending | -| Full CI and final review | Lead | Run on frozen final candidate | +| Full CI and final review | Lead | Initial Linux CI and Greptile running on `8ce217b71`; final-head verification pending | | Local API/subscription task + follow-up | Lead | Credential/runtime identity inventory; unverified on this branch | | Managed staging API/subscription onboarding | Lead | Exact-source preview/artifact and budget qualification pending | | Shipped npm/Docker/cloud artifacts | Lead | Reuse existing hosted checks; prerequisite packaging proof is historical | @@ -118,3 +120,18 @@ Next action: finish core integration checks, commit and push the candidate, then run existing Linux CI/install checks and bounded actual journeys. Preserve failed attempts and costs. Unrelated findings and remaining harness qualification belong to later slices. + +Post-rebase UI checks pass (126 cases, typecheck, token gates). Product E2E +catalog/onboarding support passes (122 cases), plus 47 confirmation fixture +checks and E2E noemit. Existing Pi companion/executor controls pass (26 and 34 +cases); core selection/setup passes 87 cases and transport passes six. +Production Storybook build and 26 desktop/six mobile captures were completed +before the Pi rebase; they are component evidence, not live execution proof. +Both final integration gaps are implemented: discovery resolves the selected +environment through the same helper as saving, and explicit native export/import +profiles remain valid on macOS while new automatic defaults stay legacy. +Scoped discovery/authorization passes 63 checks. UI context and recovery checks +pass 280 cases plus three final regressions. Missing discovery metadata shows +loading or the server error with Retry; explicit Legacy remains available. +Final portability checks pass 105 cases; approval, built-in asset and CLI unit +controls pass eight, three and three. The 72 skipped DB cases still need Linux. diff --git a/packages/adapters/codex-local/src/index.ts b/packages/adapters/codex-local/src/index.ts index c5ab8289c7e..49c9d1e69b6 100644 --- a/packages/adapters/codex-local/src/index.ts +++ b/packages/adapters/codex-local/src/index.ts @@ -188,7 +188,7 @@ export const models = [ export const agentConfigurationDoc = `# codex_local agent configuration -New agents select this Codex harness with adapterType: codex_local. On supported targets, automatic selection stores adapterType: paperclip_runner with provider: codex. Other harness defaults are unchanged. Set the request-level runner: legacy to use this adapter's CLI execution and custom settings. Existing agents and reviewed pending hires retain their saved runner; runtime errors never silently switch runners. +New agents select this Codex harness with adapterType: codex_local. On qualified Linux x64 targets, automatic selection stores adapterType: paperclip_runner with provider: codex. macOS, Windows, and Linux ARM retain legacy defaults until their runner artifacts are qualified. Other harness defaults are unchanged. Set the request-level runner: legacy to use this adapter's execution and custom settings. Existing agents and reviewed pending hires retain their saved runner; runtime errors never silently switch runners. Adapter: codex_local @@ -231,7 +231,7 @@ Notes: - Paperclip injects desired local skills into the effective CODEX_HOME/skills/ directory at execution time so Codex can discover "$paperclip" and related skills without polluting the project working directory. For new and updated agents, Paperclip assigns an isolated managed home at ~/.paperclip/instances//companies//agents//codex-home/skills/; when CODEX_HOME is explicitly overridden in adapter config, that override is used instead. - New and updated codex_local agents persist an empty OPENAI_API_KEY override by default so a host-level OPENAI_API_KEY cannot leak into Codex runs through process inheritance. Explicit CODEX_HOME overrides must not point at the shared company codex-home, $CODEX_HOME, or ~/.codex. - Some model/tool combinations reject certain effort levels (for example minimal with web search enabled). -- With ChatGPT sign-in, the Codex backend accepts a model only from a recent enough Codex CLI: GPT-6.1 Sol needs 0.159.0 or newer, GPT-6 Sol and GPT-6 Luna need 0.157.0 or newer. An older CLI fails every turn with "The '' model is not supported when using Codex with a ChatGPT account." The environment Test and the remote runner compare the installed \`codex --version\` against these floors before running; in a managed sandbox, the fix is a sandbox image that ships the Codex version Paperclip pins. +- With ChatGPT sign-in, model availability depends on the installed Codex capabilities. Legacy setup reports known model/version mismatches. Native setup tests the actual app-server and selected model; a version number alone does not reject or replace an installed CLI. If the provider rejects the model, select an available model or update Codex in the execution environment. - Fast mode is supported on GPT-6 (astra/sol/luna), GPT-6.1 Sol, GPT-5.6 (sol/terra/luna), GPT-5.5, GPT-5.4 and manual model IDs. When enabled for those models, Paperclip applies \`service_tier="fast"\` and \`features.fast_mode=true\`. - When Paperclip realizes a workspace/runtime for a run, it injects PAPERCLIP_WORKSPACE_* and PAPERCLIP_RUNTIME_* env vars for agent-side tooling. - The ACP engine keeps its workspace sandbox and enables network access on each turn. Explicit sandbox_workspace_write.network_access overrides in extraArgs (or env.PAPERCLIP_CODEX_ACP_NETWORK_ACCESS="false") disable it; execution-target network denial wins. The bundled ACP patch is needed because upstream mode presets override Codex config.toml on every turn. diff --git a/server/src/__tests__/adapter-routes.test.ts b/server/src/__tests__/adapter-routes.test.ts index c4a66a3ff72..ba6ba31ac2c 100644 --- a/server/src/__tests__/adapter-routes.test.ts +++ b/server/src/__tests__/adapter-routes.test.ts @@ -147,11 +147,11 @@ describe("adapter routes", () => { } }); - it("keeps paperclip_runner hidden from selection unless the rollout flag is enabled", async () => { + it("keeps the shared runner implementation available for Codex without the experimental flag", async () => { const disabledResponse = await request(createApp()).get("/api/adapters"); expect(disabledResponse.status).toBe(200); expect(disabledResponse.body.find((adapter: any) => adapter.type === "paperclip_runner")) - .toMatchObject({ disabled: true }); + .toMatchObject({ disabled: false }); const enabledResponse = await request(createApp({}, { getNativeRunnerEnabled: async () => true, @@ -166,6 +166,47 @@ describe("adapter routes", () => { }); }); + it.each([ + [{ driver: "ssh", platform: "linux", architecture: "x64" }, "paperclip"], + [{ driver: "ssh", platform: "darwin", architecture: "arm64" }, "legacy"], + ])("projects the selected environment once for the whole adapter inventory (%s)", async (target, defaultRunner) => { + const resolveRunnerTarget = vi.fn(async () => target); + const response = await request(createApp({}, { resolveRunnerTarget })).get("/api/adapters?companyId=company-1&environmentId=selected"); + expect(response.status).toBe(200); + expect(resolveRunnerTarget).toHaveBeenCalledExactlyOnceWith("company-1", "selected"); + expect(response.body.find((adapter: any) => adapter.type === "codex_local")).toMatchObject({ defaultRunner }); + expect(response.body.find((adapter: any) => adapter.type === "claude_local")).toMatchObject({ defaultRunner: "legacy" }); + }); + + it("uses the company's effective default environment when discovery omits an override", async () => { + const resolveRunnerTarget = vi.fn(async () => ({ driver: "sandbox" })); + const response = await request(createApp({}, { resolveRunnerTarget })).get("/api/adapters?companyId=company-1"); + expect(response.status).toBe(200); + expect(resolveRunnerTarget).toHaveBeenCalledExactlyOnceWith("company-1", undefined); + expect(response.body.find((adapter: any) => adapter.type === "codex_local")).toMatchObject({ defaultRunner: "paperclip" }); + }); + + it("keeps unscoped inventory independent of company target discovery", async () => { + const resolveRunnerTarget = vi.fn(); + const response = await request(createApp({}, { resolveRunnerTarget })).get("/api/adapters"); + expect(response.status).toBe(200); + expect(resolveRunnerTarget).not.toHaveBeenCalled(); + }); + + it("rejects a foreign company before selecting or probing an environment", async () => { + const resolveRunnerTarget = vi.fn(); + const response = await request(createApp({ source: "session", companyIds: ["allowed"] }, { resolveRunnerTarget })).get("/api/adapters?companyId=foreign&environmentId=selected"); + expect(response.status).toBe(403); + expect(resolveRunnerTarget).not.toHaveBeenCalled(); + }); + + it.each(["environmentId=selected", "companyId=", "companyId=one&companyId=two", "companyId=one&environmentId="])("rejects malformed discovery scope before resolution (%s)", async query => { + const resolveRunnerTarget = vi.fn(); + const response = await request(createApp({}, { resolveRunnerTarget })).get(`/api/adapters?${query}`); + expect(response.status).toBe(400); + expect(resolveRunnerTarget).not.toHaveBeenCalled(); + }); + it("keeps the shared implementation available for Dot independently, while honoring adapter-admin disabling", async () => { const options = { getNativeRunnerEnabled: async () => false, getOpenAiDotEnabled: async () => true }; const enabled = await request(createApp({}, options)).get("/api/adapters"); diff --git a/server/src/__tests__/built-in-agents.test.ts b/server/src/__tests__/built-in-agents.test.ts index c4d0c348c76..067e9e53f16 100644 --- a/server/src/__tests__/built-in-agents.test.ts +++ b/server/src/__tests__/built-in-agents.test.ts @@ -5,7 +5,7 @@ import { randomUUID } from "node:crypto"; import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; -import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { and, eq, sql } from "drizzle-orm"; import { activityLog, @@ -120,6 +120,8 @@ describe("built-in agent asset loading", () => { describeEmbeddedPostgres("built-in agents", () => { let db!: ReturnType; let tempDb: Awaited> | null = null; + let runnerSelectionSpy: { mockRestore(): void } | undefined; + const runnerTarget = { driver: "local", platform: "linux", architecture: "x64" }; const instructionOperator = { type: "board", userId: "local-board", source: "local_implicit" } as const; async function writeInstructionEntry(agent: { id: string; companyId: string }, entryFile: string, content: string) { const service = agentInstructionRevisionService(db); @@ -136,7 +138,17 @@ describeEmbeddedPostgres("built-in agents", () => { db = createDb(tempDb.connectionString); }, 20_000); + beforeEach(async () => { + // Exercise real runner policy on the qualified Linux release target. + runnerTarget.platform = "linux"; + runnerTarget.architecture = "x64"; + const selection = await import("../services/agent-runner-selection.js"); + runnerSelectionSpy = vi.spyOn(selection, "resolveNewAgentRunnerForCompany").mockImplementation(async (_db, _companyId, input) => + selection.resolveNewAgentRunner({ ...input, target: runnerTarget })); + }); + afterEach(async () => { + runnerSelectionSpy?.mockRestore(); await db.delete(routineTriggers); await db.delete(routines); await db.delete(issueThreadInteractions); @@ -287,6 +299,9 @@ describeEmbeddedPostgres("built-in agents", () => { await svc.ensure(companyId, "briefs", { budgetMonthlyCents: 200 }); await reconcileBuiltInAgentsOnStartup(db); expect((await svc.get(companyId, "briefs")).agent?.adapterConfig).toEqual(switched.agent?.adapterConfig); + // A saved native profile remains editable on source-built macOS installs. + runnerTarget.platform = "darwin"; + runnerTarget.architecture = "arm64"; const edited = await svc.ensure(companyId, "briefs", { adapterType: "codex_local", adapterConfig: { model: "gpt-5.4" } }); expect(edited.agent).toMatchObject({ adapterType: "paperclip_runner", adapterConfig: { provider: "codex", model: "gpt-5.4" } }); }); @@ -295,6 +310,9 @@ describeEmbeddedPostgres("built-in agents", () => { const companyId = await seedCompany({ requireApproval: true }); const result = await builtInAgentService(db).provision(companyId, "briefs", { adapterType: "codex_local", adapterConfig: { model: "gpt-5.4" } }, { requestedByUserId: "board-user" }); expect(result.approval?.payload).toMatchObject({ adapterType: "paperclip_runner", adapterConfig: { provider: "codex", model: "gpt-5.4" } }); + // Restart and approval activate the reviewed config even if eligibility changed. + runnerTarget.platform = "darwin"; + runnerTarget.architecture = "arm64"; await reconcileBuiltInAgentsOnStartup(db); await approvalService(db).approve(result.approval!.id, "board-user"); const approved = await builtInAgentService(db).get(companyId, "briefs"); diff --git a/server/src/__tests__/company-portability.test.ts b/server/src/__tests__/company-portability.test.ts index 99f13d46688..4cbcb025cef 100644 --- a/server/src/__tests__/company-portability.test.ts +++ b/server/src/__tests__/company-portability.test.ts @@ -7,6 +7,16 @@ import { Readable } from "node:stream"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { CompanyPortabilityFileEntry } from "@paperclipai/shared"; +const runnerTarget = vi.hoisted(() => ({ driver: "local", platform: "linux", architecture: "x64" })); + +vi.mock("../services/agent-runner-selection.js", async (importOriginal) => { + const actual = await importOriginal(); + // Keep selection/translation real on an explicit target, including when an + // existing test resets modules. Environment discovery has its own coverage. + return { ...actual, resolveNewAgentRunnerForCompany: vi.fn(async (...[_db, _companyId, input]: Parameters) => + actual.resolveNewAgentRunner({ ...input, target: runnerTarget })) }; +}); + const companySvc = { getById: vi.fn(), // Async-empty default (not a bare vi.fn()): every new-company import reads @@ -201,6 +211,8 @@ describe("company portability", () => { beforeEach(() => { vi.clearAllMocks(); + runnerTarget.platform = "linux"; + runnerTarget.architecture = "x64"; agentSvc.getById.mockImplementation(async (id: string) => (await agentSvc.list()).find((agent: { id: string }) => agent.id === id) ?? null); instanceSettingsSvc.getExperimental.mockResolvedValue({ enableNativeRunner: false }); managedAgentProfileSvc.requireQualified.mockResolvedValue({ @@ -6046,7 +6058,13 @@ describe("company portability", () => { expect(preview.plan.issuePlans).toHaveLength(0); }); - it.each(["legacy", "paperclip"] as const)("exports the resolved %s Codex runner and preserves it on import", async (runner) => { + it.each([ + { runner: "legacy", platform: "linux" }, + { runner: "paperclip", platform: "linux" }, + { runner: "paperclip", platform: "darwin" }, + ] as const)("exports the resolved Codex runner and preserves it on import ($runner/$platform)", async ({ runner, platform }) => { + runnerTarget.platform = platform; + runnerTarget.architecture = platform === "darwin" ? "arm64" : "x64"; const [fixture] = await agentSvc.list(); agentSvc.list.mockResolvedValue([{ ...fixture, adapterType: runner === "legacy" ? "codex_local" : "paperclip_runner", adapterConfig: { ...(runner === "paperclip" ? { provider: "codex" } : {}), model: "gpt-5.4" } }]); const portability = companyPortabilityService({} as any); @@ -6071,7 +6089,13 @@ describe("company portability", () => { expect(agentSvc.create).not.toHaveBeenCalled(); }); - it.each(["create", "update"] as const)("applies Codex defaults only to new imports without a runner (%s)", async (action) => { + it.each([ + { action: "create", platform: "linux", expectedType: "paperclip_runner" }, + { action: "update", platform: "linux", expectedType: "codex_local" }, + { action: "create", platform: "darwin", expectedType: "codex_local" }, + ])("applies qualified Codex defaults only to new imports without a runner ($platform/$action)", async ({ action, platform, expectedType }) => { + runnerTarget.platform = platform; + runnerTarget.architecture = platform === "darwin" ? "arm64" : "x64"; const [fixture] = await agentSvc.list(); agentSvc.list.mockResolvedValue([{ ...fixture, adapterType: "codex_local", adapterConfig: { model: "gpt-5.4" } }]); const portability = companyPortabilityService({} as any); @@ -6081,15 +6105,18 @@ describe("company portability", () => { agentSvc.create.mockImplementation(async (_companyId: string, input: Record) => ({ id: "agent-created", ...input })); agentSvc.update.mockImplementation(async (id: string, input: Record) => ({ id, ...fixture, ...input })); await portability.importBundle({ source: { type: "inline", rootPath: exported.rootPath, files }, include: { company: false, agents: true, projects: false, issues: false }, target: { mode: "existing_company", companyId: "company-1" }, agents: "all", collisionStrategy: action === "update" ? "replace" : "rename" }, "user-1"); - if (action === "create") expect(agentSvc.create).toHaveBeenCalledWith("company-1", expect.objectContaining({ adapterType: "paperclip_runner", adapterConfig: expect.objectContaining({ provider: "codex" }) }), { createdByUserId: "user-1" }); + if (action === "create") expect(agentSvc.create).toHaveBeenCalledWith("company-1", expect.objectContaining({ adapterType: expectedType, adapterConfig: expect.objectContaining(expectedType === "paperclip_runner" ? { provider: "codex" } : { model: "gpt-5.4" }) }), { createdByUserId: "user-1" }); else expect(agentSvc.update).toHaveBeenCalledWith("agent-1", expect.objectContaining({ adapterType: "codex_local", adapterConfig: expect.objectContaining({ model: "gpt-5.4" }) })); }); it.each([ - { existingRunner: "legacy", existingHarness: "codex_local", expectedType: "codex_local" }, - { existingRunner: "paperclip", existingHarness: "codex_local", expectedType: "paperclip_runner" }, - { existingRunner: "legacy", existingHarness: "claude_local", expectedType: "paperclip_runner" }, - ])("keeps execution on same-harness replace-import edits and defaults genuine changes ($existingRunner/$existingHarness)", async ({ existingRunner, existingHarness, expectedType }) => { + { existingRunner: "legacy", existingHarness: "codex_local", expectedType: "codex_local", platform: "linux" }, + { existingRunner: "paperclip", existingHarness: "codex_local", expectedType: "paperclip_runner", platform: "linux" }, + { existingRunner: "paperclip", existingHarness: "codex_local", expectedType: "paperclip_runner", platform: "darwin" }, + { existingRunner: "legacy", existingHarness: "claude_local", expectedType: "paperclip_runner", platform: "linux" }, + ])("keeps execution on same-harness replace-import edits and defaults genuine changes ($existingRunner/$existingHarness/$platform)", async ({ existingRunner, existingHarness, expectedType, platform }) => { + runnerTarget.platform = platform; + runnerTarget.architecture = platform === "darwin" ? "arm64" : "x64"; const [fixture] = await agentSvc.list(); const existing = { ...fixture, adapterType: existingRunner === "paperclip" ? "paperclip_runner" : existingHarness, adapterConfig: { ...(existingRunner === "paperclip" ? { provider: "codex" } : {}), model: existingHarness === "codex_local" ? "gpt-5.4" : "claude-sonnet-4-6" } }; agentSvc.list.mockResolvedValue([existing]); diff --git a/server/src/__tests__/onboarding-seed-route.test.ts b/server/src/__tests__/onboarding-seed-route.test.ts index 49ab0998e42..8edbb2f106b 100644 --- a/server/src/__tests__/onboarding-seed-route.test.ts +++ b/server/src/__tests__/onboarding-seed-route.test.ts @@ -1,7 +1,7 @@ import { randomUUID } from "node:crypto"; import request from "supertest"; import { and, eq } from "drizzle-orm"; -import { afterEach, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; import { activityLog, agents, @@ -38,8 +38,17 @@ const SEED = { describeEmbeddedPostgres("POST /api/companies/:companyId/onboarding-seed", () => { const ctx = useEmbeddedPostgres("onboarding-seed-route"); + let runnerSelectionSpy: { mockRestore(): void } | undefined; + + beforeEach(async () => { + // Exercise real runner policy on the qualified Linux release target. + const selection = await import("../services/agent-runner-selection.js"); + runnerSelectionSpy = vi.spyOn(selection, "resolveNewAgentRunnerForCompany").mockImplementation(async (_db, _companyId, input) => + selection.resolveNewAgentRunner({ ...input, target: { driver: "local", platform: "linux", architecture: "x64" } })); + }); afterEach(async () => { + runnerSelectionSpy?.mockRestore(); await ctx.db.delete(activityLog); await ctx.db.delete(companyOnboardingSeeds); await ctx.db.delete(issues); diff --git a/server/src/__tests__/plugin-managed-agents.test.ts b/server/src/__tests__/plugin-managed-agents.test.ts index 6f3507f0c38..b15c6a7264c 100644 --- a/server/src/__tests__/plugin-managed-agents.test.ts +++ b/server/src/__tests__/plugin-managed-agents.test.ts @@ -3,7 +3,7 @@ import { promises as fs } from "node:fs"; import os from "node:os"; import path from "node:path"; import { eq } from "drizzle-orm"; -import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { activityLog, agentConfigRevisions, @@ -95,13 +95,25 @@ if (!embeddedPostgresSupport.supported) { describeEmbeddedPostgres("plugin-managed agents", () => { let db!: ReturnType; let tempDb: Awaited> | null = null; + let runnerSelectionSpy: { mockRestore(): void } | undefined; + const runnerTarget = { driver: "local", platform: "linux", architecture: "x64" }; beforeAll(async () => { tempDb = await startEmbeddedPostgresTestDatabase("paperclip-plugin-managed-agents-"); db = createDb(tempDb.connectionString); }, 20_000); + beforeEach(async () => { + // Exercise real runner policy on the qualified Linux release target. + runnerTarget.platform = "linux"; + runnerTarget.architecture = "x64"; + const selection = await import("../services/agent-runner-selection.js"); + runnerSelectionSpy = vi.spyOn(selection, "resolveNewAgentRunnerForCompany").mockImplementation(async (_db, _companyId, input) => + selection.resolveNewAgentRunner({ ...input, target: runnerTarget })); + }); + afterEach(async () => { + runnerSelectionSpy?.mockRestore(); await db.delete(agentConfigRevisions); await db.delete(activityLog); await db.delete(pluginEntities); @@ -318,6 +330,11 @@ describeEmbeddedPostgres("plugin-managed agents", () => { expect(created.agent).toMatchObject({ adapterType: "paperclip_runner", adapterConfig: { provider: "codex" } }); const reconciled = await services.agents.managedReconcile({ companyId, agentKey: "wiki-maintainer" }); expect(reconciled.agent?.adapterConfig).toEqual(created.agent?.adapterConfig); + // Reset declaration defaults without requalifying an existing native agent. + runnerTarget.platform = "darwin"; + runnerTarget.architecture = "arm64"; + const reset = await services.agents.managedReset({ companyId, agentKey: "wiki-maintainer" }); + expect(reset.agent).toMatchObject({ adapterType: "paperclip_runner", adapterConfig: { provider: "codex" } }); }); it("materializes declared managed agent instructions with local folder paths", async () => { diff --git a/server/src/app.ts b/server/src/app.ts index 1697f311303..b5c7eeb5006 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -71,6 +71,7 @@ import { summarySlotRoutes } from "./routes/summary-slots.js"; import { statusCardRoutes } from "./routes/status-cards.js"; import { teamsCatalogRoutes } from "./routes/teams-catalog.js"; import { agentRoutes } from "./routes/agents.js"; +import { resolveAgentRunnerTargetForCompany } from "./services/agent-runner-selection.js"; import type { SetupTokenSessionService } from "./services/setup-token-session.js"; import { buildSetupTokenLoginTransport, @@ -987,6 +988,7 @@ export async function createApp( ); api.use( adapterRoutes({ + resolveRunnerTarget: (companyId, environmentId) => resolveAgentRunnerTargetForCompany(db, companyId, environmentId), getNativeRunnerEnabled: async () => (await instanceSettingsService(db).getExperimental()) .enableNativeRunner === true, diff --git a/server/src/routes/adapters.ts b/server/src/routes/adapters.ts index b2adcd0bc10..2e96ef783ab 100644 --- a/server/src/routes/adapters.ts +++ b/server/src/routes/adapters.ts @@ -29,6 +29,7 @@ import { unregisterServerAdapter, isOverridePaused, setOverridePaused, + waitForExternalAdapters, } from "../adapters/registry.js"; import { listAdapterPlugins, @@ -47,12 +48,12 @@ import type { } from "@paperclipai/adapter-utils"; import { loadExternalAdapterPackage, getUiParserSource, getOrExtractUiParserSource, reloadExternalAdapter } from "../adapters/plugin-loader.js"; import { logger } from "../middleware/logger.js"; -import { forbidden } from "../errors.js"; +import { badRequest, forbidden } from "../errors.js"; import { isCloudManagedInstance } from "../services/cloud-instance.js"; import { getHiddenSettings } from "../services/settings-visibility.js"; -import { assertBoardOrgAccess, assertInstanceAdmin } from "./authz.js"; +import { assertBoardOrgAccess, assertCompanyAccess, assertInstanceAdmin } from "./authz.js"; import { BUILTIN_ADAPTER_TYPES } from "../adapters/builtin-adapter-types.js"; -import { agentRunnerAvailability } from "../services/agent-runner-selection.js"; +import { agentRunnerAvailability, type RunnerTarget } from "../services/agent-runner-selection.js"; import type { AgentRunnerAvailability } from "@paperclipai/shared"; const execFileAsync = promisify(execFile); @@ -195,7 +196,7 @@ export function buildAdapterCapabilities(adapter: ServerAdapterModule): AdapterC }; } -function buildAdapterInfo(adapter: ServerAdapterModule, externalRecord: AdapterPluginRecord | undefined, disabledSet: Set): AdapterInfo { +function buildAdapterInfo(adapter: ServerAdapterModule, externalRecord: AdapterPluginRecord | undefined, disabledSet: Set, target?: RunnerTarget): AdapterInfo { const fromDisk = externalRecord ? readAdapterPackageVersionFromDisk(externalRecord) : undefined; return { type: adapter.type, @@ -205,7 +206,7 @@ function buildAdapterInfo(adapter: ServerAdapterModule, externalRecord: AdapterP loaded: true, // If it's in the registry, it's loaded disabled: disabledSet.has(adapter.type), capabilities: buildAdapterCapabilities(adapter), - ...agentRunnerAvailability(adapter.type), + ...agentRunnerAvailability(adapter.type, target), ...(adapter.acp ? { acp: adapter.acp } : {}), overriddenBuiltin: externalRecord ? BUILTIN_ADAPTER_TYPES.has(adapter.type) : undefined, overridePaused: BUILTIN_ADAPTER_TYPES.has(adapter.type) ? isOverridePaused(adapter.type) : undefined, @@ -263,6 +264,7 @@ function registerWithSessionManagement(adapter: ServerAdapterModule): void { export function adapterRoutes(options: { getNativeRunnerEnabled?: () => Promise; getOpenAiDotEnabled?: () => Promise; + resolveRunnerTarget?: (companyId: string, environmentId?: string) => Promise; } = {}) { const router = Router(); @@ -279,6 +281,21 @@ export function adapterRoutes(options: { // instance-admin only because they affect the whole server runtime. assertBoardOrgAccess(_req); + const companyId = _req.query.companyId; + const environmentId = _req.query.environmentId; + if ((companyId !== undefined && (typeof companyId !== "string" || !companyId.trim())) + || (environmentId !== undefined && (typeof environmentId !== "string" || !environmentId.trim()))) { + throw badRequest("companyId and environmentId must be nonempty strings."); + } + if (environmentId !== undefined && companyId === undefined) throw badRequest("companyId is required when selecting an environment."); + let target: RunnerTarget | undefined; + if (typeof companyId === "string") { + assertCompanyAccess(_req, companyId); + if (!options.resolveRunnerTarget) throw badRequest("Scoped adapter discovery is unavailable."); + target = await options.resolveRunnerTarget(companyId, typeof environmentId === "string" ? environmentId : undefined); + } + await waitForExternalAdapters(); + const registeredAdapters = listServerAdapters(); const externalRecords = new Map( listAdapterPlugins().map((r) => [r.type, r]), @@ -290,7 +307,7 @@ export function adapterRoutes(options: { // provider-specific selection gates. Explicit adapter disabling still applies. const result: AdapterInfo[] = registeredAdapters.map((adapter) => - buildAdapterInfo(adapter, externalRecords.get(adapter.type), disabledSet), + buildAdapterInfo(adapter, externalRecords.get(adapter.type), disabledSet, target), ).sort((a, b) => a.type.localeCompare(b.type)); res.json(result); diff --git a/server/src/services/agent-runner-selection.test.ts b/server/src/services/agent-runner-selection.test.ts index 598f7ce598a..b16896d94de 100644 --- a/server/src/services/agent-runner-selection.test.ts +++ b/server/src/services/agent-runner-selection.test.ts @@ -6,7 +6,7 @@ vi.mock("./instance-settings.js", () => ({ instanceSettingsService: () => ({ get vi.mock("./environments.js", () => ({ environmentService: () => ({ getById: state.getEnvironment, listBoundCompanyIds: state.bindings, findManagedSandboxEnvironment: state.managedEnvironment, findKubernetesEnvironment: state.managedEnvironment }) })); vi.mock("./environment-config.js", () => ({ resolveEnvironmentDriverConfigForRuntime: state.resolveEnvironment })); vi.mock("@paperclipai/adapter-utils/ssh", () => ({ runSshCommand: state.ssh })); -import { agentRunnerAvailability, resolveNewAgentRunner, resolveNewAgentRunnerForCompany } from "./agent-runner-selection.js"; +import { agentRunnerAvailability, resolveAgentRunnerTargetForCompany, resolveNewAgentRunner, resolveNewAgentRunnerForCompany } from "./agent-runner-selection.js"; const target = { driver: "local", platform: "linux", architecture: "x64" }; describe("server-owned Codex runner selection", () => { @@ -32,6 +32,7 @@ describe("server-owned Codex runner selection", () => { expect(() => resolveNewAgentRunner({ ...input, runner: "paperclip" })).toThrow("unavailable"); const saved = { adapterType: "paperclip_runner", adapterConfig: { provider: "codex", model: "gpt-5.6-sol" }, target: input.target }; expect(resolveNewAgentRunner(saved)).toEqual({ adapterType: saved.adapterType, adapterConfig: saved.adapterConfig }); + expect(resolveNewAgentRunner({ ...saved, runner: "paperclip" })).toEqual({ adapterType: saved.adapterType, adapterConfig: saved.adapterConfig }); }); it("automatically keeps unsupported targets and active external overrides legacy", () => { const input = { adapterType: "codex_local", target: { driver: "local", platform: "win32", architecture: "x64" } }; @@ -70,6 +71,23 @@ describe("server-owned Codex runner selection", () => { expect(state.getEnvironment).toHaveBeenCalledWith("default"); expect(state.bindings).toHaveBeenCalledWith("default"); expect(result.adapterType).toBe("codex_local"); }); + it.each([ + ["Linux\nx86_64\n", "paperclip", "paperclip_runner"], + ["Darwin\narm64\n", "legacy", "codex_local"], + ])("uses the same selected SSH target for discovery and saving (%s)", async (stdout, expectedRunner, expectedAdapter) => { + state.getEnvironment.mockResolvedValue({ id: "selected", driver: "ssh", status: "active", config: {} }); + state.ssh.mockResolvedValue({ stdout }); + const discoveredTarget = await resolveAgentRunnerTargetForCompany({} as never, "company", "selected"); + expect(agentRunnerAvailability("codex_local", discoveredTarget).defaultRunner).toBe(expectedRunner); + const saved = await resolveNewAgentRunnerForCompany({} as never, "company", { adapterType: "codex_local", defaultEnvironmentId: "selected" }); + expect(saved.adapterType).toBe(expectedAdapter); + }); + it("preserves recorded explicit native execution without rechecking creation defaults", async () => { + const recorded = { adapterType: "paperclip_runner", adapterConfig: { provider: "codex", model: "gpt-5.6-sol" }, runner: "paperclip" as const }; + expect(await resolveNewAgentRunnerForCompany({} as never, "company", recorded)).toEqual({ adapterType: recorded.adapterType, adapterConfig: recorded.adapterConfig }); + expect(state.getEnvironment).not.toHaveBeenCalled(); + expect(state.ssh).not.toHaveBeenCalled(); + }); it("does not infer a sandbox image architecture from its driver", async () => { state.getEnvironment.mockResolvedValue({ id: "sandbox", driver: "sandbox", status: "active", config: { provider: "daytona" } }); const result = await resolveNewAgentRunnerForCompany({} as never, "company", { adapterType: "codex_local", defaultEnvironmentId: "sandbox" }); diff --git a/server/src/services/agent-runner-selection.ts b/server/src/services/agent-runner-selection.ts index ac5985f57ca..2ad24c39ac1 100644 --- a/server/src/services/agent-runner-selection.ts +++ b/server/src/services/agent-runner-selection.ts @@ -8,7 +8,7 @@ import { forbidden, unprocessable } from "../errors.js"; import { runSshCommand } from "@paperclipai/adapter-utils/ssh"; import { resolveEnvironmentDriverConfigForRuntime } from "./environment-config.js"; -type RunnerTarget = { driver: string; platform?: string; architecture?: string }; +export type RunnerTarget = { driver: string; platform?: string; architecture?: string }; export interface AgentRunnerSelection { adapterType?: string | null; adapterConfig?: Record; @@ -39,7 +39,7 @@ export function resolveNewAgentRunner(input: AgentRunnerSelection): { adapterTyp const native = adapterType === "paperclip_runner"; if (adapterType === "codex_local" && getDisabledAdapterTypes().includes(adapterType)) throw unprocessable("Codex is disabled on this instance. Enable its adapter or choose another harness.", { code: "agent_runner_unavailable" }); // Explicit saved native profiles (including historical provider omission) retain execution. - if (native && (input.runner === undefined || (harness !== "codex_local" && input.runner !== "legacy"))) return { adapterType, adapterConfig: normalizeLegacyRunnerProvider(config) }; + 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"); @@ -79,13 +79,19 @@ export function resolveNewAgentRunner(input: AgentRunnerSelection): { adapterTyp /** Use the execution environment selected by dispatch, without starting a provider. */ export async function resolveNewAgentRunnerForCompany(db: Db, companyId: string, input: AgentRunnerSelection & { defaultEnvironmentId?: string | null }) { if (agentHarnessType(input.adapterType ?? "process", input.adapterConfig) !== "codex_local" - || input.runner === "legacy" || (input.adapterType === "paperclip_runner" && input.runner === undefined)) return resolveNewAgentRunner(input); + || 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 }); +} + +/** Shared creation/discovery target selection; no provider turn or environment lease. */ +export async function resolveAgentRunnerTargetForCompany(db: Db, companyId: string, defaultEnvironmentId?: string | null): Promise { await waitForExternalAdapters(); const { instanceSettingsService } = await import("./instance-settings.js"); const { environmentService } = await import("./environments.js"); const settings = await instanceSettingsService(db).get(); const envs = environmentService(db); - const environmentId = input.defaultEnvironmentId ?? settings.defaultEnvironmentId; + const environmentId = defaultEnvironmentId ?? settings.defaultEnvironmentId; let environment = environmentId ? await envs.getById(environmentId) : null; if (settings.experimental?.enableManagedSandboxOnly && (!environment || environment.driver === "local")) { environment = await envs.findManagedSandboxEnvironment(companyId); @@ -114,5 +120,5 @@ export async function resolveNewAgentRunnerForCompany(db: Db, companyId: string, const [os = "", arch = ""] = output.trim().split(/\r?\n/); target = { driver, platform: os === "Linux" ? "linux" : os === "Darwin" ? "darwin" : "unknown", architecture: arch === "x86_64" ? "x64" : ["aarch64", "arm64"].includes(arch) ? "arm64" : arch }; } - return resolveNewAgentRunner({ ...input, target }); + return target; } diff --git a/server/src/services/built-in-agents.ts b/server/src/services/built-in-agents.ts index c719eee102c..713b98db48d 100644 --- a/server/src/services/built-in-agents.ts +++ b/server/src/services/built-in-agents.ts @@ -1761,9 +1761,17 @@ export function builtInAgentService(db: Db) { && agentHarnessType(existing.adapterType, existing.adapterConfig) === "codex_local"; const runner = resolvedInput.runner ?? (sameCodexHarness && existing.metadata?.paperclipBuiltInSetupRequired !== true ? agentRunner(existing.adapterType) : undefined); + const retainedNative = sameCodexHarness && runner === "paperclip" && existing.adapterType === "paperclip_runner"; + const resolvedAdapterConfig = { ...adapterConfig }; + if (retainedNative) { + for (const field of ["provider", "acpxAgent", "acpxMode"]) { + if (existing.adapterConfig[field] !== undefined) resolvedAdapterConfig[field] = existing.adapterConfig[field]; + } + } Object.assign(patch, runner !== undefined || existing.metadata?.paperclipBuiltInSetupRequired === true ? await resolveNewAgentRunnerForCompany(db, companyId, { - adapterType, adapterConfig, runner, + adapterType: retainedNative ? existing.adapterType : adapterType, + adapterConfig: resolvedAdapterConfig, runner, defaultEnvironmentId: existing.defaultEnvironmentId, }) : { adapterType, adapterConfig }); diff --git a/server/src/services/company-portability.ts b/server/src/services/company-portability.ts index 9c7fb0990c4..4a7adfaf621 100644 --- a/server/src/services/company-portability.ts +++ b/server/src/services/company-portability.ts @@ -5624,7 +5624,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { const desiredSkills = (manifestAgent.skills ?? []).map((skillRef) => desiredSkillRefMap.get(skillRef) ?? skillRef); const existingImportAgent = planAgent.action === "update" && planAgent.existingAgentId ? await agents.getById(planAgent.existingAgentId) : null; - const selectedAdapterType = adapterOverride?.adapterType ?? manifestAgent.adapterType; + let selectedAdapterType = adapterOverride?.adapterType ?? manifestAgent.adapterType; const selectedHarness = agentHarnessType(selectedAdapterType, baseAdapterConfig); // Config-only edits keep the package's explicit runner or an existing // agent's execution. A genuinely changed harness selects its default. @@ -5638,6 +5638,18 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { runner = agentRunner(existingImportAgent.adapterType); } } + // The picker sends harness identity for config edits. Keep a saved + // native profile, including on a source-built unqualified platform. + const retainedNative = runner === "paperclip" && selectedHarness === "codex_local" + ? [manifestAgent, existingImportAgent].find((agent) => agent?.adapterType === "paperclip_runner" + && agentHarnessType(agent.adapterType, agent.adapterConfig) === selectedHarness) + : undefined; + if (retainedNative) { + selectedAdapterType = "paperclip_runner"; + for (const field of ["provider", "acpxAgent", "acpxMode"]) { + if (retainedNative.adapterConfig[field] !== undefined) baseAdapterConfig[field] = retainedNative.adapterConfig[field]; + } + } const normalizedAdapter = await prepareImportedAgentAdapter( targetCompany.id, selectedAdapterType, diff --git a/server/src/services/plugin-managed-agents.ts b/server/src/services/plugin-managed-agents.ts index 76500f19dfe..e274b927e86 100644 --- a/server/src/services/plugin-managed-agents.ts +++ b/server/src/services/plugin-managed-agents.ts @@ -659,11 +659,20 @@ export function pluginManagedAgentService( const adapterType = await resolveManagedAdapterType(companyId, declaration); const defaults = declarationPatch(declaration, { adapterType }); // A Codex reset keeps its selected runner; other harness reset behavior is unchanged. + const sameCodexHarness = adapterType === "codex_local" && agentHarnessType(reconciled.agent.adapterType, reconciled.agent.adapterConfig) === adapterType; + const retainedNative = sameCodexHarness && reconciled.agent.adapterType === "paperclip_runner"; + const resetAdapterConfig = { ...defaults.adapterConfig }; + if (retainedNative) { + for (const field of ["provider", "acpxAgent", "acpxMode"]) { + if (reconciled.agent.adapterConfig[field] !== undefined) resetAdapterConfig[field] = reconciled.agent.adapterConfig[field]; + } + } const execution = await resolveNewAgentRunnerForCompany(db, companyId, { ...defaults, + adapterType: retainedNative ? reconciled.agent.adapterType : adapterType, + adapterConfig: resetAdapterConfig, defaultEnvironmentId: reconciled.agent.defaultEnvironmentId, - runner: adapterType === "codex_local" && agentHarnessType(reconciled.agent.adapterType, reconciled.agent.adapterConfig) === adapterType - ? agentRunner(reconciled.agent.adapterType) : undefined, + runner: sameCodexHarness ? agentRunner(reconciled.agent.adapterType) : undefined, }); // Reset content through the canonical CAS path before changing defaults. // A conflict must leave the existing adapter configuration untouched. diff --git a/tests/runner-e2e/confirmation-replies.test.ts b/tests/runner-e2e/confirmation-replies.test.ts index ff7c79b4991..d69fb0a67f4 100644 --- a/tests/runner-e2e/confirmation-replies.test.ts +++ b/tests/runner-e2e/confirmation-replies.test.ts @@ -113,11 +113,13 @@ describe("confirmation-reply independent oracle", () => { }); it.each(runnerMatrix.filter(e => e.suite.id === "confirmation-replies" && e.task.flow === "first_task"))("provisions the real onboarding fixture contract for $id", async execution => { const get = vi.fn().mockResolvedValue([{ id: "local", driver: "local" }]); + const patch = vi.fn().mockResolvedValue({ id: "company" }); const postSensitive = vi.fn().mockResolvedValue({ id: "secret" }); const credential = execution.profile.credential; - const fixtures = await provisionFirstTaskFixtures({ api: { get, postSensitive }, execution, nonce: "fixture", + const fixtures = await provisionFirstTaskFixtures({ api: { get, patch, postSensitive }, execution, nonce: "fixture", company: { id: "company", name: "Garden" }, credentials: { [credential]: "test-credential" } }); expect(get).toHaveBeenCalledExactlyOnceWith("/api/companies/company/environments?driver=local"); + expect(patch).toHaveBeenCalledExactlyOnceWith("/api/companies/company", { budgetMonthlyCents: 500 }); expect(postSensitive).toHaveBeenCalledExactlyOnceWith("/api/companies/company/secrets", expect.objectContaining({ key: credential })); expect(fixtures.agent.id).toBe(""); // The real wizard must still create the agent. expect(JSON.stringify(fixtures)).not.toContain("test-credential"); diff --git a/ui/src/api/adapters.test.ts b/ui/src/api/adapters.test.ts new file mode 100644 index 00000000000..c4b5bb4bbe2 --- /dev/null +++ b/ui/src/api/adapters.test.ts @@ -0,0 +1,24 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { adaptersApi } from "./adapters"; +import { api } from "./client"; +import { queryKeys } from "../lib/queryKeys"; + +afterEach(() => vi.restoreAllMocks()); + +describe("adapter availability discovery", () => { + it("keeps inventory unscoped and sends the selected company and target", async () => { + const get = vi.spyOn(api, "get").mockResolvedValue([]); + await adaptersApi.list(); + await adaptersApi.list({ companyId: "company-1", environmentId: "linux ssh" }); + await adaptersApi.list({ companyId: "company-2", environmentId: null }); + expect(get.mock.calls.map(([path]) => path)).toEqual([ + "/adapters", "/adapters?companyId=company-1&environmentId=linux+ssh", "/adapters?companyId=company-2", + ]); + }); + it("isolates target availability from inventory, other targets, and other companies", () => { + const selected = queryKeys.adapters.availability("company-1", "linux"); + expect(selected).not.toEqual(queryKeys.adapters.all); + expect(selected).not.toEqual(queryKeys.adapters.availability("company-1", "mac")); + expect(selected).not.toEqual(queryKeys.adapters.availability("company-2", "linux")); + }); +}); diff --git a/ui/src/api/adapters.ts b/ui/src/api/adapters.ts index ad419dbb750..89c6e358749 100644 --- a/ui/src/api/adapters.ts +++ b/ui/src/api/adapters.ts @@ -65,7 +65,14 @@ export interface AdapterInstallResult { export const adaptersApi = { /** List all registered adapters (built-in + external). */ - list: () => api.get("/adapters"), + list: (context?: { companyId: string; environmentId?: string | null }) => { + const query = new URLSearchParams(); + if (context) { + query.set("companyId", context.companyId); + if (context.environmentId) query.set("environmentId", context.environmentId); + } + return api.get(`/adapters${query.size ? `?${query}` : ""}`); + }, /** Install an external adapter from npm or a local path. */ install: (params: { packageName: string; version?: string; isLocalPath?: boolean }) => diff --git a/ui/src/components/AgentConfigForm.tsx b/ui/src/components/AgentConfigForm.tsx index eb10561c687..e2d282c31ee 100644 --- a/ui/src/components/AgentConfigForm.tsx +++ b/ui/src/components/AgentConfigForm.tsx @@ -609,7 +609,19 @@ export function AgentConfigForm(props: AgentConfigFormProps) { const effectiveConfig = isCreate ? props.values.adapterSchemaValues ?? {} : { ...config, ...overlay.adapterConfig }; const harnessType = agentHarnessType(adapterType, effectiveConfig); const isCodexHarness = harnessType === "codex_local"; - const runnerDiscovery = useQuery({ queryKey: queryKeys.adapters.all, queryFn: adaptersApi.list }); + const rawCurrentDefaultEnvironmentId = isCreate + ? props.values.defaultEnvironmentId ?? "" + : eff("identity", "defaultEnvironmentId", props.agent.defaultEnvironmentId ?? ""); + const currentDefaultEnvironmentId = useMemo(() => { + if (!rawCurrentDefaultEnvironmentId) return ""; + const selected = environments.find((environment) => environment.id === rawCurrentDefaultEnvironmentId) ?? null; + return selected?.driver === "local" ? "" : rawCurrentDefaultEnvironmentId; + }, [environments, rawCurrentDefaultEnvironmentId]); + const runnerDiscovery = useQuery({ + queryKey: queryKeys.adapters.availability(selectedCompanyId, rawCurrentDefaultEnvironmentId || null), + queryFn: () => adaptersApi.list({ companyId: selectedCompanyId!, environmentId: rawCurrentDefaultEnvironmentId || null }), + enabled: Boolean(selectedCompanyId), + }); const codexRunnerAvailability = runnerDiscovery.data?.find(item => item.type === "codex_local"); const runner: AgentRunnerChoice = isCreate ? props.values.runner ?? "auto" : overlay.runner ?? agentRunner(adapterType); const fieldsAdapterType = isCodexHarness @@ -784,14 +796,6 @@ export function AgentConfigForm(props: AgentConfigFormProps) { invalidateUserSecretDefinitions(); }; - const rawCurrentDefaultEnvironmentId = isCreate - ? val!.defaultEnvironmentId ?? "" - : eff("identity", "defaultEnvironmentId", props.agent.defaultEnvironmentId ?? ""); - const currentDefaultEnvironmentId = useMemo(() => { - if (!rawCurrentDefaultEnvironmentId) return ""; - const selected = environments.find((environment) => environment.id === rawCurrentDefaultEnvironmentId) ?? null; - return selected?.driver === "local" ? "" : rawCurrentDefaultEnvironmentId; - }, [environments, rawCurrentDefaultEnvironmentId]); const currentDefaultEnvironment = useMemo( () => environments.find((environment) => environment.id === currentDefaultEnvironmentId) ?? null, [currentDefaultEnvironmentId, environments], @@ -1149,7 +1153,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) { resetTestEnvironmentRef.current(); setTestActionError(null); clearClaudeLoginClaimRef.current(); - }, [adapterType, runner, effectiveLoginEnvironmentId]); + }, [adapterType, runner, effectiveLoginEnvironmentId, runner === "auto" ? codexRunnerAvailability?.defaultRunner : undefined]); // Show the login affordance only for a current sandbox adapter that declares a // login capability, and whose most recent Test result carries the canonical @@ -1889,6 +1893,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) { >
{isCodexHarness && { void runnerDiscovery.refetch(); }} defaultRunner={codexRunnerAvailability?.defaultRunner} supportedRunners={codexRunnerAvailability?.supportedRunners} onChange={next => isCreate ? set!({ runner: next }) : setOverlay(prev => ({ ...prev, runner: next }))} />} diff --git a/ui/src/components/CodexRunnerSelect.tsx b/ui/src/components/CodexRunnerSelect.tsx index 08e452a5368..f1954f81ee2 100644 --- a/ui/src/components/CodexRunnerSelect.tsx +++ b/ui/src/components/CodexRunnerSelect.tsx @@ -1,33 +1,45 @@ import type { AgentRunner, AgentRunnerChoice } from "@paperclipai/shared"; import { Field } from "./agent-config-primitives"; import { SelectPopover } from "./ui/select"; +import { Button } from "./ui/button"; /** The override is request-level. Automatic selection is resolved by the server. */ export function CodexRunnerSelect({ value, onChange, - defaultRunner = "legacy", + defaultRunner, supportedRunners = ["legacy"], disabled, + pending, + error, + onRetry, }: { value: AgentRunnerChoice; onChange: (value: AgentRunnerChoice) => void; defaultRunner?: AgentRunner; supportedRunners?: readonly AgentRunner[]; disabled?: boolean; + pending?: boolean; + error?: string; + onRetry?: () => void; }) { return ( onChange(next as AgentRunnerChoice)} options={[ { value: "paperclip", label: `Paperclip Runner${defaultRunner === "paperclip" ? " (default)" : ""}`, disabled: !supportedRunners.includes("paperclip") && value !== "paperclip" }, { value: "legacy", label: `Legacy runner${defaultRunner === "legacy" ? " (default)" : ""}`, disabled: !supportedRunners.includes("legacy") && value !== "legacy" }, ]} /> + {error &&
+

{error}

+ {onRetry && } +
}
); } diff --git a/ui/src/components/ConfigureBuiltInAgentModal.tsx b/ui/src/components/ConfigureBuiltInAgentModal.tsx index 3722b103384..f041764189d 100644 --- a/ui/src/components/ConfigureBuiltInAgentModal.tsx +++ b/ui/src/components/ConfigureBuiltInAgentModal.tsx @@ -71,7 +71,8 @@ export function ConfigureBuiltInAgentModal({ () => state.agent && agentHarnessType(state.agent.adapterType, state.agent.adapterConfig) === "codex_local" ? "codex_local" : state.agent?.adapterType ?? defaultAdapterType(state), ); const [runner, setRunner] = useState(); - const adapters = useQuery({ queryKey: queryKeys.adapters.all, queryFn: adaptersApi.list, enabled: open }); + const environmentId = state.agent?.defaultEnvironmentId ?? null; + const adapters = useQuery({ queryKey: queryKeys.adapters.availability(companyId, environmentId), queryFn: () => adaptersApi.list({ companyId, environmentId }), enabled: open }); const codexAvailability = adapters.data?.find(item => item.type === "codex_local"); const [model, setModel] = useState(() => { const config = state.agent?.adapterConfig; @@ -185,6 +186,7 @@ export function ConfigureBuiltInAgentModal({ {adapterType === "codex_local" &&
Advanced { void adapters.refetch(); }} defaultRunner={codexAvailability?.defaultRunner} supportedRunners={codexAvailability?.supportedRunners} onChange={setRunner} />
} diff --git a/ui/src/components/OnboardingWizard.tsx b/ui/src/components/OnboardingWizard.tsx index 0998082037a..f4d5646dbad 100644 --- a/ui/src/components/OnboardingWizard.tsx +++ b/ui/src/components/OnboardingWizard.tsx @@ -601,7 +601,6 @@ function OnboardingWizardInner({ ); const [runner, setRunner] = useState(() => saved?.runner === "legacy" || saved?.runner === "paperclip" ? saved.runner : saved?.adapterType === "paperclip_runner" && (saved?.runnerProvider === undefined || saved?.runnerProvider === "codex") ? "paperclip" : "auto"); - const runnerDiscovery = useQuery({ queryKey: queryKeys.adapters.all, queryFn: adaptersApi.list, enabled: effectiveOnboardingOpen }); /** * Whether a model source has been chosen, as opposed to which one * `adapterType` happens to hold. @@ -986,6 +985,11 @@ function OnboardingWizardInner({ loginEnvironmentList, experimentalSettingsForLogin?.enableManagedSandboxOnly, ]); + const runnerDiscovery = useQuery({ + queryKey: queryKeys.adapters.availability(createdCompanyId, resolvedLoginEnvironmentId), + queryFn: () => adaptersApi.list({ companyId: createdCompanyId!, environmentId: resolvedLoginEnvironmentId }), + enabled: Boolean(createdCompanyId) && effectiveOnboardingOpen && step === 4, + }); const resolvedLoginEnvironment = useMemo( () => loginEnvironmentList.find((environment) => environment.id === resolvedLoginEnvironmentId) ?? @@ -1546,7 +1550,7 @@ function OnboardingWizardInner({ setAdapterEnvResult(null); adapterEnvResultAppliedStoredLoginRef.current = false; setAdapterEnvError(null); - }, [step, adapterType, runner, model, command, args, url, credentialMode, apiKey, selectedSavedKey, selectedApiKey?.id, savedSubscription?.id]); + }, [step, adapterType, runner, model, command, args, url, credentialMode, apiKey, selectedSavedKey, selectedApiKey?.id, savedSubscription?.id, createdCompanyId, resolvedLoginEnvironmentId, runner === "auto" ? runnerDiscovery.data?.find(item => item.type === "codex_local")?.defaultRunner : undefined]); /** * Leaving the step puts the row back to a question. @@ -2907,6 +2911,7 @@ function OnboardingWizardInner({ {sourceSelected && adapterType === "codex_local" &&
Advanced { void runnerDiscovery.refetch(); }} defaultRunner={runnerDiscovery.data?.find(item => item.type === "codex_local")?.defaultRunner} supportedRunners={runnerDiscovery.data?.find(item => item.type === "codex_local")?.supportedRunners} onChange={setRunner} /> diff --git a/ui/src/components/new-agent/AgentBasicsDialog.tsx b/ui/src/components/new-agent/AgentBasicsDialog.tsx index b3d24556882..d90bed04ea8 100644 --- a/ui/src/components/new-agent/AgentBasicsDialog.tsx +++ b/ui/src/components/new-agent/AgentBasicsDialog.tsx @@ -65,7 +65,7 @@ export function AgentBasicsDialog({ error, } = useQuery({ queryKey: queryKeys.adapters.all, - queryFn: adaptersApi.list, + queryFn: () => adaptersApi.list(), enabled: open, }); const choices = (adapters ?? []).filter( diff --git a/ui/src/components/new-agent/NewAgentSetup.tsx b/ui/src/components/new-agent/NewAgentSetup.tsx index d7af40755e7..4a539faf603 100644 --- a/ui/src/components/new-agent/NewAgentSetup.tsx +++ b/ui/src/components/new-agent/NewAgentSetup.tsx @@ -204,9 +204,9 @@ function Setup({ resetTest(); setScreen("connect"); }; - const adapters = useQuery({ + const adapterInventory = useQuery({ queryKey: queryKeys.adapters.all, - queryFn: adaptersApi.list, + queryFn: () => adaptersApi.list(), }); const agents = useQuery({ queryKey: queryKeys.agents.list(companyId), @@ -279,6 +279,14 @@ function Setup({ : "Could not resolve the environment."; } const environment = envs.data?.find((env) => env.id === environmentId); + const adapters = useQuery({ + queryKey: queryKeys.adapters.availability(companyId, environmentId), + queryFn: () => adaptersApi.list({ companyId, environmentId }), + }); + const defaultRunner = adapters.data?.find(adapter => adapter.type === "codex_local")?.defaultRunner; + useEffect(() => { + resetTest(); + }, [companyId, environmentId, runner === "auto" ? defaultRunner : undefined]); const sandboxProvider = typeof environment?.config?.provider === "string" ? environment.config.provider @@ -321,7 +329,7 @@ function Setup({ openAiDotEnabled: experimental.data?.enableOpenAiDot === true, runnerProvider, }) && - adapters.data?.some( + adapterInventory.data?.some( (adapter) => adapter.type === adapterType && adapter.loaded && @@ -330,6 +338,7 @@ function Setup({ ); const ready = Boolean( available && + (adapterType !== "codex_local" || runner !== "auto" || Boolean(adapters.data)) && !environmentError && !envs.isPending && !settings.isPending && @@ -646,6 +655,7 @@ function Setup({ : "Local machine"; const setupError = adapters.error ?? + adapterInventory.error ?? envs.error ?? settings.error ?? experimental.error ?? @@ -685,7 +695,7 @@ function Setup({ {setupError.message}

)} - {adapters.data && !available && ( + {adapterInventory.data && !available && (

This adapter is unavailable. Choose an enabled adapter.

@@ -800,6 +810,7 @@ function Setup({ {adapterType === "codex_local" &&
Advanced { void adapters.refetch(); }} defaultRunner={adapters.data?.find(item => item.type === "codex_local")?.defaultRunner} supportedRunners={adapters.data?.find(item => item.type === "codex_local")?.supportedRunners} onChange={value => { setRunner(value); resetTest(); }} /> @@ -1150,6 +1161,7 @@ function Setup({ {adapterType === "codex_local" &&
Advanced { void adapters.refetch(); }} defaultRunner={adapters.data?.find(item => item.type === "codex_local")?.defaultRunner} supportedRunners={adapters.data?.find(item => item.type === "codex_local")?.supportedRunners} onChange={value => { setRunner(value); resetTest(); }} /> diff --git a/ui/src/lib/queryKeys.ts b/ui/src/lib/queryKeys.ts index 81b1ac98387..3fcd28b9e68 100644 --- a/ui/src/lib/queryKeys.ts +++ b/ui/src/lib/queryKeys.ts @@ -743,5 +743,7 @@ export const queryKeys = { }, adapters: { all: ["adapters"] as const, + availability: (companyId: string | null, environmentId?: string | null) => + ["adapters", "availability", companyId, environmentId ?? null] as const, }, }; diff --git a/ui/src/pages/NewAgent.test.tsx b/ui/src/pages/NewAgent.test.tsx index c4d7dafd59c..4700f4a422d 100644 --- a/ui/src/pages/NewAgent.test.tsx +++ b/ui/src/pages/NewAgent.test.tsx @@ -49,9 +49,8 @@ vi.mock("@/api/agents", () => ({ agentsApi: api })); vi.mock("@/api/environments", () => ({ environmentsApi: envApi })); vi.mock("@/api/instanceSettings", () => ({ instanceSettingsApi: settings })); vi.mock("@/api/secrets", () => ({ secretsApi: secrets })); -vi.mock("@/api/adapters", () => ({ - adaptersApi: { list: async () => state.adapters }, -})); +const adapterApi = vi.hoisted(() => ({ list: vi.fn() })); +vi.mock("@/api/adapters", () => ({ adaptersApi: adapterApi })); vi.mock("@/context/CompanyContext", () => ({ useCompany: () => ({ selectedCompanyId: "company-1" }), })); @@ -157,6 +156,7 @@ const pass = { }; beforeEach(() => { vi.clearAllMocks(); + adapterApi.list.mockImplementation(async () => state.adapters); managedApi.list.mockResolvedValue({ currentUserId: "user-1", canManageConnections: true, connections: [] }); container = document.createElement("div"); document.body.append(container); @@ -200,6 +200,54 @@ afterEach(async () => { container.remove(); }); describe("New agent setup", () => { + it("discovers the runner for the selected target and rechecks after changing environments", async () => { + settings.get.mockResolvedValue({ defaultEnvironmentId: "linux-ssh" }); + envApi.list.mockResolvedValue([ + { id: "local-1", name: "Mac", driver: "local", status: "active", config: {} }, + { id: "linux-ssh", name: "Linux", driver: "ssh", status: "active", config: {} }, + ]); + adapterApi.list.mockImplementation(async (context?: { companyId: string; environmentId?: string | null }) => state.adapters.map((adapter: any) => + adapter.type === "codex_local" ? { ...adapter, supportedRunners: ["paperclip", "legacy"], defaultRunner: context?.environmentId === "linux-ssh" ? "paperclip" : "legacy" } : adapter)); + await render("codex_local"); + await act(async () => (container.querySelector("summary") as HTMLElement).click()); + expect(container.querySelector('[aria-label="Runner"]')?.textContent).toBe("Paperclip Runner (default)"); + expect(adapterApi.list).toHaveBeenCalledWith({ companyId: "company-1", environmentId: "linux-ssh" }); + await connect("OpenAI"); + expect(api.testEnvironment.mock.calls[0][2].environmentId).toBe("linux-ssh"); + const environment = container.querySelector('select[aria-label="Environment"]')!; + await act(async () => { + environment.value = "local-1"; + environment.dispatchEvent(new Event("change", { bubbles: true })); + }); + await settle(); + await act(async () => (container.querySelector("summary") as HTMLElement).click()); + expect(container.querySelector('[aria-label="Runner"]')?.textContent).toBe("Legacy runner (default)"); + expect(adapterApi.list).toHaveBeenCalledWith({ companyId: "company-1", environmentId: "local-1" }); + await connect("OpenAI"); + await click("Finish setup"); + expect(api.testEnvironment).toHaveBeenCalledTimes(2); + expect(api.testEnvironment).toHaveBeenLastCalledWith("company-1", "codex_local", expect.objectContaining({ environmentId: "local-1" })); + expect(api.hire.mock.calls[0][1]).not.toHaveProperty("runner"); + }); + it("surfaces failed target discovery without guessing a default and permits explicit legacy recovery", async () => { + adapterApi.list.mockImplementation(async (context?: object) => { + if (context) throw new Error("SSH target is unavailable. Check its connection."); + return state.adapters; + }); + await render("codex_local"); + await settle(); + await act(async () => (container.querySelector("summary") as HTMLElement).click()); + expect(container.querySelector('[aria-label="Runner"]')?.textContent).toBe("Automatic selection unavailable"); + expect(container.textContent).toContain("SSH target is unavailable. Check its connection."); + expect(container.textContent).not.toContain("Legacy runner (default)"); + expect(container.textContent).toContain("Retry runner discovery"); + await act(async () => container.querySelector('[aria-label="Runner"]')!.click()); + await act(async () => [...document.querySelectorAll('[role="option"]')].find(option => option.textContent === "Legacy runner")!.click()); + await connect("OpenAI"); + await click("Finish setup"); + expect(api.testEnvironment.mock.calls[0][2]).toMatchObject({ runner: "legacy" }); + expect(api.hire.mock.calls[0][1]).toMatchObject({ runner: "legacy" }); + }); it("uses automatic Codex resolution through setup and hire without the experimental gate", async () => { settings.getExperimental.mockResolvedValue({ enableNativeRunner: false }); await render("codex_local");