diff --git a/doc/plans/2026-10-06-default-agent-runner.md b/doc/plans/2026-10-06-default-agent-runner.md index aa7eb148e61..65583ee507a 100644 --- a/doc/plans/2026-10-06-default-agent-runner.md +++ b/doc/plans/2026-10-06-default-agent-runner.md @@ -2,6 +2,38 @@ Updated 2026-10-12. +## Corrective source checkpoint: ready for fresh CI and review + +Runtime prerequisite source is `cf19e086ae4a0142c8b4314771c55f73b294805b` +on `codex/runner-native-platform-prerequisites`, with 50 changed files. The +Codex-default branch `codex/codex-runner-default` is composed on it and retains +99 changed files. Both reviews remain under 100 files. All four original review +findings and the relevant independent-review follow-ups are fixed. + +Existing controls pass: release workflow 70, assembly 7, registration 15, +measured environment/probe routes 32, UI permission/forms 207, and selection/import +140. Counts describe separate runs and can overlap. Canonical server, runner and +UI no-emit checks and token gates pass. Source-matching independent reviews +confirm cleanup, immutable-image binding and saved Legacy permission handling. +No local Docker/Rust build, new provider turn, or agent migration was used. + +Remaining gates are fresh exact-head full CI/review and managed Codex acceptance. +The existing managed eval catalog already requires OCI digests. Its image-backed +fixture helper must use the existing explicit environment probe before automatic +Codex creation and inspect the stored default; explicit-runner coverage remains +separate. First-task onboarding fixtures are local-only, so fresh managed +onboarding still requires live proof. The pinned fleet tenant currently uses a +name-only snapshot and cannot establish native-default compatibility. Use a +qualified immutable image-backed QA environment, preserve the tenant pin, and +verify the serving commit and independently checked task/follow-up artifacts. + +Merge the prerequisite after its applicable checks pass. Then reconcile the +Codex PR with actual master, verify its final source, and build the immutable +preview and DB/shared pair remotely once. All three cancelled previews remain +excluded. The original budget and cleanup holds remain unchanged. Claude owns +its separate bounded proof and downstream attestation reconciliation. No merge +or production deployment has occurred at this checkpoint. + ## Final review checkpoint: shared fixes and sandbox qualification The user authorizes normal sequential merges after the applicable gates pass. diff --git a/packages/adapter-utils/src/types.ts b/packages/adapter-utils/src/types.ts index 2633d62fc9e..0dd5c682536 100644 --- a/packages/adapter-utils/src/types.ts +++ b/packages/adapter-utils/src/types.ts @@ -727,6 +727,8 @@ export interface CreateConfigValues { runner?: "auto" | "paperclip" | "legacy"; adapterType: string; codexPermissionMode?: "never" | "on-request" | "untrusted"; + /** Explicit Legacy permission choice; never persisted as a runner setting. */ + codexLegacySandboxBypass?: boolean; paperclipRunnerLifecycleMode?: "per_turn" | "warm"; paperclipRunnerIdleTimeoutMs?: number; cwd: string; diff --git a/server/src/services/agent-runner-selection.test.ts b/server/src/services/agent-runner-selection.test.ts index a8ab7bdfdcc..197ebad4ef0 100644 --- a/server/src/services/agent-runner-selection.test.ts +++ b/server/src/services/agent-runner-selection.test.ts @@ -1,10 +1,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { agentHarnessType, agentRunner, paperclipRunnerProfileForHarness, paperclipRunnerSupportsPlatform, PAPERCLIP_RUNNER_MINIMUM_GLIBC_VERSION, createAgentSchema, updateAgentSchema, testAdapterEnvironmentSchema } from "@paperclipai/shared"; -const state = vi.hoisted(() => ({ disabled: [] as string[], overridden: new Set(), settings: {} as Record, getEnvironment: vi.fn(), bindings: vi.fn(), managedEnvironment: vi.fn(), ssh: vi.fn(), resolveEnvironment: vi.fn() })); +const state = vi.hoisted(() => ({ disabled: [] as string[], overridden: new Set(), settings: {} as Record, getEnvironment: vi.fn(), bindings: vi.fn(), managedEnvironment: vi.fn(), ssh: vi.fn(), resolveEnvironment: vi.fn(), sandboxQualification: vi.fn() })); vi.mock("../adapters/registry.js", () => ({ findActiveServerAdapter: (type: string) => ({ type }), hasActiveAdapterOverride: (type: string) => state.overridden.has(type), waitForExternalAdapters: async () => {} })); vi.mock("./adapter-plugin-store.js", () => ({ getDisabledAdapterTypes: () => state.disabled })); vi.mock("./instance-settings.js", () => ({ instanceSettingsService: () => ({ get: async () => state.settings }) })); vi.mock("./environments.js", () => ({ environmentService: () => ({ getById: state.getEnvironment, listBoundCompanyIds: state.bindings, findManagedSandboxEnvironment: state.managedEnvironment, findKubernetesEnvironment: state.managedEnvironment }) })); +vi.mock("./environment-probe.js", () => ({ readSandboxPlatformQualification: state.sandboxQualification })); vi.mock("./environment-config.js", () => ({ resolveEnvironmentDriverConfigForRuntime: state.resolveEnvironment })); vi.mock("@paperclipai/adapter-utils/ssh", () => ({ runSshCommand: state.ssh })); import { AGENT_RUNNER_SSH_PLATFORM_COMMAND, agentRunnerAvailability, resolveAgentRunnerTargetForCompany, resolveNewAgentRunner, resolveNewAgentRunnerForCompany } from "./agent-runner-selection.js"; @@ -16,7 +17,7 @@ const originalArchitecture = Object.getOwnPropertyDescriptor(process, "arch")!; afterEach(() => { vi.restoreAllMocks(); Object.defineProperty(process, "platform", originalPlatform); Object.defineProperty(process, "arch", originalArchitecture); }); describe("server-owned Codex runner selection", () => { - beforeEach(() => { state.disabled = []; state.overridden.clear(); state.settings = {}; state.getEnvironment.mockReset(); state.bindings.mockReset().mockResolvedValue([]); state.managedEnvironment.mockReset(); state.ssh.mockReset().mockResolvedValue({ stdout: "Linux\nx86_64\n" }); state.resolveEnvironment.mockReset().mockResolvedValue({ driver: "ssh", config: {} }); }); + beforeEach(() => { state.disabled = []; state.overridden.clear(); state.settings = {}; state.getEnvironment.mockReset(); state.bindings.mockReset().mockResolvedValue([]); state.managedEnvironment.mockReset(); state.ssh.mockReset().mockResolvedValue({ stdout: "Linux\nx86_64\n" }); state.sandboxQualification.mockReset().mockResolvedValue(null); state.resolveEnvironment.mockReset().mockResolvedValue({ driver: "ssh", config: {} }); }); it.each(["x64", "arm64"])("keeps detected musl/unknown Linux %s legacy and preserves saved execution", architecture => { for (const libc of ["musl", "unknown"] as const) { const target = { driver: "local", platform: "linux", architecture, libc }; @@ -166,10 +167,30 @@ describe("server-owned Codex runner selection", () => { expect(state.getEnvironment).not.toHaveBeenCalled(); expect(state.ssh).not.toHaveBeenCalled(); }); + it.each([ + [{ platform: "linux", architecture: "x64", libc: "glibc", glibcVersion: "2.39" }, "paperclip_runner"], + [{ platform: "linux", architecture: "arm64", libc: "glibc", glibcVersion: "3.0" }, "paperclip_runner"], + [{ platform: "linux", architecture: "arm64", libc: "glibc", glibcVersion: "2.38" }, "codex_local"], + [{ platform: "linux", architecture: "x64", libc: "glibc" }, "codex_local"], + [{ platform: "linux", architecture: "arm64", libc: "musl" }, "codex_local"], + [{ platform: "linux", architecture: "unknown", libc: "glibc", glibcVersion: "2.39" }, "codex_local"], + ])("uses cached selected-image measurements for sandbox creation/discovery (%j)", async (facts, expected) => { + state.getEnvironment.mockResolvedValue({ id: "sandbox", driver: "sandbox", status: "active", config: { provider: "daytona" } }); + state.sandboxQualification.mockResolvedValue(facts); + const selectedTarget = await resolveAgentRunnerTargetForCompany({} as never, "company", "sandbox"); + expect(agentRunnerAvailability("codex_local", selectedTarget).defaultRunner).toBe(expected === "paperclip_runner" ? "paperclip" : "legacy"); + const result = await resolveNewAgentRunnerForCompany({} as never, "company", { adapterType: "codex_local", defaultEnvironmentId: "sandbox" }); + expect(result.adapterType).toBe(expected); + expect(state.ssh).not.toHaveBeenCalled(); + expect(state.resolveEnvironment).not.toHaveBeenCalled(); + if (expected === "codex_local") await expect(resolveNewAgentRunnerForCompany({} as never, "company", { adapterType: "codex_local", runner: "paperclip", defaultEnvironmentId: "sandbox" })).rejects.toMatchObject({ status: 422 }); + }); 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" }); - expect(result.adapterType).toBe("paperclip_runner"); expect(state.ssh).not.toHaveBeenCalled(); + expect(result.adapterType).toBe("codex_local"); expect(state.ssh).not.toHaveBeenCalled(); + expect(state.sandboxQualification).toHaveBeenCalledWith({}, expect.objectContaining({ id: "sandbox" }), "company"); + await expect(resolveNewAgentRunnerForCompany({} as never, "company", { adapterType: "codex_local", runner: "paperclip", defaultEnvironmentId: "sandbox" })).rejects.toMatchObject({ status: 422, details: { code: "agent_runner_unavailable" } }); }); }); diff --git a/server/src/services/agent-runner-selection.ts b/server/src/services/agent-runner-selection.ts index f2963c16188..86d5226bc3c 100644 --- a/server/src/services/agent-runner-selection.ts +++ b/server/src/services/agent-runner-selection.ts @@ -25,6 +25,8 @@ export function agentRunnerAvailability(harness: string, target: RunnerTarget = && !!findActiveServerAdapter(harness) && !!findActiveServerAdapter("paperclip_runner") && !hasActiveAdapterOverride(harness) && !hasActiveAdapterOverride("paperclip_runner") && isEnvironmentDriverSupportedForAdapter("paperclip_runner", target.driver) + && (target.driver !== "sandbox" || Boolean(target.platform && target.architecture + && (target.platform !== "linux" || (target.libc === "glibc" && target.glibcVersion)))) && (!target.platform || paperclipRunnerSupportsPlatform(harness, target.platform, target.architecture ?? "", target.libc, target.glibcVersion)); return { supportedRunners: supported ? ["paperclip", "legacy"] : ["legacy"], defaultRunner: supported ? "paperclip" : "legacy" }; } @@ -55,7 +57,9 @@ export function resolveNewAgentRunner(input: AgentRunnerSelection): { adapterTyp return { adapterType: "codex_local", adapterConfig: { ...config, dangerouslyBypassApprovalsAndSandbox: config.dangerouslyBypassApprovalsAndSandbox ?? true } }; } if (native && harness !== "codex_local" && choice === "paperclip") return { adapterType, adapterConfig: normalizeLegacyRunnerProvider(config) }; - if (availability.defaultRunner !== "paperclip") throw unprocessable("Paperclip Runner is unavailable for this harness and environment. Select Legacy runner in Advanced.", { code: "agent_runner_unavailable" }); + if (availability.defaultRunner !== "paperclip") throw unprocessable(input.target?.driver === "sandbox" + ? "Paperclip Runner is unavailable for this sandbox. Choose an immutable OCI image digest and run its environment connection probe to qualify the OS, architecture and libc, or select Legacy runner in Advanced." + : "Paperclip Runner is unavailable for this harness and environment. Select Legacy runner in Advanced.", { code: "agent_runner_unavailable" }); if (native) return { adapterType, adapterConfig: normalizeLegacyRunnerProvider(config) }; const unsupported = ["agentCommand", "stateDir", "warmHandleIdleMs", "extraArgs", "args", "search", "fastMode", "variant", "filesystemScope", "filesystemExtraPaths", "filesystemSandboxCommand", "networkScope", "networkAllowlist", "permissionMode", "maxTurns", "disableWebSearch", "outputInactivityTimeoutMs", "terminalResultCleanupGraceMs"] .filter(key => config[key] !== undefined && config[key] !== false && config[key] !== "" && config[key] !== 0 && !(Array.isArray(config[key]) && config[key].length === 0)); @@ -112,7 +116,12 @@ export async function resolveAgentRunnerTargetForCompany(db: Db, companyId: stri } const driver = environment?.driver ?? "local"; let target: RunnerTarget = driver === "local" ? localAgentRunnerTarget() - : { driver }; // Sandbox platform/artifact identity is verified by selected-runtime setup; do not guess an image's architecture. + : { driver }; + if (driver === "sandbox" && environment) { + const { readSandboxPlatformQualification } = await import("./environment-probe.js"); + const qualified = await readSandboxPlatformQualification(db, environment, companyId); + if (qualified) target = { driver, ...qualified }; + } if (driver === "ssh" && environment && agentRunnerAvailability("codex_local", target).defaultRunner === "paperclip") { const parsed = await resolveEnvironmentDriverConfigForRuntime(db, companyId, environment); if (parsed.driver !== "ssh") throw unprocessable("The selected SSH environment is unavailable."); diff --git a/ui/src/adapters/codex-local/config-fields.test.tsx b/ui/src/adapters/codex-local/config-fields.test.tsx index 14ff83245b4..addc2b47b00 100644 --- a/ui/src/adapters/codex-local/config-fields.test.tsx +++ b/ui/src/adapters/codex-local/config-fields.test.tsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom import { createRoot } from "react-dom/client"; -import { act } from "react"; +import { act, useState } from "react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { ReactNode } from "react"; @@ -9,6 +9,9 @@ import { beforeAll, describe, expect, it } from "vitest"; import { TooltipProvider } from "@/components/ui/tooltip"; import { CodexLocalConfigFields } from "./config-fields"; +import { buildCodexRunnerConfig } from "./config-fields"; +import { defaultCreateValues } from "@/components/agent-config-defaults"; +import { resolveCodexLocalCliPolicy } from "@paperclipai/adapter-codex-local"; beforeAll(() => { Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); }); @@ -51,6 +54,47 @@ async function renderRunner(config: Record, expand?: string, in } describe("Paperclip Runner Codex configuration", () => { + it.each(["", "--sandbox,read-only"])("shows actual automatic Legacy policy and preserves the explicit toggle (%s)", async (extraArgs) => { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + let saved: typeof defaultCreateValues = { ...defaultCreateValues, adapterType: "codex_local", runner: "auto", extraArgs }; + function Fixture() { + const [values, setValues] = useState(saved); + saved = values; + return setValues(current => ({ ...current, ...patch }))} config={{}} + eff={(_group, _field, original) => original} mark={() => undefined} models={[]} hideInstructionsFile />; + } + try { + await act(async () => root.render()); + const toggle = container.querySelector('[aria-label="Bypass sandbox"]'); + expect(toggle).toBeDefined(); + expect(toggle?.getAttribute("aria-checked")).toBe(String(resolveCodexLocalCliPolicy(buildCodexRunnerConfig(saved)).bypass)); + await act(async () => toggle!.click()); + const config = buildCodexRunnerConfig(saved); + expect(saved.runner).toBe("auto"); + expect(config.dangerouslyBypassApprovalsAndSandbox).toBe(extraArgs !== ""); + expect(toggle?.getAttribute("aria-checked")).toBe(String(resolveCodexLocalCliPolicy(config).bypass)); + } finally { + await act(async () => root.unmount()); + container.remove(); + } + }); + it.each([ + {}, + { dangerouslyBypassApprovalsAndSandbox: false, dangerouslyBypassSandbox: true }, + { dangerouslyBypassSandbox: false }, + { extraArgs: ["--sandbox", "read-only"] }, + ])("shows the actual saved Legacy CLI policy while editing %j", async config => { + await renderMarkup( original} mark={() => undefined} models={[]} hideInstructionsFile /> + , undefined, container => { + expect(container.querySelector('[aria-label="Bypass sandbox"]')?.getAttribute("aria-checked")) + .toBe(String(resolveCodexLocalCliPolicy(config).bypass)); + }); + }); it("edits the legacy execution engine through the shared keyboard picker", async () => { const container = document.createElement("div"); document.body.appendChild(container); diff --git a/ui/src/adapters/codex-local/config-fields.tsx b/ui/src/adapters/codex-local/config-fields.tsx index 2880d293cfc..d330d0f9903 100644 --- a/ui/src/adapters/codex-local/config-fields.tsx +++ b/ui/src/adapters/codex-local/config-fields.tsx @@ -3,6 +3,8 @@ import { AdapterMark } from "../../components/AdapterMark"; import { DotRunnerConnection } from "../../components/DotRunnerConnection"; import { configFieldsForSection } from "../config-sections"; import type { AdapterConfigFieldsProps } from "../types"; +import type { CreateConfigValues } from "@paperclipai/adapter-utils"; +import { buildCodexLocalConfig } from "@paperclipai/adapter-codex-local/ui"; import { Field, ToggleField, @@ -17,6 +19,7 @@ import { CODEX_LOCAL_FAST_MODE_SUPPORTED_MODELS, isCodexLocalFastModeSupported, isCodexLocalManualModel, + resolveCodexLocalCliPolicy, } from "@paperclipai/adapter-codex-local"; import { PAPERCLIP_RUNNER_IDLE_TIMEOUT_DEFAULT_MS, @@ -47,6 +50,23 @@ const runnerHarnessOptions = [ { value: "grok", label: "Grok Build", adapter: "grok_local" }, ]; +/** Keep explicit Legacy policy even when the server resolves an automatic choice. */ +export function buildCodexRunnerConfig(values: CreateConfigValues): Record { + const config = buildCodexLocalConfig(values); + if (values.codexLegacySandboxBypass !== undefined) { + config.dangerouslyBypassApprovalsAndSandbox = values.codexLegacySandboxBypass; + } else if (values.runner !== "legacy") { + // An untouched false form default is not an explicit native permission policy. + delete config.dangerouslyBypassApprovalsAndSandbox; + } + return { + ...config, + ...(values.codexPermissionMode !== undefined ? { codexPermissionMode: values.codexPermissionMode } : {}), + ...(values.paperclipRunnerLifecycleMode !== undefined ? { lifecycleMode: values.paperclipRunnerLifecycleMode } : {}), + ...(values.paperclipRunnerIdleTimeoutMs !== undefined ? { idleTimeoutMs: values.paperclipRunnerIdleTimeoutMs } : {}), + }; +} + export function CodexLocalConfigFields({ section, companyId, @@ -149,9 +169,13 @@ export function CodexLocalConfigFields({ const engine = rawEngine === "acp" || rawEngine === "cli" ? rawEngine : "auto"; const acpSelected = engine === "acp"; - const bypassEnabled = - config.dangerouslyBypassApprovalsAndSandbox === true || - config.dangerouslyBypassSandbox === true; + const bypassEnabled = resolveCodexLocalCliPolicy({ + ...config, + dangerouslyBypassApprovalsAndSandbox: eff("adapterConfig", "dangerouslyBypassApprovalsAndSandbox", config.dangerouslyBypassApprovalsAndSandbox), + dangerouslyBypassSandbox: eff("adapterConfig", "dangerouslyBypassSandbox", config.dangerouslyBypassSandbox), + extraArgs: eff("adapterConfig", "extraArgs", config.extraArgs), + args: eff("adapterConfig", "args", config.args), + }).bypass; const fastModeEnabled = isCreate ? Boolean(values!.fastMode) : eff("adapterConfig", "fastMode", Boolean(config.fastMode)); @@ -753,16 +777,12 @@ export function CodexLocalConfigFields({ hint={help.dangerouslyBypassSandbox} checked={ isCreate - ? values!.dangerouslyBypassSandbox - : eff( - "adapterConfig", - "dangerouslyBypassApprovalsAndSandbox", - bypassEnabled, - ) + ? resolveCodexLocalCliPolicy(buildCodexRunnerConfig(values!)).bypass + : bypassEnabled } onChange={(v) => isCreate - ? set!({ dangerouslyBypassSandbox: v }) + ? set!({ dangerouslyBypassSandbox: v, codexLegacySandboxBypass: v }) : mark( "adapterConfig", "dangerouslyBypassApprovalsAndSandbox", diff --git a/ui/src/adapters/codex-local/index.ts b/ui/src/adapters/codex-local/index.ts index 29e4d15e7d9..52a86ec41f8 100644 --- a/ui/src/adapters/codex-local/index.ts +++ b/ui/src/adapters/codex-local/index.ts @@ -1,22 +1,11 @@ import type { UIAdapterModule } from "../types"; import { parseCodexStdoutLine } from "@paperclipai/adapter-codex-local/ui"; -import { CodexLocalConfigFields } from "./config-fields"; -import { buildCodexLocalConfig } from "@paperclipai/adapter-codex-local/ui"; +import { CodexLocalConfigFields, buildCodexRunnerConfig } from "./config-fields"; export const codexLocalUIAdapter: UIAdapterModule = { type: "codex_local", label: "Codex", parseStdoutLine: parseCodexStdoutLine, ConfigFields: CodexLocalConfigFields, - buildAdapterConfig: (values) => { - const config = buildCodexLocalConfig(values); - // Legacy permission flags cannot describe the native provider policy. - if (values.runner !== "legacy") delete config.dangerouslyBypassApprovalsAndSandbox; - return { - ...config, - ...(values.codexPermissionMode !== undefined ? { codexPermissionMode: values.codexPermissionMode } : {}), - ...(values.paperclipRunnerLifecycleMode !== undefined ? { lifecycleMode: values.paperclipRunnerLifecycleMode } : {}), - ...(values.paperclipRunnerIdleTimeoutMs !== undefined ? { idleTimeoutMs: values.paperclipRunnerIdleTimeoutMs } : {}), - }; - }, + buildAdapterConfig: buildCodexRunnerConfig, }; diff --git a/ui/src/components/AgentConfigForm.render.test.tsx b/ui/src/components/AgentConfigForm.render.test.tsx index 0a7c3410ddd..e2b0212ce87 100644 --- a/ui/src/components/AgentConfigForm.render.test.tsx +++ b/ui/src/components/AgentConfigForm.render.test.tsx @@ -660,6 +660,39 @@ async function flushUntil(check: () => boolean, timeoutMs = 4000) { describe("AgentConfigForm environment selector", () => { let roots: Root[] = []; + it("keeps an explicit Codex draft policy until a harness reset clears it", async () => { + mockEnvironmentsApi.list.mockResolvedValue([]); + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } }); + let current = { ...defaultCreateValues, adapterType: "codex_local", runner: "auto" as const, + dangerouslyBypassSandbox: false, codexLegacySandboxBypass: false }; + function Fixture() { + const [values, setValues] = useState(current); + current = values as typeof current; + return setValues(prev => ({ ...prev, ...patch }))} showAdapterTypeField hidePromptTemplate />; + } + try { + await act(async () => root.render( + + )); + await flushReact(); + expect(current.codexLegacySandboxBypass).toBe(false); + for (const harness of ["claude_local", "codex_local"]) { + await clickElement(container.querySelector('[aria-label="Harness"]')); + await clickElement(document.querySelector(`[role="option"][data-value="${harness}"]`)); + await flushReact(); + expect(current.adapterType).toBe(harness); + expect(current).toHaveProperty("codexLegacySandboxBypass", undefined); + } + } finally { + await act(async () => root.unmount()); + container.remove(); + } + }); + it.each(["codex_local", "paperclip_runner"])("keeps a saved %s runner during unrelated edits", async adapterType => { const adapterConfig = { model: "gpt-5.6-sol", ...(adapterType === "paperclip_runner" ? { provider: "codex", reasoningEffort: "high" } : { modelReasoningEffort: "high" }) }; const result = await renderForm([], { adapterType, adapterConfig }, { showAdapterTypeField: true }); diff --git a/ui/src/components/agent-config-defaults.ts b/ui/src/components/agent-config-defaults.ts index 118494fd90b..cf844320805 100644 --- a/ui/src/components/agent-config-defaults.ts +++ b/ui/src/components/agent-config-defaults.ts @@ -12,6 +12,7 @@ export const defaultCreateValues: CreateConfigValues = { search: false, fastMode: false, dangerouslyBypassSandbox: false, + codexLegacySandboxBypass: undefined, command: "", args: "", extraArgs: "", diff --git a/ui/src/lib/new-agent-hire-payload.test.ts b/ui/src/lib/new-agent-hire-payload.test.ts index ca5a1786d7b..bdf15906d52 100644 --- a/ui/src/lib/new-agent-hire-payload.test.ts +++ b/ui/src/lib/new-agent-hire-payload.test.ts @@ -3,8 +3,17 @@ import { describe, expect, it } from "vitest"; import { buildNewAgentHirePayload } from "./new-agent-hire-payload"; import { defaultCreateValues } from "../components/agent-config-defaults"; import { codexLocalUIAdapter } from "../adapters/codex-local"; +import { resolveCodexLocalCliPolicy } from "@paperclipai/adapter-codex-local"; describe("buildNewAgentHirePayload", () => { + it.each([false, true])("preserves explicit automatic Legacy sandbox choice %s through the actual CLI policy", (bypass) => { + const values = { ...defaultCreateValues, adapterType: "codex_local", runner: "auto" as const, dangerouslyBypassSandbox: bypass, codexLegacySandboxBypass: bypass }; + const payload = buildNewAgentHirePayload({ name: "Codex", effectiveRole: "general", configValues: values, adapterConfig: codexLocalUIAdapter.buildAdapterConfig(values) }); + expect(payload.runner).toBe("auto"); + expect(payload.adapterConfig.dangerouslyBypassApprovalsAndSandbox).toBe(bypass); + expect(resolveCodexLocalCliPolicy(payload.adapterConfig).bypass).toBe(bypass); + expect(payload.adapterConfig).not.toHaveProperty("codexLegacySandboxBypass"); + }); it.each([undefined, "auto", "paperclip", "legacy"] as const)("keeps runner %s separate from Codex execution settings", (runner) => { const values = { ...defaultCreateValues, adapterType: "codex_local", runner, model: "gpt-5.6-sol", thinkingEffort: "high" }; const payload = buildNewAgentHirePayload({ name: "Codex", effectiveRole: "general", configValues: values, adapterConfig: codexLocalUIAdapter.buildAdapterConfig(values) }); diff --git a/ui/src/pages/CompanyImport.test.tsx b/ui/src/pages/CompanyImport.test.tsx index 8ac4808f7a3..dd6dd55db56 100644 --- a/ui/src/pages/CompanyImport.test.tsx +++ b/ui/src/pages/CompanyImport.test.tsx @@ -1269,6 +1269,38 @@ describe("CompanyImport", () => { expect(preview.manifest.agents[0]!.adapterConfig).toEqual(savedSourceConfig); }); + it.each([ + { dangerouslyBypassApprovalsAndSandbox: false }, + { dangerouslyBypassSandbox: false }, + { dangerouslyBypassApprovalsAndSandbox: false, dangerouslyBypassSandbox: true }, + ])("keeps saved restrictive Legacy policy during an automatic import model edit (%j)", async policy => { + mockOptionalCompany.mockReturnValue({ selectedCompanyId: "company-1" }); + mockAdaptersApi.list.mockResolvedValue([ + { type: "codex_local", disabled: false, supportedRunners: ["legacy"], defaultRunner: "legacy" }, + ]); + const preview = buildPreviewResult(); + preview.manifest.agents[0]!.adapterType = "codex_local"; + preview.manifest.agents[0]!.adapterConfig = { model: "gpt-5.6-sol", ...policy }; + mockCompaniesApi.importPreview.mockResolvedValue(preview); + await renderPage(); + await enterGithubUrl(); + await clickButton(text => text === "Preview import"); + await clickButton(text => text.includes("configure adapter")); + await clickButton(text => text.includes("Advanced")); + expect(container.querySelector('[aria-label="Bypass sandbox"]')?.getAttribute("aria-checked")).toBe("false"); + await act(async () => container.querySelector('[aria-label="Model"]')!.click()); + const defaultModel = [...document.querySelectorAll('[role="option"]')].find(option => option.textContent?.trim() === "Default"); + expect(defaultModel).toBeTruthy(); + await act(async () => defaultModel!.click()); + await clickButton(text => text.startsWith("Import 3 file")); + await settle(); + expect(lastImportMeta().adapterOverrides?.coder).toMatchObject({ + adapterType: "codex_local", adapterConfig: { dangerouslyBypassApprovalsAndSandbox: false }, + }); + expect(lastImportMeta().adapterOverrides?.coder?.adapterConfig).not.toHaveProperty("model"); + expect(lastImportMeta().adapterOverrides?.coder?.adapterConfig).not.toHaveProperty("codexLegacySandboxBypass"); + }); + it("does not reinterpret an unknown native provider as Codex when its configuration is edited", async () => { mockAdaptersApi.list.mockResolvedValue([{ type: "paperclip_runner", disabled: false }]); const preview = buildPreviewResult(); diff --git a/ui/src/pages/CompanyImport.tsx b/ui/src/pages/CompanyImport.tsx index 089af848b6b..e37627fd13c 100644 --- a/ui/src/pages/CompanyImport.tsx +++ b/ui/src/pages/CompanyImport.tsx @@ -585,6 +585,7 @@ function importAdapterValues( ...(sourceConfig && choice === "codex_local" ? { model: typeof sourceConfig.model === "string" ? sourceConfig.model : "", thinkingEffort: String(sourceConfig.reasoningEffort ?? sourceConfig.modelReasoningEffort ?? ""), + ...(typeof savedBypass === "boolean" ? { codexLegacySandboxBypass: savedBypass } : {}), // Runner-only imports preserve server-translated policy; display that policy too. dangerouslyBypassSandbox: typeof savedBypass === "boolean" ? savedBypass : nativeCodex