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");