mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-04 19:28:46 +01:00
fix(claude): preserve Legacy limits after automatic runner resolution
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
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.
|
||||
@@ -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");
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user