diff --git a/doc/plans/2026-10-12-claude-runner-default.md b/doc/plans/2026-10-12-claude-runner-default.md index 5f835109121..94da9af7ff6 100644 --- a/doc/plans/2026-10-12-claude-runner-default.md +++ b/doc/plans/2026-10-12-claude-runner-default.md @@ -148,3 +148,45 @@ The existing successful preview/data and all unknown-charge holds remain. No additional paid turns occurred during this review/packaging work; aggregate active company caps remain $42 of the $50 Claude ceiling. Remaining live checks use API keys per the user's steering. No Docker or Rust build ran locally. + +## Installed API-key acceptance and latest review corrections + +The installed 8d07b409 public package preview is now usable at +[CLAA-2](http://127.0.0.1:3105/CLAA/issues/CLAA-2). Runs +`11155946-5239-4127-9c67-ff23acf961c2` and +`5f0e4191-9dd6-44ef-bc0a-0b66a910ec2c` succeeded using the saved API-key +connection, Opus 5.5, admitted high effort, and normal installed daemon +resolution with no runner override or ambient Claude credential. Accounting +completed: estimates $0.197602000 and $0.136576400. Known estimates now total +$1.331719; unknown-charge reservations and aggregate $42 caps remain intact. + +The controller restarted between turns. Task, provider and backend session +identities matched privately; IDs are redacted from the sanitized report +`/private/tmp/claude-installed-api-acceptance.json`. Agent settings and the +completed task persisted after reload with the native experiment disabled. +Independent downloads matched v1 (168 bytes, SHA256 +`858f0438f048937dcd2e357a77f77e058582968215d33d5857637cd37fcfdb94`) and +v2 (222 bytes, SHA256 +`4efd87a1f3a9d946d4278906bb779c4e3c0c83ec92faafdc29b9d6da28cada6d`); +v1 remained unchanged. [v1 download](http://127.0.0.1:3105/api/attachments/4fa30ccf-f6bd-47a5-83f3-564c1b16a138/content?download=1), +[v2 download](http://127.0.0.1:3105/api/attachments/af0f2c2e-a0f1-4138-980e-f93f5b48d2cf/content?download=1). +Installed daemon SHA256: +`36bbf4d878bfaa23e2fcec443e117cf04f46006ff8815c64855491b7bd3e440f`; +sidecar SHA256: +`ca6cad1f9b2fb2822d940d281a383360076c2ac03ea2ac875e64662f4719ae13`. +This source predates subsequent review fixes; final package qualification and +subscription follow-up are not claimed. + +Latest behavior corrections preserve plain environment model bindings, reject +unresolved default/model-routing bindings, retain native translation when an +import explicitly switches to Legacy, and clear every effort alias on an +incompatible model change. Selection/import/form controls: 271 passed; server +noemit and token gates passed. Current historical instruction admission: 6 +passed. Platform `33077bed73` pins producer Node to the image's 24.21.0 version; +its narrow policy exception and workflow controls passed (71 tests). + +[Four-target campaign](https://github.com/paperclipai/paperclip/actions/runs/38293566808) +and [cloud image build](https://github.com/paperclipai/paperclip/actions/runs/38293576503) +retain source `557c3064a30779e86e1ae312eab3d1d42c3a8b8d`. Actual pack/image +identity agreement remains required. Additional Claude defaults remain gated +pending clean-install and execution evidence. No local Docker or Rust build. diff --git a/server/src/services/agent-runner-selection.test.ts b/server/src/services/agent-runner-selection.test.ts index ccdc916b0c3..28532d0ce16 100644 --- a/server/src/services/agent-runner-selection.test.ts +++ b/server/src/services/agent-runner-selection.test.ts @@ -219,6 +219,21 @@ describe("Claude runner defaults", () => { expect(resolveNewAgentRunner(legacy)).toEqual({ adapterType: legacy.adapterType, adapterConfig: legacy.adapterConfig }); expect(resolveNewAgentRunner({ adapterType: "claude_local", target: mac, adapterConfig: { model: "custom" } }).adapterConfig.model).toBe("custom"); }); + it.each(["claude-sonnet-5-5", "custom-model"])("preserves the plain environment model binding %s", model => { + const env = { ANTHROPIC_MODEL: { type: "plain", value: model } }; + const native = resolveNewAgentRunner({ adapterType: "claude_local", target: mac, adapterConfig: { env } }); + expect(native.adapterConfig.model).toBe(model); + expect(native.adapterConfig.env).toEqual(env); + }); + it.each(["ANTHROPIC_MODEL", "CLAUDE_CODE_USE_BEDROCK", "CLAUDE_CODE_USE_VERTEX", "ANTHROPIC_BEDROCK_BASE_URL"])("rejects unresolved %s before choosing a default", key => { + const env = { [key]: { type: "secret_ref", secretId: "override" } }; + expect(() => resolveNewAgentRunner({ adapterType: "claude_local", target: mac, adapterConfig: { env } })).toThrow(`env.${key}`); + expect(resolveNewAgentRunner({ adapterType: "claude_local", target: mac, adapterConfig: { env, model: "custom-model" } }).adapterConfig.model).toBe("custom-model"); + }); + it("does not pin the ordinary model over a plain provider-routing binding", () => { + expect(() => resolveNewAgentRunner({ adapterType: "claude_local", target: mac, + adapterConfig: { env: { CLAUDE_CODE_USE_BEDROCK: { type: "plain", value: "1" } } } })).toThrow("Legacy runner"); + }); it.each(["permissionMode", "acpPermissionMode"])("translates the legacy Claude %s without granting broader permissions", key => { for (const mode of ["deny-all", "approve-reads", "approve-all"]) { const native = resolveNewAgentRunner({ adapterType: "claude_local", target: mac, adapterConfig: { engine: "acp", [key]: mode } }); diff --git a/server/src/services/agent-runner-selection.ts b/server/src/services/agent-runner-selection.ts index f20dae2ce52..ea63be7bd39 100644 --- a/server/src/services/agent-runner-selection.ts +++ b/server/src/services/agent-runner-selection.ts @@ -59,7 +59,17 @@ function resolveClaudeRunnerConfig(config: Record): Record : {}; - const model = resolveClaudeModel(config.model, env); + const modelEnv = { ...env }; + if (!(typeof config.model === "string" && config.model.trim())) { + for (const key of ["ANTHROPIC_MODEL", "CLAUDE_CODE_USE_BEDROCK", "CLAUDE_CODE_USE_VERTEX", "ANTHROPIC_BEDROCK_BASE_URL"]) { + const binding = env[key]; + if (binding === undefined || typeof binding === "string") continue; + if (binding && typeof binding === "object" && "type" in binding && binding.type === "plain" + && "value" in binding && typeof binding.value === "string") modelEnv[key] = binding.value; + else incompatible([`env.${key}`]); + } + } + const model = resolveClaudeModel(config.model, modelEnv); if (!model) incompatible(["model"]); return { ...translated, ...(effort === undefined || effort === "" ? {} : { modelReasoningEffort: effort }), provider: "acpx", acpxAgent: "claude", model, acpxPermissionMode: translated.acpxPermissionMode ?? "approve-all", lifecycleMode: config.lifecycleMode ?? "per_turn" }; } diff --git a/ui/src/components/AgentConfigForm.render.test.tsx b/ui/src/components/AgentConfigForm.render.test.tsx index b651b8e5fac..b611decd309 100644 --- a/ui/src/components/AgentConfigForm.render.test.tsx +++ b/ui/src/components/AgentConfigForm.render.test.tsx @@ -1006,6 +1006,53 @@ describe("AgentConfigForm environment selector", () => { }); }); + it.each(["modelReasoningEffort", "reasoningEffort", "effort"])("clears native Claude %s when the model does not support it", async key => { + mockAgentsApi.adapterModels.mockResolvedValue([ + { id: "claude-opus-5-5", label: "claude-opus-5-5" }, + { id: "claude-haiku-4-5", label: "claude-haiku-4-5" }, + ]); + const result = await renderForm( + [makeEnvironment({ id: "local-1", name: "Local", driver: "local" })], + { + adapterType: "paperclip_runner", + adapterConfig: { + provider: "acpx", acpxAgent: "claude", + model: "claude-opus-5-5", + [key]: "max", + }, + }, + ); + roots.push(result.root); + + const modelButton = Array.from(result.container.querySelectorAll("button")) + .find((button) => button.textContent?.trim() === "claude-opus-5-5"); + expect(modelButton).not.toBeUndefined(); + await act(async () => { + modelButton!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flushReact(); + + const astraOption = Array.from(document.body.querySelectorAll("button")) + .find((button) => button.textContent?.trim() === "claude-haiku-4-5"); + expect(astraOption).not.toBeUndefined(); + await act(async () => { + astraOption!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flushReact(); + + const saveButton = Array.from(result.container.querySelectorAll("button")) + .find((button) => button.textContent?.trim() === "Save"); + expect(saveButton).not.toBeUndefined(); + await act(async () => { + saveButton!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(result.onSave).toHaveBeenCalledWith({ + adapterConfig: { provider: "acpx", acpxAgent: "claude", model: "claude-haiku-4-5" }, + replaceAdapterConfig: true, + }); + }); + it("names the Claude default for new and existing agents without pinning it", async () => { const environments = [makeEnvironment({ id: "local-1", name: "Local", driver: "local" })]; const existing = await renderForm(environments, { adapterType: "claude_local", adapterConfig: {} }); diff --git a/ui/src/components/AgentConfigForm.tsx b/ui/src/components/AgentConfigForm.tsx index 9e119f608bb..b2b9c3f87a5 100644 --- a/ui/src/components/AgentConfigForm.tsx +++ b/ui/src/components/AgentConfigForm.tsx @@ -1776,8 +1776,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) { } mark("adapterConfig", "model", connectionModels?.resolveModel(v) || v || undefined); if (clearUnsupportedEffort) { - mark("adapterConfig", thinkingEffortKey, undefined); - mark("adapterConfig", "reasoningEffort", undefined); + for (const key of ["modelReasoningEffort", "reasoningEffort", "effort"]) mark("adapterConfig", key, undefined); } }} open={modelOpen} diff --git a/ui/src/pages/CompanyImport.test.tsx b/ui/src/pages/CompanyImport.test.tsx index 87eb5bb5e9a..551659373c6 100644 --- a/ui/src/pages/CompanyImport.test.tsx +++ b/ui/src/pages/CompanyImport.test.tsx @@ -1265,7 +1265,7 @@ describe("CompanyImport", () => { } await clickButton(text => text.startsWith("Import 3 file")); await settle(); - expect(lastImportMeta().adapterOverrides).toEqual({ coder: { adapterType: "codex_local", runner } }); + expect(lastImportMeta().adapterOverrides).toEqual({ coder: { adapterType: sourceType === "paperclip_runner" ? "paperclip_runner" : "codex_local", runner } }); expect(preview.manifest.agents[0]!.adapterConfig).toEqual(savedSourceConfig); }); @@ -1302,6 +1302,32 @@ describe("CompanyImport", () => { expect(preview.manifest.agents[0]!.adapterConfig).toEqual(source); }); + it("keeps native Claude configuration at the server translation boundary when switching an import to Legacy", async () => { + mockOptionalCompany.mockReturnValue({ selectedCompanyId: "company-1" }); + mockAdaptersApi.list.mockResolvedValue([ + { type: "claude_local", disabled: false, supportedRunners: ["paperclip", "legacy"], defaultRunner: "paperclip" }, + { type: "paperclip_runner", disabled: false }, + ]); + const preview = buildPreviewResult(); + const source = { provider: "acpx", acpxAgent: "claude", model: "claude-opus-5-5", modelReasoningEffort: "high", acpxPermissionMode: "approve-all", timeoutSec: 180, lifecycleMode: "per_turn" }; + preview.manifest.agents[0]!.adapterType = "paperclip_runner"; + preview.manifest.agents[0]!.runner = "paperclip"; + preview.manifest.agents[0]!.adapterConfig = source; + 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")); + await act(async () => container.querySelector('[aria-label="Runner"]')!.click()); + await act(async () => document.querySelector('[role="option"][data-value="legacy"]')!.click()); + await flushReact(); + await clickButton(text => text.startsWith("Import 3 file")); + await settle(); + expect(lastImportMeta().adapterOverrides?.coder).toMatchObject({ adapterType: "paperclip_runner", runner: "legacy" }); + expect(preview.manifest.agents[0]!.adapterConfig).toEqual(source); + }); + 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 526ec210963..87eca0ab42f 100644 --- a/ui/src/pages/CompanyImport.tsx +++ b/ui/src/pages/CompanyImport.tsx @@ -1724,7 +1724,9 @@ export function CompanyImport() { const selectedType = effectiveAdapterType(agent); const configVals = adapterConfigValues[agent.slug]; if (selectedType === agent.adapterChoice && !configVals) continue; - const apiType = selectedType === "openai_dot" ? "paperclip_runner" : selectedType; + const retainsNativeHarness = agent.adapterType === "paperclip_runner" + && selectedType === agent.adapterChoice && ["codex_local", "claude_local"].includes(selectedType); + const apiType = selectedType === "openai_dot" || retainsNativeHarness ? "paperclip_runner" : selectedType; const override: CompanyPortabilityAdapterOverride = { adapterType: apiType }; if (configVals?.runner !== undefined) override.runner = configVals.runner; if (configVals && (selectedType !== agent.adapterChoice || adapterConfigEditedSlugs.has(agent.slug))) { @@ -1735,8 +1737,6 @@ export function CompanyImport() { if (selectedType === "openai_dot" && configVals.adapterSchemaValues?.allowUnmeteredProvider !== true) { throw new Error("OpenAI Dot requires acknowledgement of external billing. Configure the Dot adapter and enable Allow externally billed provider."); } - const retainsNativeHarness = agent.adapterType === "paperclip_runner" - && selectedType === agent.adapterChoice && ["codex_local", "claude_local"].includes(selectedType); override.adapterConfig = getUIAdapter(retainsNativeHarness ? "paperclip_runner" : configVals.adapterType).buildAdapterConfig(configVals); } overrides[agent.slug] = override;