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 <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-09 10:12:15 -05:00
1 parent aa879604ae
commit 0a82004bd0
25 files changed
+378 -58

No files matched your search

+20 -3
View File
@@ -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.
+2 -2
View File
@@ -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/<id>/companies/<companyId>/agents/<agentId>/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>' 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.
+43 -2
View File
@@ -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");
+19 -1
View File
@@ -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<typeof createDb>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | 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");
@@ -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<typeof import("../services/agent-runner-selection.js")>();
// 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<typeof actual.resolveNewAgentRunnerForCompany>) =>
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<string, unknown>) => ({ id: "agent-created", ...input }));
agentSvc.update.mockImplementation(async (id: string, input: Record<string, unknown>) => ({ 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]);
@@ -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);
@@ -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<typeof createDb>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | 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 () => {
+2
View File
@@ -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,
+23 -6
View File
@@ -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<string>): AdapterInfo {
function buildAdapterInfo(adapter: ServerAdapterModule, externalRecord: AdapterPluginRecord | undefined, disabledSet: Set<string>, 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<boolean>;
getOpenAiDotEnabled?: () => Promise<boolean>;
resolveRunnerTarget?: (companyId: string, environmentId?: string) => Promise<RunnerTarget>;
} = {}) {
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);
@@ -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" });
+11 -5
View File
@@ -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<string, unknown>;
@@ -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<RunnerTarget> {
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;
}
+9 -1
View File
@@ -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 });
+13 -1
View File
@@ -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,
+11 -2
View File
@@ -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.
@@ -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");
+24
View File
@@ -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"));
});
});
+8 -1
View File
@@ -65,7 +65,14 @@ export interface AdapterInstallResult {
export const adaptersApi = {
/** List all registered adapters (built-in + external). */
list: () => api.get<AdapterInfo[]>("/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<AdapterInfo[]>(`/adapters${query.size ? `?${query}` : ""}`);
},
/** Install an external adapter from npm or a local path. */
install: (params: { packageName: string; version?: string; isLocalPath?: boolean }) =>
+15 -10
View File
@@ -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) {
>
<div className="space-y-3">
{isCodexHarness && <CodexRunnerSelect value={runner}
pending={runnerDiscovery.isFetching} error={runnerDiscovery.error?.message} onRetry={() => { void runnerDiscovery.refetch(); }}
defaultRunner={codexRunnerAvailability?.defaultRunner}
supportedRunners={codexRunnerAvailability?.supportedRunners}
onChange={next => isCreate ? set!({ runner: next }) : setOverlay(prev => ({ ...prev, runner: next }))} />}
+14 -2
View File
@@ -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 (
<Field label="Runner">
<SelectPopover
aria-label="Runner"
disabled={disabled}
value={value === "auto" ? defaultRunner : value}
value={value === "auto" ? defaultRunner ?? "auto" : value}
displayValue={value === "auto" && !defaultRunner ? (pending ? "Checking runner availability…" : "Automatic selection unavailable") : undefined}
onValueChange={next => 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 && <div className="mt-2 space-y-2">
<p role="alert" className="text-sm text-destructive">{error}</p>
{onRetry && <Button type="button" variant="outline" size="sm" disabled={pending || disabled} onClick={onRetry}>Retry runner discovery</Button>}
</div>}
</Field>
);
}
@@ -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<AgentRunnerChoice | undefined>();
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<string>(() => {
const config = state.agent?.adapterConfig;
@@ -185,6 +186,7 @@ export function ConfigureBuiltInAgentModal({
{adapterType === "codex_local" && <details className="space-y-3">
<summary className="cursor-pointer text-sm text-muted-foreground">Advanced</summary>
<CodexRunnerSelect value={runner ?? (state.agent ? agentRunner(state.agent.adapterType) : "auto")}
pending={adapters.isFetching} error={adapters.error?.message} onRetry={() => { void adapters.refetch(); }}
defaultRunner={codexAvailability?.defaultRunner} supportedRunners={codexAvailability?.supportedRunners}
onChange={setRunner} />
</details>}
+7 -2
View File
@@ -601,7 +601,6 @@ function OnboardingWizardInner({
);
const [runner, setRunner] = useState<AgentRunnerChoice>(() => 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" && <details className="space-y-3">
<summary className="cursor-pointer text-sm text-muted-foreground">Advanced</summary>
<CodexRunnerSelect value={runner} disabled={loading || adapterEnvLoading}
pending={runnerDiscovery.isFetching} error={runnerDiscovery.error?.message} onRetry={() => { 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} />
@@ -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(
+16 -4
View File
@@ -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}
</p>
)}
{adapters.data && !available && (
{adapterInventory.data && !available && (
<p role="alert" className="text-sm text-destructive">
This adapter is unavailable. Choose an enabled adapter.
</p>
@@ -800,6 +810,7 @@ function Setup({
{adapterType === "codex_local" && <details className="mt-5 space-y-3">
<summary className="cursor-pointer text-sm text-muted-foreground">Advanced</summary>
<CodexRunnerSelect value={runner} disabled={busy}
pending={adapters.isFetching} error={adapters.error?.message} onRetry={() => { 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" && <details className="space-y-3">
<summary className="cursor-pointer text-sm text-muted-foreground">Advanced</summary>
<CodexRunnerSelect value={runner} disabled={busy}
pending={adapters.isFetching} error={adapters.error?.message} onRetry={() => { 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(); }} />
+2
View File
@@ -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,
},
};
+51 -3
View File
@@ -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<HTMLSelectElement>('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<HTMLButtonElement>('[aria-label="Runner"]')!.click());
await act(async () => [...document.querySelectorAll<HTMLButtonElement>('[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");