fix(claude): preserve Legacy limits after automatic runner resolution

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-12 18:13:12 -05:00
1 parent 1285cb77f6
commit 75239d8cd7
8 files changed
+101 -7

No files matched your search

@@ -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.
+2
View File
@@ -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. */
@@ -37,6 +37,20 @@ function makeValues(overrides: Partial<CreateConfigValues> = {}): 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" }));
@@ -43,8 +43,14 @@ export function buildClaudeLocalConfig(v: CreateConfigValues): Record<string, un
ac.graceSec = 15;
const env = buildAdapterEnvConfig(v.envBindings, v.envVars);
if (Object.keys(env).length > 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 = {
@@ -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<HTMLElement>('[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");
+6
View File
@@ -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";
+1
View File
@@ -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
+3 -2
View File
@@ -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;