mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-31 22:24:16 +01:00
fix(agents): preserve legacy policy and require measured sandbox defaults
Keep explicit Legacy permission choices through automatic selection, import editing, draft reset and policy display. Use company-scoped measured immutable-image facts for automatic sandbox defaults without allocating resources during creation or discovery. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
f340d92b39
commit
65700c6908
12 files changed
+222
-29
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<string>(), settings: {} as Record<string, unknown>, 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<string>(), settings: {} as Record<string, unknown>, 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" } });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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.");
|
||||
|
||||
@@ -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<string, unknown>, 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 <CodexLocalConfigFields mode="create" isCreate adapterType="codex_local" values={values}
|
||||
set={(patch) => setValues(current => ({ ...current, ...patch }))} config={{}}
|
||||
eff={(_group, _field, original) => original} mark={() => undefined} models={[]} hideInstructionsFile />;
|
||||
}
|
||||
try {
|
||||
await act(async () => root.render(<QueryClientProvider client={new QueryClient()}><TooltipProvider><Fixture /></TooltipProvider></QueryClientProvider>));
|
||||
const toggle = container.querySelector<HTMLButtonElement>('[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(<TooltipProvider><CodexLocalConfigFields mode="edit" isCreate={false}
|
||||
adapterType="codex_local" values={null} set={null} config={config}
|
||||
eff={(_group, _field, original) => original} mark={() => undefined} models={[]} hideInstructionsFile />
|
||||
</TooltipProvider>, 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);
|
||||
|
||||
@@ -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<string, unknown> {
|
||||
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",
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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<typeof defaultCreateValues>(current);
|
||||
current = values as typeof current;
|
||||
return <AgentConfigForm mode="create" values={values}
|
||||
onChange={patch => setValues(prev => ({ ...prev, ...patch }))} showAdapterTypeField hidePromptTemplate />;
|
||||
}
|
||||
try {
|
||||
await act(async () => root.render(<QueryClientProvider client={queryClient}><ToastProvider><TooltipProvider>
|
||||
<Fixture />
|
||||
</TooltipProvider></ToastProvider></QueryClientProvider>));
|
||||
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 });
|
||||
|
||||
@@ -12,6 +12,7 @@ export const defaultCreateValues: CreateConfigValues = {
|
||||
search: false,
|
||||
fastMode: false,
|
||||
dangerouslyBypassSandbox: false,
|
||||
codexLegacySandboxBypass: undefined,
|
||||
command: "",
|
||||
args: "",
|
||||
extraArgs: "",
|
||||
|
||||
@@ -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) });
|
||||
|
||||
@@ -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<HTMLButtonElement>('[aria-label="Model"]')!.click());
|
||||
const defaultModel = [...document.querySelectorAll<HTMLButtonElement>('[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();
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user