diff --git a/doc/plans/2026-10-12-claude-runner-default.md b/doc/plans/2026-10-12-claude-runner-default.md index c541e32d988..44d8fb956f2 100644 --- a/doc/plans/2026-10-12-claude-runner-default.md +++ b/doc/plans/2026-10-12-claude-runner-default.md @@ -446,3 +446,36 @@ full cloud execution must validate them. Custom ACP Stop/recovery browser fixtur explicitly select Legacy for their custom commands/state paths; latest cloud browser execution remains required. No provider turns or local native/Docker builds were added by these corrections. + + +## Lifetime ownership and resolved Legacy limit — 2026-10-12 + +The fresh protocol review identified a real ownership violation: sandbox +preparation rewrote active Claude settings before a contender acquired the +provider lifetime lease. A fake-provider/real-lease regression reproduced losing +the active owner's saved high effort and marker when the second opener was +rejected. Repair `4cdb5ec4b38d792b432993a9a4b722090813a8ef` defers the +Claude settings write until after lifetime ownership is acquired and before +provider launch. Cancellation retains the lease until that filesystem write +settles. A later legitimate owner can initialize normally. Host, sandbox and +profile controls pass 104/104; runner noemit and unchanged declaration generation +pass. No provider process was launched by these controls. + +Fresh behavior review also found the Legacy form's default 1000-turn cap was +omitted for Auto even when the selected target resolved to Legacy. The form now +passes a transient resolution to the builder; unknown/Legacy Auto and explicit +Legacy retain the cap, while resolved native omits only that Legacy default. +Explicit custom limits still reach compatibility validation. The hint is absent +from execution config and API overrides. Onboarding uses its existing discovery; +import excludes hint-only updates from meaningful edits. The same builder carries +explicit native permission choices from the ordinary create form. Mounted forms, +builder, imports and onboarding pass 298/298; adapter-utils TS build, Claude/UI +noemit, maps and token gates pass. No local Rust or Docker build or new paid turn. + +The prior corrective platform source `a97a681f1caf90627505097cdcd368356811ffc1` +passed all required cloud checks and fresh 5/5 review. Behavior and protocol +required CI aggregates passed before these two findings. New repair heads require +fresh exact-head CI/review. Prerequisite #16028 is not merged: normal auto-merge +is queued for actual code-owner approval. Dependent branches have not been +retargeted as if master contains that prerequisite. Final packaged runtime and +source reconciliation remain gated; the source owner retains merge ownership. diff --git a/packages/adapter-utils/src/types.ts b/packages/adapter-utils/src/types.ts index 0dd5c682536..bad24a8ba1d 100644 --- a/packages/adapter-utils/src/types.ts +++ b/packages/adapter-utils/src/types.ts @@ -725,6 +725,8 @@ export interface CLIAdapterModule { export interface CreateConfigValues { runner?: "auto" | "paperclip" | "legacy"; + /** Transient form resolution; never an execution setting or API override. */ + resolvedRunner?: "paperclip" | "legacy"; adapterType: string; codexPermissionMode?: "never" | "on-request" | "untrusted"; /** Explicit Legacy permission choice; never persisted as a runner setting. */ diff --git a/packages/adapters/claude-local/src/ui/build-config.test.ts b/packages/adapters/claude-local/src/ui/build-config.test.ts index 30eb04c9d71..591970e6777 100644 --- a/packages/adapters/claude-local/src/ui/build-config.test.ts +++ b/packages/adapters/claude-local/src/ui/build-config.test.ts @@ -37,6 +37,20 @@ function makeValues(overrides: Partial = {}): CreateConfigVa } describe("buildClaudeLocalConfig", () => { + it.each([undefined, "legacy"] as const)("retains the default turn cap when Auto has resolved to %s", resolvedRunner => { + expect(buildClaudeLocalConfig(makeValues({ runner: "auto", resolvedRunner })).maxTurnsPerRun).toBe(1000); + }); + it.each(["auto", "paperclip"] as const)("omits the Legacy default only for native %s", runner => { + const config = buildClaudeLocalConfig(makeValues({ runner, resolvedRunner: "paperclip" })); + expect(config).not.toHaveProperty("maxTurnsPerRun"); + expect(buildClaudeLocalConfig(makeValues({ runner, resolvedRunner: "paperclip", maxTurnsPerRun: 3 })).maxTurnsPerRun).toBe(3); + }); + it("retains an explicit Legacy cap over a stale native form resolution", () => { + expect(buildClaudeLocalConfig(makeValues({ runner: "legacy", resolvedRunner: "paperclip" })).maxTurnsPerRun).toBe(1000); + }); + it.each(["approve-all", "approve-reads", "deny-all"])("carries the selected native %s permission to creation", acpxPermissionMode => { + expect(buildClaudeLocalConfig(makeValues({ resolvedRunner: "paperclip", adapterSchemaValues: { acpxPermissionMode } })).acpxPermissionMode).toBe(acpxPermissionMode); + }); it("omits engine for the auto default so runtime fallback remains available", () => { const config = buildClaudeLocalConfig(makeValues({ claudeEngine: "auto" })); diff --git a/packages/adapters/claude-local/src/ui/build-config.ts b/packages/adapters/claude-local/src/ui/build-config.ts index f7598e3e4a4..072efac710e 100644 --- a/packages/adapters/claude-local/src/ui/build-config.ts +++ b/packages/adapters/claude-local/src/ui/build-config.ts @@ -43,8 +43,14 @@ export function buildClaudeLocalConfig(v: CreateConfigValues): Record 0) ac.env = env; - // Native creation omits the legacy form default; explicit limits remain visible to validation. - if (v.runner === "legacy" || v.maxTurnsPerRun !== 1000) ac.maxTurnsPerRun = v.maxTurnsPerRun; + const nativeSelected = v.runner === "paperclip" + || ((v.runner === undefined || v.runner === "auto") && v.resolvedRunner === "paperclip"); + // Only a resolved native form omits the Legacy default. Explicit limits + // still reach the server's compatibility check. + if (!nativeSelected || v.maxTurnsPerRun !== 1000) ac.maxTurnsPerRun = v.maxTurnsPerRun; + if (nativeSelected && v.adapterSchemaValues?.acpxPermissionMode !== undefined) { + ac.acpxPermissionMode = v.adapterSchemaValues.acpxPermissionMode; + } ac.dangerouslySkipPermissions = v.dangerouslySkipPermissions; if (v.workspaceStrategyType === "git_worktree") { ac.workspaceStrategy = { diff --git a/ui/src/components/AgentConfigForm.render.test.tsx b/ui/src/components/AgentConfigForm.render.test.tsx index 91e62e6d452..b4cc8fe8d82 100644 --- a/ui/src/components/AgentConfigForm.render.test.tsx +++ b/ui/src/components/AgentConfigForm.render.test.tsx @@ -18,6 +18,7 @@ import { ApiError } from "../api/client"; import { aiConnectionsApi } from "../api/ai-connections"; import { CodexLocalConfigFields } from "../adapters/codex-local/config-fields"; import type { AdapterConfigFieldsProps } from "../adapters/types"; +import { buildClaudeLocalConfig } from "@paperclipai/adapter-claude-local/ui"; import { DEFAULT_CODEX_LOCAL_MODEL } from "@paperclipai/adapter-codex-local"; const mockAgentsApi = vi.hoisted(() => ({ @@ -73,9 +74,8 @@ vi.mock("../api/agents", () => ({ agentsApi: mockAgentsApi, })); -vi.mock("../api/adapters", () => ({ - adaptersApi: { list: async () => [{ type: "codex_local", supportedRunners: ["legacy", "paperclip"], defaultRunner: "legacy" }] }, -})); +const mockAdaptersApi = vi.hoisted(() => ({ list: vi.fn(async () => [{ type: "codex_local", supportedRunners: ["legacy", "paperclip"], defaultRunner: "legacy" }]) })); +vi.mock("../api/adapters", () => ({ adaptersApi: mockAdaptersApi })); vi.mock("../api/environments", () => ({ environmentsApi: mockEnvironmentsApi, @@ -660,6 +660,37 @@ async function flushUntil(check: () => boolean, timeoutMs = 4000) { describe("AgentConfigForm environment selector", () => { let roots: Root[] = []; + it.each(["paperclip", "legacy"] as const)("uses the resolved %s Claude form to preserve the correct turn limit", async defaultRunner => { + mockAdaptersApi.list.mockResolvedValue([{ type: "claude_local", supportedRunners: defaultRunner === "paperclip" ? ["paperclip", "legacy"] : ["legacy"], defaultRunner }]); + try { + const values = { ...defaultCreateValues, adapterType: "claude_local", runner: "auto" as const }; + const result = await renderCreateForm([], values); + roots.push(result.root); + const resolved = result.onChange.mock.calls.reduce((current, [patch]) => ({ ...current, ...patch }), values); + expect(resolved.resolvedRunner).toBe(defaultRunner); + const config = buildClaudeLocalConfig(resolved); + if (defaultRunner === "legacy") expect(config.maxTurnsPerRun).toBe(1000); + else expect(config).not.toHaveProperty("maxTurnsPerRun"); + expect(config).not.toHaveProperty("resolvedRunner"); + } finally { mockAdaptersApi.list.mockResolvedValue([{ type: "codex_local", supportedRunners: ["legacy", "paperclip"], defaultRunner: "legacy" }]); } + }); + + + it("carries a selected native Claude permission through the ordinary creation builder", async () => { + mockAdaptersApi.list.mockResolvedValue([{ type: "claude_local", supportedRunners: ["paperclip", "legacy"], defaultRunner: "paperclip" }]); + try { + const values = { ...defaultCreateValues, adapterType: "claude_local", runner: "auto" as const }; + const result = await renderCreateForm([], values); + roots.push(result.root); + await clickElement(result.container.querySelector('[aria-label="Permission mode"]')); + const deny = [...document.querySelectorAll('[role="option"]')].find(option => option.textContent === "Deny all"); + expect(deny).toBeTruthy(); + await clickElement(deny); + const resolved = result.onChange.mock.calls.reduce((current, [patch]) => ({ ...current, ...patch }), values); + expect(buildClaudeLocalConfig(resolved)).toMatchObject({ acpxPermissionMode: "deny-all" }); + } finally { mockAdaptersApi.list.mockResolvedValue([{ type: "codex_local", supportedRunners: ["legacy", "paperclip"], defaultRunner: "legacy" }]); } + }); + it("keeps an explicit Codex draft policy until a harness reset clears it", async () => { mockEnvironmentsApi.list.mockResolvedValue([]); const container = document.createElement("div"); diff --git a/ui/src/components/AgentConfigForm.tsx b/ui/src/components/AgentConfigForm.tsx index b2b9c3f87a5..f2bc5074fd4 100644 --- a/ui/src/components/AgentConfigForm.tsx +++ b/ui/src/components/AgentConfigForm.tsx @@ -628,6 +628,12 @@ export function AgentConfigForm(props: AgentConfigFormProps) { const fieldsAdapterType = isRunnerHarness ? (runner === "paperclip" || (runner === "auto" && runnerAvailability?.defaultRunner === "paperclip") ? "paperclip_runner" : harnessType) : adapterType; + useEffect(() => { + if (props.mode !== "create" || harnessType !== "claude_local") return; + const resolvedRunner = runner === "auto" ? runnerAvailability?.defaultRunner : runner; + if (props.values.resolvedRunner !== resolvedRunner) props.onChange({ resolvedRunner }); + }, [props.mode, harnessType, runner, runnerAvailability?.defaultRunner, + isCreate ? props.values.resolvedRunner : undefined, isCreate ? props.onChange : undefined]); const getCapabilities = useAdapterCapabilities(); const adapterCaps = getCapabilities(isRunnerHarness ? harnessType : adapterType); const isDotRunner = adapterType === "paperclip_runner" && (isCreate ? props.values.adapterSchemaValues?.provider : eff("adapterConfig", "provider", config.provider)) === "openai_dot"; diff --git a/ui/src/components/OnboardingWizard.tsx b/ui/src/components/OnboardingWizard.tsx index 1e6c898b68e..3c17c6752c7 100644 --- a/ui/src/components/OnboardingWizard.tsx +++ b/ui/src/components/OnboardingWizard.tsx @@ -1832,6 +1832,7 @@ function OnboardingWizardInner({ ...defaultCreateValues, adapterType, runner, + resolvedRunner: runner === "auto" ? runnerDiscovery.data?.find(item => item.type === adapterType)?.defaultRunner : runner, model: adapterType === "gemini_local" ? model || DEFAULT_GEMINI_LOCAL_MODEL diff --git a/ui/src/pages/CompanyImport.tsx b/ui/src/pages/CompanyImport.tsx index 77b925a93d6..57d1004bd55 100644 --- a/ui/src/pages/CompanyImport.tsx +++ b/ui/src/pages/CompanyImport.tsx @@ -1626,7 +1626,7 @@ export function CompanyImport() { resetMutationState(); const agent = adapterAgents.find((a) => a.slug === slug); const currentType = agent ? effectiveAdapterType(agent) : adapterOverrides[slug] ?? "claude_local"; - if (Object.keys(patch).some(key => key !== "runner")) { + if (Object.keys(patch).some(key => key !== "runner" && key !== "resolvedRunner")) { setAdapterConfigEditedSlugs(prev => new Set([...prev, slug])); } setAdapterConfigValues((prev) => ({ @@ -1736,7 +1736,8 @@ export function CompanyImport() { for (const agent of adapterAgents) { const selectedType = effectiveAdapterType(agent); const configVals = adapterConfigValues[agent.slug]; - if (selectedType === agent.adapterChoice && !configVals) continue; + if (selectedType === agent.adapterChoice && !adapterConfigEditedSlugs.has(agent.slug) + && (!configVals || configVals.runner === importAdapterValues(selectedType, agent.adapterConfig, agent.adapterType, agent.runner).runner)) continue; const retainsNativeHarness = agent.adapterType === "paperclip_runner" && selectedType === agent.adapterChoice && ["codex_local", "claude_local"].includes(selectedType); const apiType = selectedType === "openai_dot" || retainsNativeHarness ? "paperclip_runner" : selectedType;