From 87bf89756e631e2742013af380e926c602a948fa Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Sat, 24 Oct 2026 07:59:18 -0700 Subject: [PATCH] fix: support Daytona runner upgrades and ignore obsolete Claude options (#17361) ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Existing Claude and Codex agents can upgrade to Paperclip Runner. > - A Daytona computer needs platform qualification before Runner becomes its default. > - The upgrade notice required that default, so it hid the action that could start qualification. > - Saved Claude turn limits also caused conversion to fail even though Runner can run without them. > - This pull request verifies the computer on click and ignores obsolete Claude options with a warning. > - Agents retain their model, account, environment, instructions, and permission policy. ## Linked Issues or Issue Description Related: #17122. **What happened?** A legacy Claude agent on a Daytona snapshot had no upgrade button until its environment had platform qualification. A saved `maxTurnsPerRun: 1000` then caused conversion to return HTTP 422. **Expected behavior** The upgrade action verifies a supported Daytona computer and converts the agent. Unsupported tuning options produce a visible warning and do not block execution. **Steps to reproduce** 1. Create a legacy Claude agent with `maxTurnsPerRun: 1000` on an active Daytona snapshot. 2. Open Runtime before the environment has platform qualification. 3. Try to upgrade to Paperclip Runner. **Paperclip version or commit** After #17122, reproduced against `9c442cf48e`. ## What Changed - Offer upgrade on probe-capable Daytona snapshots and immutable sandbox images. Perform the public environment probe only after a click. Save after fresh adapter discovery confirms support. - Resolve the actual computer for explicit, instance-default, and hidden managed environments. Preserve pending edits and reject stale probe results. - Ignore Claude `maxTurnsPerRun`, `chrome`, `warmHandleIdleMs`, and `acpWarmHandleIdleMs`. Return structured warnings from create, hire, and update. Show warnings in Runtime and after a save. - Keep validation for commands, credentials, filesystem, network, and permission settings. - Retain the legacy turn limit in the existing live conversion fixture. Update regression tests, upgrade stories, and capability maps. ## Verification - Red: the old route returns HTTP 422 for the saved turn limit; the old form hides the Daytona upgrade notice. - Green: 417 focused server and form tests pass. A further 188 tests pass after the review fixes. Qualification coverage includes failed probes, unsupported targets, changed settings, managed defaults, unsaved environment variables, and forced Kubernetes targets. - `pnpm -r typecheck` and `pnpm build` pass. Token gates, map checks, map tests, and Storybook catalog checks pass. - Real local Claude Opus 5.5 conversion and task pass, with the downloaded artifact checked against seeded source bytes, SHA-256, and Git commit. - Real browser upgrade and full Claude Opus 5.5 task on a disposable named Daytona snapshot pass at `b81cec7d8c`. The saved agent retains its model, high effort, credentials, timeout, and grace settings while dropping the turn limit with a visible warning. Exactly one successful run completed the task. The downloaded artifact matched independently seeded source bytes, SHA-256, and Git commit; the source was unchanged, no interactions remained pending, and the delivery link survived a browser reload. The snapshot used current runtime assets built from public source. The supplied Daytona account could not access the original staging snapshot, so this verifies an equivalent local fixture rather than that exact snapshot. Disposable test resources were cleaned up. - Earlier live attempts remain recorded: an image-based sandbox creation failed, and the first named-snapshot probe returned failure with unresolved provisional cleanup identity. Later named-snapshot probing and the task succeeded; the initial creation failures are not attributed to this PR or declared resolved. A separate attempt using the Oct 18 cached runtime failed the current provider-pack compatibility check; rebuilding the runtime from matching public source removed that mismatch. Owned resources were identified and cleaned up, with diagnostic databases retained. - 54 checks passed at `b81cec7d8c`, with no failed checks; Greptile and Paperclip Review both scored 5/5, with both review threads resolved. Master advanced during the live proof, so the branch was rebased with both sides of additive import/test conflicts retained. On the rebased head `2b74222b94`, 472 focused tests, UI typecheck, and UI build pass. All required CI gates pass; Greptile and Paperclip Review both score 5/5 on this head, with no unresolved review threads. The PR is mergeable. The serial local `pnpm test:run` was stopped after the CI test lanes passed; that local run is not claimed as a pass. ## Risks - An explicit upgrade can allocate a temporary probe sandbox and take several minutes. Probe failures leave the agent on its existing runner. - Ignored options no longer enforce a turn cap, Chrome integration, or legacy idle-time tuning. The UI warns about each active ignored option. - Unsafe or ambiguous execution settings still fail validation. This change does not weaken environment qualification or runtime artifact checks. ## Model Used OpenAI Codex, GPT-6, with reasoning, code execution, and browser tools. The exact deployment model ID and context-window size are not exposed in this session. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- maps/agent-access-map/agents.md | 7 +- maps/feature-map/agents.md | 14 ++- packages/shared/src/index.ts | 1 + .../src/runner-configuration-warnings.ts | 32 +++++++ packages/shared/src/types/agent.ts | 2 + .../agent-adapter-validation-routes.test.ts | 18 ++++ server/src/routes/agents.ts | 25 +++-- .../services/agent-runner-selection.test.ts | 12 ++- server/src/services/agent-runner-selection.ts | 12 +++ .../heartbeat-runner-provider-config.test.ts | 4 +- .../native-runtime/provider-profile.ts | 3 +- tests/runner-e2e/README.md | 6 +- .../repository-conversion-setup.test.ts | 4 +- tests/runner-e2e/repository-verification.ts | 2 +- .../AgentConfigForm.render.test.tsx | 96 +++++++++++++++++++ ui/src/components/AgentConfigForm.tsx | 87 ++++++++++++----- ui/src/lib/qualify-claude-setup.ts | 31 ++++-- ui/src/pages/AgentDetail.tsx | 3 +- .../agents/codex-runner/upgrade.stories.tsx | 20 ++-- 19 files changed, 314 insertions(+), 65 deletions(-) create mode 100644 packages/shared/src/runner-configuration-warnings.ts diff --git a/maps/agent-access-map/agents.md b/maps/agent-access-map/agents.md index 1164ebdb622..8599a70e397 100644 --- a/maps/agent-access-map/agents.md +++ b/maps/agent-access-map/agents.md @@ -123,8 +123,11 @@ configuration surfaces. Board browser verification does not qualify agent access The existing-agent upgrade notice uses this configuration PATCH. It does not grant configuration, account, or environment access. The server resolves the -saved settings under normal company/configuration authorization and rejects -unsupported settings before persistence. Operator browser evidence does not +saved settings under normal company/configuration authorization. Obsolete Claude +tuning fields are removed with `configurationWarnings`; security incompatibilities +remain errors. Unqualified sandbox upgrades first use the existing +`POST /api/environments/{id}/probe?companyId=...`, retaining its instance-admin +authorization and company-scoped qualification. No probe runs merely on page load. Operator browser evidence does not qualify the agent permission paths for this operation. Evidence: `source-only` — [implementation](../../server/src/routes/agents.ts#L1395) at diff --git a/maps/feature-map/agents.md b/maps/feature-map/agents.md index 43b6f31a65f..1d0f8edc0ab 100644 --- a/maps/feature-map/agents.md +++ b/maps/feature-map/agents.md @@ -131,16 +131,20 @@ retry, and reload; the failure must disappear only after readiness commits. Provider output and credentials must not appear in the lifecycle message. An explicit runner change is a configuration change, with normal revisions and -session invalidation. Incompatible settings must produce a field-specific error; -selecting another runner must not silently discard them or change an active run's +session invalidation. Obsolete Claude tuning options (including maxTurnsPerRun) are ignored with a field-specific warning; +permission, network, filesystem and command incompatibilities still produce an error. +Selecting another runner must not silently discard security settings or change an active run's recorded execution. Keep runner choice in Advanced and verify the next eligible run separately from historical transcripts. -Existing legacy Claude and Codex agents on qualified default environments also -show **Upgrade runner** above their configuration. Exercise that action with +Existing legacy Claude and Codex agents on qualified default environments, or +probe-capable Daytona snapshots / immutable sandbox images, show **Upgrade runner** above their configuration. Exercise that action with pending edits and an environment-variable draft; reopen the page and compare the saved harness, model, account bindings, instructions, and run policy. Check a -rejected incompatible setting, repair it, and retry. Verify a real task before +saved `maxTurnsPerRun: 1000`: upgrade must warn and remove it. Test an initially +unqualified Daytona Paperclip Computer: click must probe, record qualification, +refresh availability, and only then save. A failed probe or changed selection must +leave the agent on Legacy. Check a rejected permission setting, repair it, and retry. Verify a real task before and after migration, including its output, preserved history, and fresh session. The notice must stay absent for native agents, other harnesses, and unsupported targets. Discovery failure must not imply that an upgrade is available. diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index f9f6db272b6..5fd101bbb9e 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -2818,6 +2818,7 @@ export * from "./agent-avatar-upload.js"; export type { DotBinding, DotInvitation, DotConnection, DotPairing } from "./dot-invitations.js"; export { agentHarnessType, agentRuntimeSettingsHarness, agentRunner, paperclipRunnerProfileForHarness, paperclipRunnerSupportsPlatform, PAPERCLIP_RUNNER_MINIMUM_GLIBC_VERSION, type AgentRunnerChoice, type AgentRunner, type AgentRunnerAvailability } from "./agent-runner.js"; +export { normalizeClaudeRunnerOptions, type AgentConfigurationWarning } from "./runner-configuration-warnings.js"; export { EXTERNAL_AGENT_TOOL_GUIDANCE, DOT_AGENT_TOOL_GUIDANCE, GROK_BOT_AGENT_TOOL_GUIDANCE } from "./external-agent-guidance.js"; export { AGENT_LIFECYCLE_STATES, type AgentLifecycleState, type AgentLifecycleOperation } from "./types/agent-lifecycle.js"; diff --git a/packages/shared/src/runner-configuration-warnings.ts b/packages/shared/src/runner-configuration-warnings.ts new file mode 100644 index 00000000000..81db25b1b3a --- /dev/null +++ b/packages/shared/src/runner-configuration-warnings.ts @@ -0,0 +1,32 @@ +export interface AgentConfigurationWarning { + code: "runner_option_ignored"; + field: string; + message: string; +} + +// Only obsolete tuning/features belong here. Permission, filesystem, network, +// command and credential settings must retain their existing validation. +const CLAUDE_IGNORED_OPTIONS = { + maxTurnsPerRun: "Max turns per run", + chrome: "Chrome integration", + warmHandleIdleMs: "Warm handle idle timeout", + acpWarmHandleIdleMs: "ACP warm handle idle timeout", +} as const; + +export function normalizeClaudeRunnerOptions(config: Record): { + config: Record; + warnings: AgentConfigurationWarning[]; +} { + const normalized = { ...config }; + const warnings: AgentConfigurationWarning[] = []; + for (const [field, label] of Object.entries(CLAUDE_IGNORED_OPTIONS)) { + const value = config[field]; + if (value !== undefined && value !== null && value !== false && value !== "" && value !== 0 + && !(Array.isArray(value) && value.length === 0)) { + warnings.push({ code: "runner_option_ignored", field, + message: `${label} (${field}) is not supported by Paperclip Runner and is ignored. The agent can still run.` }); + } + delete normalized[field]; + } + return { config: normalized, warnings }; +} diff --git a/packages/shared/src/types/agent.ts b/packages/shared/src/types/agent.ts index 4cac11e5a02..ac1970305b7 100644 --- a/packages/shared/src/types/agent.ts +++ b/packages/shared/src/types/agent.ts @@ -98,6 +98,8 @@ export interface Agent { capabilities: string | null; adapterType: AgentAdapterType; adapterConfig: Record; + /** Non-fatal options ignored by this configuration write; not persisted. */ + configurationWarnings?: import("../runner-configuration-warnings.js").AgentConfigurationWarning[]; runtimeConfig: AgentRuntimeConfig; defaultEnvironmentId?: string | null; budgetMonthlyCents: number; diff --git a/server/src/__tests__/agent-adapter-validation-routes.test.ts b/server/src/__tests__/agent-adapter-validation-routes.test.ts index 0c445913998..c1ad79a4cf9 100644 --- a/server/src/__tests__/agent-adapter-validation-routes.test.ts +++ b/server/src/__tests__/agent-adapter-validation-routes.test.ts @@ -1058,6 +1058,24 @@ describe("agent routes adapter validation", () => { expect(mockLogActivity).not.toHaveBeenCalled(); }); + it("upgrades a legacy Claude agent with a turn limit, warning instead of rejecting it", async () => { + const existing = await mockAgentService.getById(); + const env = { ANTHROPIC_API_KEY: { type: "plain", value: "synthetic-fixture" } }; + mockAgentService.getById.mockResolvedValue({ ...existing, adapterType: "claude_local", + adapterConfig: { model: "claude-opus-5-5", effort: "high", maxTurnsPerRun: 1000, + dangerouslySkipPermissions: true, timeoutSec: 0, graceSec: 15, env } }); + const app = await createApp(); + const res = await requestApp(app, baseUrl => request(baseUrl) + .patch(`/api/agents/${existing.id}`).send({ runner: "paperclip" })); + expect(res.status, JSON.stringify(res.body)).toBe(200); + const patch = mockAgentService.update.mock.calls[0][1]; + expect(patch.adapterType).toBe("paperclip_runner"); + expect(patch.adapterConfig).toMatchObject({ provider: "acpx", acpxAgent: "claude", model: "claude-opus-5-5", + modelReasoningEffort: "high", acpxPermissionMode: "approve-all", timeoutSec: 0, graceSec: 15, env }); + expect(patch.adapterConfig).not.toHaveProperty("maxTurnsPerRun"); + expect(res.body.configurationWarnings).toEqual([expect.objectContaining({ field: "maxTurnsPerRun", code: "runner_option_ignored" })]); + }); + it("normalizes legacy skills and permissions when switching to paperclip_runner", async () => { mockInstanceSettingsService.getExperimental.mockResolvedValue({ enableNativeRunner: true }); const existing = await mockAgentService.getById(); diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index a8408a07496..0a03946e7f2 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -1,7 +1,7 @@ import { prepareRuntimeSettingsPatch } from "../services/agent-runtime-settings.js"; import { completeSetupTokenAiConnection } from "../services/setup-token-ai-connection.js"; import { cancellationRequestId } from "../services/native-runtime/native-cancellation-request.js"; -import { agentHarnessType, isEnvironmentDriverSupportedForAdapter, type AgentRunnerChoice } from "@paperclipai/shared"; +import { agentHarnessType, isEnvironmentDriverSupportedForAdapter, normalizeClaudeRunnerOptions, type AgentConfigurationWarning, type AgentRunnerChoice } from "@paperclipai/shared"; import { resolveNewAgentRunnerForCompany } from "../services/agent-runner-selection.js"; import { agentEnvironmentTestService } from "../services/agent-environment-test.js"; import { createAgentLifecycle } from "../services/agent-lifecycle.js"; @@ -4405,7 +4405,9 @@ export function agentRoutes( hireInput.defaultEnvironmentId = caller.defaultEnvironmentId ?? null; } if (inheritRuntimeFrom === "caller" && hireRunner !== undefined) throw unprocessable("inheritRuntimeFrom=caller cannot be combined with runner"); - Object.assign(hireInput, await resolveNewAgentRunnerForCompany(db, companyId, { ...hireInput, runner: hireRunner })); + let configurationWarnings: AgentConfigurationWarning[] = []; + Object.assign(hireInput, await resolveNewAgentRunnerForCompany(db, companyId, { ...hireInput, runner: hireRunner, + onConfigurationWarnings: warnings => { configurationWarnings = warnings; } })); hireInput.adapterType = await assertSelectableAdapterType(hireInput.adapterType, hireInput.adapterConfig); const rawHireAdapterConfig = (hireInput.adapterConfig ?? {}) as Record; assertProviderTraceSettingTransition(req, hireInput.runtimeConfig); @@ -4678,7 +4680,7 @@ export function agentRoutes( }); } - return { status: 201, body: { agent, approval } }; + return { status: 201, body: { agent: { ...agent, ...(configurationWarnings.length ? { configurationWarnings } : {}) }, approval } }; }; const outcome = runId @@ -4740,7 +4742,9 @@ export function agentRoutes( assertNoAgentLocalAdapterHostCommandMutation(req, createInput.adapterType ?? "process", requestedCreateConfig, await callerUsesAiConnectionPool(req, companyId)); assertNoAgentProcessAdapterMutation(req, createInput.adapterType ?? "process", Object.keys(requestedCreateConfig).length > 0); assertExternalInstructionsAdmin(req, { id: agentId, companyId, name: createInput.name, adapterConfig: requestedCreateConfig }); - Object.assign(createInput, await resolveNewAgentRunnerForCompany(db, companyId, { ...createInput, runner: createRunner, setupOnly: Boolean(setupId) })); + let configurationWarnings: AgentConfigurationWarning[] = []; + Object.assign(createInput, await resolveNewAgentRunnerForCompany(db, companyId, { ...createInput, runner: createRunner, setupOnly: Boolean(setupId), + onConfigurationWarnings: warnings => { configurationWarnings = warnings; } })); createInput.adapterType = await assertSelectableAdapterType(createInput.adapterType, createInput.adapterConfig); const rawCreateAdapterConfig = (createInput.adapterConfig ?? {}) as Record; assertProviderTraceSettingTransition(req, createInput.runtimeConfig); @@ -4833,7 +4837,7 @@ export function agentRoutes( if (!(await assertAgentReadAllowed(req, res, createdAgent))) return; await assertCanReadConfigurations(req, companyId); } - res.status(201).json(redactAgentRowForResponse(createdAgent)); + res.status(201).json({ ...redactAgentRowForResponse(createdAgent), ...(configurationWarnings.length ? { configurationWarnings } : {}) }); return; } const onboardingFirstAgentBundle = await resolveOnboardingFirstAgentBundle({ @@ -4890,7 +4894,7 @@ export function agentRoutes( ); } - res.status(201).json(redactAgentRowForResponse(agent)); + res.status(201).json({ ...redactAgentRowForResponse(agent), ...(configurationWarnings.length ? { configurationWarnings } : {}) }); }); router.patch("/agents/:id/permissions", validate(updateAgentPermissionsSchema), async (req, res) => { @@ -5401,6 +5405,7 @@ export function agentRoutes( // it gets the selectable check; keeping the agent's current adapter (even // one since disabled) stays allowed, so a disabled harness does not make an // existing agent uneditable. + let configurationWarnings: AgentConfigurationWarning[] = []; const requestedRunnerConfig = asRecord(patchData.adapterConfig) ?? {}; const convertingPi = existing.adapterType === "pi_local" && patchData.adapterType === "paperclip_runner" && (requestedRunnerConfig.provider === undefined @@ -5411,6 +5416,7 @@ export function agentRoutes( Object.assign(patchData, await resolveNewAgentRunnerForCompany(db, existing.companyId, { adapterType: convertingPi ? "pi_local" : typeof patchData.adapterType === "string" ? patchData.adapterType : existing.adapterType, adapterConfig: config, runner: runner ?? "paperclip", setupOnly: existing.lifecycleHolds?.includes("setup"), + onConfigurationWarnings: warnings => { configurationWarnings = warnings; }, defaultEnvironmentId: hasOwn(patchData, "defaultEnvironmentId") ? patchData.defaultEnvironmentId as string | null : existing.defaultEnvironmentId, })); } @@ -5487,6 +5493,11 @@ export function agentRoutes( } if (requestedAdapterType === "paperclip_runner") { rawEffectiveAdapterConfig = normalizePaperclipRunnerAdapterConfig(requestedAdapterType, rawEffectiveAdapterConfig); + if (agentHarnessType(requestedAdapterType, rawEffectiveAdapterConfig) === "claude_local") { + const normalized = normalizeClaudeRunnerOptions(rawEffectiveAdapterConfig); + configurationWarnings.push(...normalized.warnings.filter(warning => !configurationWarnings.some(existing => existing.field === warning.field))); + rawEffectiveAdapterConfig = normalized.config; + } } if (req.actor.type === "agent" && rawEffectiveAdapterConfig.homeMode !== existingAdapterConfig.homeMode) throw forbidden("Only an operator can configure Runner homeMode."); @@ -5640,7 +5651,7 @@ export function agentRoutes( details: summarizeAgentUpdateDetails(patchData), }); - res.json(redactAgentRowForResponse(agent)); + res.json({ ...redactAgentRowForResponse(agent), ...(configurationWarnings.length ? { configurationWarnings } : {}) }); }); router.post("/agents/:id/lifecycle/retry", async (req, res) => { diff --git a/server/src/services/agent-runner-selection.test.ts b/server/src/services/agent-runner-selection.test.ts index 3edd2377540..afec21d6b45 100644 --- a/server/src/services/agent-runner-selection.test.ts +++ b/server/src/services/agent-runner-selection.test.ts @@ -616,7 +616,17 @@ describe("Claude runner defaults", () => { expect(resolveNewAgentRunner({ ...saved, runner: "legacy" })).toEqual({ adapterType: "claude_local", adapterConfig: { model: "custom", dangerouslySkipPermissions: true } }); expect(() => resolveNewAgentRunner({ ...saved, runner: "legacy", adapterConfig: { ...saved.adapterConfig, acpxPermissionMode: "deny-all" } })).toThrow("cannot honor"); }); - it.each(["chrome", "agentCommand", "maxTurnsPerRun", "networkScope"])("rejects unsupported %s without fallback", key => { + it.each(["maxTurnsPerRun", "chrome", "warmHandleIdleMs", "acpWarmHandleIdleMs"])("warns and ignores obsolete Claude %s on conversion and native saves", field => { + for (const adapterType of ["claude_local", "paperclip_runner"]) { + const onConfigurationWarnings = vi.fn(); + const result = resolveNewAgentRunner({ adapterType, target: mac, runner: "paperclip", + adapterConfig: { provider: "acpx", acpxAgent: "claude", model: "claude-opus-5-5", [field]: 1000 }, onConfigurationWarnings }); + expect(result.adapterType).toBe("paperclip_runner"); + expect(result.adapterConfig).not.toHaveProperty(field); + expect(onConfigurationWarnings).toHaveBeenCalledWith([expect.objectContaining({ field, code: "runner_option_ignored" })]); + } + }); + it.each(["agentCommand", "networkScope"])("rejects unsupported %s without fallback", key => { expect(() => resolveNewAgentRunner({ adapterType: "claude_local", target: mac, adapterConfig: { [key]: "configured" } })).toThrow("Legacy runner"); }); it.each(["low", "medium", "high", "xhigh", "max"])("translates Claude effort %s and preserves it on an explicit Legacy switch", effort => { diff --git a/server/src/services/agent-runner-selection.ts b/server/src/services/agent-runner-selection.ts index 250686d56b6..42347ccad20 100644 --- a/server/src/services/agent-runner-selection.ts +++ b/server/src/services/agent-runner-selection.ts @@ -9,6 +9,7 @@ import { forbidden, unprocessable } from "../errors.js"; import { runSshCommand } from "@paperclipai/adapter-utils/ssh"; import { parseEnvironmentDriverConfig, resolveEnvironmentDriverConfigForRuntime } from "./environment-config.js"; import { localAgentRunnerTarget } from "../lib/agent-runner-platform.js"; +import { normalizeClaudeRunnerOptions, type AgentConfigurationWarning } from "@paperclipai/shared"; export type RunnerTarget = { driver: string; platform?: string; architecture?: string; libc?: "glibc" | "musl" | "unknown"; glibcVersion?: string; @@ -23,6 +24,7 @@ export interface AgentRunnerSelection { target?: RunnerTarget; /** Server-owned inactive setup drafts may precede model selection. Never use for a hire or run. */ setupOnly?: boolean; + onConfigurationWarnings?: (warnings: AgentConfigurationWarning[]) => void; } function runnerAdaptersAvailable(harness: string, driver: string): boolean { @@ -133,6 +135,16 @@ function resolveClaudeRunnerConfig(config: Record): Record } { + const result = resolveAgentRunner(input); + if (result.adapterType === "paperclip_runner" && agentHarnessType(result.adapterType, result.adapterConfig) === "claude_local") { + const normalized = normalizeClaudeRunnerOptions(input.adapterConfig ?? {}); + result.adapterConfig = normalizeClaudeRunnerOptions(result.adapterConfig).config; + input.onConfigurationWarnings?.(normalized.warnings); + } + return result; +} + +function resolveAgentRunner(input: AgentRunnerSelection): { adapterType: string; adapterConfig: Record } { const adapterType = input.adapterType ?? "process"; let config = { ...input.adapterConfig }; const harness = agentHarnessType(adapterType, config); diff --git a/server/src/services/heartbeat-runner-provider-config.test.ts b/server/src/services/heartbeat-runner-provider-config.test.ts index 0136711a0e7..5e874641c19 100644 --- a/server/src/services/heartbeat-runner-provider-config.test.ts +++ b/server/src/services/heartbeat-runner-provider-config.test.ts @@ -140,7 +140,9 @@ describe("Paperclip Runner native provider configuration", () => { expect(resolvePaperclipRunnerNativeProviderInput({ backend: "acpx_runtime", adapterConfig: projected })).toMatchObject({ provider: "acpx", acpxAgent: "claude", claudeEffort: "xhigh", acpxPermissionMode: "deny-all" }); const changedModel = projectPaperclipRunnerTaskConfig("acpx_runtime", base, { model: "claude-sonnet-5" }); expect(changedModel).not.toHaveProperty("modelReasoningEffort"); - expect(() => resolvePaperclipRunnerNativeProviderInput({ backend: "acpx_runtime", adapterConfig: { ...base, chrome: true } })).toThrow("Legacy runner"); + expect(resolvePaperclipRunnerNativeProviderInput({ backend: "acpx_runtime", adapterConfig: { ...base, chrome: true, maxTurnsPerRun: 1000 } })) + .toEqual(resolvePaperclipRunnerNativeProviderInput({ backend: "acpx_runtime", adapterConfig: base })); + expect(() => resolvePaperclipRunnerNativeProviderInput({ backend: "acpx_runtime", adapterConfig: { ...base, networkScope: "unrestricted" } })).toThrow("Legacy runner"); }); it("uses an OpenCode task model without changing the Runner provider", () => { diff --git a/server/src/services/native-runtime/provider-profile.ts b/server/src/services/native-runtime/provider-profile.ts index 0232d00bc04..22f906c59c3 100644 --- a/server/src/services/native-runtime/provider-profile.ts +++ b/server/src/services/native-runtime/provider-profile.ts @@ -1,4 +1,4 @@ -import { agentHarnessType } from "@paperclipai/shared"; +import { agentHarnessType, normalizeClaudeRunnerOptions } from "@paperclipai/shared"; import { resolveClaudeEffort, resolveQualifiedAcpxProfile } from "../../vendor/paperclip-runner/index.js"; import { isPaperclipRunnerProvider, @@ -385,6 +385,7 @@ export function assertAgentCoreProfileRecoveryBinding(input: { } export function claudeRunnerUnsupportedSettings(config: Record): string[] { + config = normalizeClaudeRunnerOptions(config).config; const unsupported = ["chrome", "agentCommand", "acpAgentCommand", "stateDir", "acpStateDir", "warmHandleIdleMs", "acpWarmHandleIdleMs", "extraArgs", "args", "filesystemScope", "filesystemExtraPaths", "filesystemSandboxCommand", "networkScope", "networkAllowlist", "maxTurnsPerRun", "permissionMode", "acpPermissionMode"] .filter(key => config[key] !== undefined && config[key] !== false && config[key] !== "" && config[key] !== 0 && !(Array.isArray(config[key]) && config[key].length === 0)); if (config.command && config.command !== "claude") unsupported.push("command"); diff --git a/tests/runner-e2e/README.md b/tests/runner-e2e/README.md index 5f199a2fad4..94b9f55ffa1 100644 --- a/tests/runner-e2e/README.md +++ b/tests/runner-e2e/README.md @@ -71,8 +71,10 @@ hard stops. The conversion journey creates a real legacy agent and selects Paperclip Runner through the public agent update API. It checks retained configuration, encrypted secret references, environment, instructions, and corresponding permission policy before submitting work through the browser. -The disposable legacy profile starts without optional CLI-only extra arguments or turn limits; -the journey does not remove settings from an existing user agent. For either +The disposable legacy profile starts without optional CLI-only extra arguments. +Claude retains its saved `maxTurnsPerRun: 1000` to verify that conversion ignores +the obsolete option without blocking the task. The journey does not remove +settings from an existing user agent. For either provider, Daytona's public environment probe must save platform qualification before the same agent selects Runner. Probe leases and uncertain probe accounting remain included in attempt evidence and cleanup. diff --git a/tests/runner-e2e/repository-conversion-setup.test.ts b/tests/runner-e2e/repository-conversion-setup.test.ts index aad6ce2e91c..e0abd935b29 100644 --- a/tests/runner-e2e/repository-conversion-setup.test.ts +++ b/tests/runner-e2e/repository-conversion-setup.test.ts @@ -8,7 +8,7 @@ function fixtures(driver = "sandbox"): Parameters { - it("constructs compatible legacy settings before recording a retention baseline", () => { + it("retains the legacy Claude turn limit in the conversion fixture", () => { const suite = runnerSuites.find(suite => suite.id === "legacy-conversion")!; for (const profile of suite.profiles) { const agent = profile.buildAgent({ environmentId: "environment", environmentFixtureId: "local", workspacePath: "/owned/workspace", @@ -18,7 +18,7 @@ describe("real legacy conversion prerequisites", () => { expect(agent.adapterType).toBe(profile.provider === "codex" ? "codex_local" : "claude_local"); expect(agent.adapterConfig).toMatchObject({ model: profile.model, timeoutSec: 360, cwd: "/owned/workspace" }); expect(agent.adapterConfig).not.toHaveProperty("extraArgs"); - expect(agent.adapterConfig).not.toHaveProperty("maxTurnsPerRun"); + if (profile.provider !== "codex") expect(agent.adapterConfig).toHaveProperty("maxTurnsPerRun", 1000); expect((agent.adapterConfig as Record).env).toMatchObject(profile.provider === "codex" ? { OPENAI_API_KEY: { type: "secret_ref", secretId: "codex-reference", version: "latest" } } : { ANTHROPIC_API_KEY: { type: "secret_ref", secretId: "claude-reference", version: "latest" } }); diff --git a/tests/runner-e2e/repository-verification.ts b/tests/runner-e2e/repository-verification.ts index c6ef5325b1f..f5dd8f817cf 100644 --- a/tests/runner-e2e/repository-verification.ts +++ b/tests/runner-e2e/repository-verification.ts @@ -12,7 +12,7 @@ export function repositoryProofProfile(native: RunnerProfileFixture, legacy?: Ru const config = { ...(agent.adapterConfig as Record), model: native.model }; if (legacy) { delete (config as Record).extraArgs; - delete (config as Record).maxTurnsPerRun; + if (legacy.adapterType === "claude_local") (config as Record).maxTurnsPerRun = 1000; } return { ...agent, ...(legacy ? { runner: "legacy" } : {}), budgetMonthlyCents: 1000, adapterConfig: config }; } }; diff --git a/ui/src/components/AgentConfigForm.render.test.tsx b/ui/src/components/AgentConfigForm.render.test.tsx index 25ceb0e56fb..244d13e83db 100644 --- a/ui/src/components/AgentConfigForm.render.test.tsx +++ b/ui/src/components/AgentConfigForm.render.test.tsx @@ -56,6 +56,7 @@ const mockClipboard = vi.hoisted(() => ({ const mockEnvironmentsApi = vi.hoisted(() => ({ list: vi.fn(), capabilities: vi.fn(), + probe: vi.fn(), })); const mockInstanceSettingsApi = vi.hoisted(() => ({ @@ -808,6 +809,90 @@ describe("AgentConfigForm environment selector", () => { } finally { mockAdaptersApi.list.mockResolvedValue([{ type: "codex_local", supportedRunners: ["legacy", "paperclip"], defaultRunner: "legacy" }]); } }); + it.each(["explicit", "instance", "managed", "env-draft"])("qualifies a saved Daytona Claude agent and upgrades with its legacy turn limit (%s environment)", async mode => { + const environment = makeEnvironment({ id: "daytona-computer", name: "Paperclip Computer", driver: "sandbox", status: "active", + config: { provider: "daytona", snapshot: "managed-snapshot" }, metadata: { managedByPaperclip: true } }); + const inventory = (qualified: boolean) => [ + { type: "claude_local", supportedRunners: qualified ? ["paperclip", "legacy"] : ["legacy"], defaultRunner: qualified ? "paperclip" : "legacy" }, + { type: "paperclip_runner", supportedRunners: ["legacy"], defaultRunner: "legacy" }, + ]; + mockAdaptersApi.list.mockResolvedValue(inventory(false)); + mockEnvironmentsApi.probe.mockImplementation(async () => { + mockAdaptersApi.list.mockResolvedValue(inventory(true)); + return { ok: true, driver: "sandbox", details: { platformQualificationRecorded: true, leaseId: "probe-lease" } }; + }); + if (mode === "instance") mockInstanceSettingsApi.get.mockResolvedValue({ defaultEnvironmentId: environment.id }); + if (mode === "managed") mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableManagedSandboxOnly: true, enableEnvironments: false }); + const result = await renderForm([environment], { adapterType: "claude_local", defaultEnvironmentId: mode === "explicit" || mode === "env-draft" ? environment.id : null, + adapterConfig: { model: "claude-opus-5-5", effort: "high", maxTurnsPerRun: 1000, dangerouslySkipPermissions: true } }, + { environmentVariablesPlacement: "configuration" }); + roots.push(result.root); + expect(result.container.textContent).toContain("Upgrade to Paperclip Runner"); + expect(result.container.textContent).toContain("Max turns per run"); + expect(mockEnvironmentsApi.probe).not.toHaveBeenCalled(); + if (mode === "env-draft") { + await clickByText(result.container, "Add variable"); + await act(async () => { + setInputValue(result.container.querySelector('input[aria-label="Variable name"]')!, "MIGRATION_MARKER"); + setInputValue(result.container.querySelector('input[aria-label="Variable value"]')!, "kept"); + }); + await flushReact(); + } + await clickByText(result.container, "Upgrade runner"); + expect(mockEnvironmentsApi.probe).toHaveBeenCalledWith(environment.id, "company-1"); + expect(result.onSave).toHaveBeenCalledWith(mode === "env-draft" ? expect.objectContaining({ + runner: "paperclip", replaceAdapterConfig: true, + adapterConfig: expect.objectContaining({ env: { MIGRATION_MARKER: { type: "plain", value: "kept" } } }), + }) : { runner: "paperclip" }); + }); + + it.each(["probe", "unsupported", "settings-changed"])("does not save after Daytona %s", async failure => { + const environment = makeEnvironment({ id: "daytona-computer", name: "Paperclip Computer", driver: "sandbox", status: "active", + config: { provider: "daytona", snapshot: "managed-snapshot" } }); + mockAdaptersApi.list.mockResolvedValue([{ type: "claude_local", supportedRunners: ["legacy"], defaultRunner: "legacy" }, { type: "paperclip_runner", supportedRunners: ["legacy"], defaultRunner: "legacy" }]); + let finish!: () => void; + mockEnvironmentsApi.probe.mockImplementation(() => new Promise(resolve => { finish = () => resolve({ ok: failure !== "probe", + summary: "Could not verify this computer", details: { platformQualificationRecorded: failure !== "probe", leaseId: "lease" } }); })); + const result = await renderForm([environment], { adapterType: "claude_local", defaultEnvironmentId: environment.id }); + roots.push(result.root); + await clickByText(result.container, "Upgrade runner"); + if (failure === "settings-changed") { + await act(async () => setInputValue(result.container.querySelector('input[placeholder="Agent name"]')!, "Edited during probe")); + } + await act(async () => finish()); + await flushReact(); + expect(result.onSave).not.toHaveBeenCalled(); + expect(result.container.querySelector('[role="alert"]')).not.toBeNull(); + }); + + it.each([true, false])("respects forced Kubernetes instead of probing a saved Daytona default (target exists: %s)", async available => { + const daytona = makeEnvironment({ id: "old-daytona", driver: "sandbox", config: { provider: "daytona", snapshot: "old-snapshot" } }); + const kubernetes = makeEnvironment({ id: "forced-kubernetes", driver: "sandbox", config: { provider: "kubernetes", image: `example.test/runner@sha256:${"a".repeat(64)}` } }); + mockInstanceSettingsApi.getGeneral.mockResolvedValue({ executionMode: "kubernetes" }); + const inventory = (qualified: boolean) => [ + { type: "claude_local", supportedRunners: qualified ? ["paperclip", "legacy"] : ["legacy"], defaultRunner: qualified ? "paperclip" : "legacy" }, + { type: "paperclip_runner", supportedRunners: ["legacy"], defaultRunner: "legacy" }, + ]; + mockAdaptersApi.list.mockResolvedValue(inventory(false)); + mockEnvironmentsApi.probe.mockImplementation(async () => { + mockAdaptersApi.list.mockResolvedValue(inventory(true)); + return { ok: true, details: { platformQualificationRecorded: true, leaseId: "kubernetes-probe" } }; + }); + const result = await renderForm(available ? [daytona, kubernetes] : [daytona], { + adapterType: "claude_local", defaultEnvironmentId: daytona.id, + }); + roots.push(result.root); + if (available) { + await clickByText(result.container, "Upgrade runner"); + expect(mockEnvironmentsApi.probe).toHaveBeenCalledWith(kubernetes.id, "company-1"); + expect(mockAdaptersApi.list).toHaveBeenLastCalledWith({ companyId: "company-1", environmentId: kubernetes.id }); + expect(result.onSave).toHaveBeenCalledWith({ runner: "paperclip" }); + } else { + expect(findButton(result.container, "Upgrade runner")).toBeUndefined(); + expect(mockEnvironmentsApi.probe).not.toHaveBeenCalled(); + } + }); + it("does not offer an upgrade when the target only supports Legacy", async () => { mockAdaptersApi.list.mockResolvedValueOnce([{ type: "codex_local", supportedRunners: ["legacy"], defaultRunner: "legacy" }]); const result = await renderForm([]); @@ -815,6 +900,15 @@ describe("AgentConfigForm environment selector", () => { expect(result.container.textContent).not.toContain("Upgrade runner"); }); + it("explains ignored options already saved on a native Claude agent", async () => { + const result = await renderForm([], { adapterType: "paperclip_runner", adapterConfig: { + provider: "acpx", acpxAgent: "claude", model: "claude-opus-5-5", maxTurnsPerRun: 1000, + } }); + roots.push(result.root); + expect(result.container.querySelector('[role="status"]')?.textContent).toContain("maxTurnsPerRun"); + expect(result.container.textContent).toContain("is ignored. The agent can still run."); + }); + it.each(["codex", "claude"].flatMap(harness => ["modelReasoningEffort", "reasoningEffort", "effort"].map(key => ({ harness, key }))))("hydrates saved effort and clears explicit Auto for $harness ($key)", async ({ harness, key }) => { const profile = harness === "codex" ? { provider: "codex", model: "gpt-5.6-sol" } : { provider: "acpx", acpxAgent: "claude", model: "claude-opus-5-5" }; const result = await renderForm([], { adapterType: "paperclip_runner", adapterConfig: { ...profile, [key]: "high" } }); @@ -928,6 +1022,8 @@ describe("AgentConfigForm environment selector", () => { }); beforeEach(() => { + mockEnvironmentsApi.probe.mockReset(); + mockAdaptersApi.list.mockReset().mockResolvedValue([{ type: "codex_local", supportedRunners: ["legacy", "paperclip"], defaultRunner: "legacy" }]); mockAgentsApi.adapterModels.mockResolvedValue([]); mockAgentsApi.detectModel.mockResolvedValue(null); mockAgentsApi.list.mockResolvedValue([]); diff --git a/ui/src/components/AgentConfigForm.tsx b/ui/src/components/AgentConfigForm.tsx index c8d54a6789e..c8e2ceb60ca 100644 --- a/ui/src/components/AgentConfigForm.tsx +++ b/ui/src/components/AgentConfigForm.tsx @@ -1,7 +1,8 @@ import { COPILOT_MODEL_DISCOVERY_QUERY_OPTIONS } from "@/lib/copilot-task-model-catalog"; import { useConnectionModels } from "./ai-connections/useConnectionModels"; -import { agentHarnessType, agentRunner, paperclipRunnerProfileForHarness, type AgentRunnerChoice } from "@paperclipai/shared"; +import { agentHarnessType, agentRunner, paperclipRunnerProfileForHarness, normalizeClaudeRunnerOptions, type AgentRunnerChoice } from "@paperclipai/shared"; import { adaptersApi } from "../api/adapters"; +import { canQualifyRunnerEnvironment, qualifyRunnerEnvironment } from "../lib/qualify-claude-setup"; import { AgentRunnerSelect } from "./AgentRunnerSelect"; import { aiRoutingHarness } from "@paperclipai/shared"; import { AiConnectionField } from "./ai-connections/AiConnectionField"; @@ -444,7 +445,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) { // when the experimental environments picker is otherwise hidden). enabled: Boolean(selectedCompanyId) && - (environmentsEnabled || generalSettings?.executionMode === "kubernetes"), + (environmentsEnabled || !isCreate || generalSettings?.executionMode === "kubernetes"), }); // Setting-driven: resolve whether the instance forces Kubernetes execution and @@ -637,28 +638,6 @@ export function AgentConfigForm(props: AgentConfigFormProps) { enabled: Boolean(selectedCompanyId), }); const runnerAvailability = runnerDiscovery.data?.find(item => item.type === harnessType); - const showRunnerUpgrade = !isCreate - && ["codex_local", "claude_local"].includes(props.agent.adapterType) - && adapterType === props.agent.adapterType - && !runnerDiscovery.isError - && runnerAvailability?.defaultRunner === "paperclip"; - const upgradeRunner = async () => { - if (isCreate || runnerUpgradePending) return; - const agentId = props.agent.id; - setRunnerUpgrade({ agentId, status: "pending" }); - try { - const flushedEnv = flushEnvironmentDraft(); - const nextOverlay = flushedEnv - ? { ...overlay, adapterConfig: { ...overlay.adapterConfig, env: flushedEnv } } - : overlay; - await props.onSave({ ...buildAgentUpdatePatch(props.agent, nextOverlay), runner: "paperclip" }); - setRunnerUpgrade(current => current?.agentId === agentId ? { agentId, status: "upgraded" } : current); - } catch (error) { - setRunnerUpgrade(current => current?.agentId === agentId ? { - agentId, status: "failed", error: error instanceof Error ? error.message : "Could not upgrade this agent. Try again.", - } : current); - } - }; const runner: AgentRunnerChoice = isCreate ? props.values.runner ?? "auto" : overlay.runner ?? agentRunner(adapterType); const fieldsAdapterType = isRunnerHarness ? (runner === "paperclip" || (runner === "auto" && runnerAvailability?.defaultRunner === "paperclip") ? "paperclip_runner" : harnessType) @@ -915,6 +894,56 @@ export function AgentConfigForm(props: AgentConfigFormProps) { () => environments.find((environment) => environment.id === effectiveLoginEnvironmentId) ?? null, [environments, effectiveLoginEnvironmentId], ); + const nativeRunnerAvailability = runnerDiscovery.data?.find(item => item.type === "paperclip_runner"); + const upgradeEnvironment = forcedKubernetes ? kubernetesEnvironment : effectiveLoginEnvironment; + const upgradeEnvironmentId = upgradeEnvironment?.id ?? null; + const canQualifyRunnerUpgrade = upgradeEnvironment?.status === "active" + && canQualifyRunnerEnvironment(upgradeEnvironment) + && Boolean(runnerAvailability && !runnerAvailability.disabled && !runnerAvailability.overriddenBuiltin) + && Boolean(nativeRunnerAvailability && !nativeRunnerAvailability.disabled && !nativeRunnerAvailability.overriddenBuiltin); + const upgradeWarnings = harnessType === "claude_local" ? normalizeClaudeRunnerOptions(effectiveConfig).warnings : []; + const upgradeTargetRef = useRef(""); + const upgradeTarget = JSON.stringify([!isCreate ? props.agent.id : null, selectedCompanyId, adapterType, + rawCurrentDefaultEnvironmentId, upgradeEnvironmentId, upgradeEnvironment?.config, effectiveConfig, overlay]); + upgradeTargetRef.current = upgradeTarget; + useEffect(() => () => { upgradeTargetRef.current = ""; }, []); + const showRunnerUpgrade = !isCreate + && ["codex_local", "claude_local"].includes(props.agent.adapterType) + && adapterType === props.agent.adapterType + && !runnerDiscovery.isError + && (runnerAvailability?.defaultRunner === "paperclip" || canQualifyRunnerUpgrade); + const upgradeRunner = async () => { + if (isCreate || runnerUpgradePending) return; + const agentId = props.agent.id; + setRunnerUpgrade({ agentId, status: "pending" }); + try { + const flushedEnv = flushEnvironmentDraft(); + const nextOverlay = flushedEnv + ? { ...overlay, adapterConfig: { ...overlay.adapterConfig, env: flushedEnv } } + : overlay; + const pendingTarget = upgradeTargetRef.current; + if (runnerAvailability?.defaultRunner !== "paperclip") { + if (!canQualifyRunnerUpgrade || !upgradeEnvironmentId || !selectedCompanyId) { + throw new Error("Could not determine which environment to verify. Retry the upgrade."); + } + const qualified = await qualifyRunnerEnvironment({ + adapterType: harnessType, + readAdapters: () => adaptersApi.list({ companyId: selectedCompanyId, environmentId: upgradeEnvironmentId }), + probeEnvironment: () => environmentsApi.probe(upgradeEnvironmentId, selectedCompanyId), + isCurrent: () => upgradeTargetRef.current === pendingTarget, + }); + if (!qualified) throw new Error("Agent settings changed during verification. Retry the upgrade with the current settings."); + queryClient.setQueryData(queryKeys.adapters.availability(selectedCompanyId, rawCurrentDefaultEnvironmentId || null), qualified.adapters); + if (qualified.runner !== "paperclip") throw new Error("This environment does not support Paperclip Runner. Your agent still uses Legacy runner."); + } + await props.onSave({ ...buildAgentUpdatePatch(props.agent, nextOverlay), runner: "paperclip" }); + setRunnerUpgrade(current => current?.agentId === agentId ? { agentId, status: "upgraded" } : current); + } catch (error) { + setRunnerUpgrade(current => current?.agentId === agentId ? { + agentId, status: "failed", error: error instanceof Error ? error.message : "Could not upgrade this agent. Try again.", + } : current); + } + }; // Load the sandbox provider capabilities. A login that runs on a real // pseudo-terminal needs a provider that advertises the login pseudo-terminal // capability. The login panel gate reads this to hide the panel for a provider @@ -1461,6 +1490,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) { const environmentVariablesEditor = ( + {canQualifyRunnerUpgrade && runnerAvailability?.defaultRunner !== "paperclip" ? ( +

The upgrade will verify your computer first. This can take a few minutes.

+ ) : null} + {upgradeWarnings.map(warning =>

{warning.message}

)} {isDirty ?

Your unsaved changes will be included.

: null}