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 <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip authored and GitHub committed 2026-10-24 07:59:18 -07:00
1 parent 52a66af29d
commit 87bf89756e
19 files changed
+314 -65

No files matched your search

+5 -2
View File
@@ -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
+9 -5
View File
@@ -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.
+1
View File
@@ -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";
@@ -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<string, unknown>): {
config: Record<string, unknown>;
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 };
}
+2
View File
@@ -98,6 +98,8 @@ export interface Agent {
capabilities: string | null;
adapterType: AgentAdapterType;
adapterConfig: Record<string, unknown>;
/** Non-fatal options ignored by this configuration write; not persisted. */
configurationWarnings?: import("../runner-configuration-warnings.js").AgentConfigurationWarning[];
runtimeConfig: AgentRuntimeConfig;
defaultEnvironmentId?: string | null;
budgetMonthlyCents: number;
@@ -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();
+18 -7
View File
@@ -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<string, unknown>;
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<string, unknown>;
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) => {
@@ -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 => {
@@ -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<string, unknown>): Record<stri
}
export function resolveNewAgentRunner(input: AgentRunnerSelection): { adapterType: string; adapterConfig: Record<string, unknown> } {
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<string, unknown> } {
const adapterType = input.adapterType ?? "process";
let config = { ...input.adapterConfig };
const harness = agentHarnessType(adapterType, config);
@@ -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", () => {
@@ -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, unknown>): 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");
+4 -2
View File
@@ -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.
@@ -8,7 +8,7 @@ function fixtures(driver = "sandbox"): Parameters<typeof convertRepositoryProofA
}
describe("real legacy conversion prerequisites", () => {
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<string, any>).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" } });
+1 -1
View File
@@ -12,7 +12,7 @@ export function repositoryProofProfile(native: RunnerProfileFixture, legacy?: Ru
const config = { ...(agent.adapterConfig as Record<string, unknown>), model: native.model };
if (legacy) {
delete (config as Record<string, unknown>).extraArgs;
delete (config as Record<string, unknown>).maxTurnsPerRun;
if (legacy.adapterType === "claude_local") (config as Record<string, unknown>).maxTurnsPerRun = 1000;
}
return { ...agent, ...(legacy ? { runner: "legacy" } : {}), budgetMonthlyCents: 1000, adapterConfig: config };
} };
@@ -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<HTMLInputElement>('input[aria-label="Variable name"]')!, "MIGRATION_MARKER");
setInputValue(result.container.querySelector<HTMLInputElement>('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<HTMLInputElement>('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([]);
+63 -24
View File
@@ -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 = (
<EnvironmentVariablesEditor
ref={environmentVariablesEditorRef}
disabled={runnerUpgradePending}
key={environmentEditorKey}
onDirtyChange={setEnvironmentDraftDirty}
hideDraftActions={!isCreate && props.hideInlineSave}
@@ -1544,6 +1574,10 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
Keep {harnessType === "claude_local" ? "Claude" : "Codex"}, your model, account, and compatible settings.
New sessions start fresh; past runs stay available.
</p>
{canQualifyRunnerUpgrade && runnerAvailability?.defaultRunner !== "paperclip" ? (
<p className="text-xs text-muted-foreground">The upgrade will verify your computer first. This can take a few minutes.</p>
) : null}
{upgradeWarnings.map(warning => <p key={warning.field} className="text-xs text-muted-foreground">{warning.message}</p>)}
{isDirty ? <p className="text-xs text-muted-foreground">Your unsaved changes will be included.</p> : null}
</div>
<Button size="sm" className="self-end sm:self-auto" onClick={upgradeRunner} disabled={runnerUpgradePending || (!isCreate && props.isSaving)}>
@@ -1555,6 +1589,11 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
</div>
) : null}
{runnerUpgraded ? <p role="status" className="text-sm text-muted-foreground">Upgraded to Paperclip Runner.</p> : null}
{adapterType === "paperclip_runner" && upgradeWarnings.length > 0 ? (
<div role="status" className="space-y-1 rounded-lg border border-border bg-muted/30 p-4">
{upgradeWarnings.map(warning => <p key={warning.field} className="text-sm text-muted-foreground">{warning.message}</p>)}
</div>
) : null}
<ConfigSections order={props.sectionOrder} className={cn("relative", cards && "space-y-6")}>
{/* ---- Floating Save button (edit mode, when dirty) ---- */}
{isDirty && !props.hideInlineSave && (
+22 -9
View File
@@ -7,11 +7,15 @@ export function needsClaudeSetupQualification(input: {
savedAgentId?: string | null;
environment?: Pick<Environment, "driver" | "config"> | null;
}): boolean {
const config = input.environment?.config;
return !input.savedAgentId && input.adapterType === "claude_local" && input.runner === "auto"
&& canQualifyRunnerEnvironment(input.environment);
}
export function canQualifyRunnerEnvironment(environment?: Pick<Environment, "driver" | "config"> | null): boolean {
const config = environment?.config;
const immutableImage = typeof config?.image === "string" && /^[^\s@]+@sha256:[a-f0-9]{64}$/.test(config.image) && !config.snapshot;
const daytonaSnapshot = config?.provider === "daytona" && typeof config.snapshot === "string" && !!config.snapshot.trim() && !config.image;
return !input.savedAgentId && input.adapterType === "claude_local" && input.runner === "auto"
&& input.environment?.driver === "sandbox" && (immutableImage || daytonaSnapshot)
return environment?.driver === "sandbox" && (immutableImage || daytonaSnapshot)
&& !config?.template && !config?.templateRef;
}
@@ -21,19 +25,28 @@ export async function qualifyClaudeSetup(input: Parameters<typeof needsClaudeSet
isCurrent: () => boolean;
}): Promise<{ runner: AgentRunner; adapters: AdapterInfo[] } | undefined> {
if (!needsClaudeSetupQualification(input)) return undefined;
return qualifyRunnerEnvironment(input);
}
export async function qualifyRunnerEnvironment(input: {
adapterType: string;
readAdapters: () => Promise<AdapterInfo[]>;
probeEnvironment: () => Promise<EnvironmentProbeResult>;
isCurrent: () => boolean;
}): Promise<{ runner: AgentRunner; adapters: AdapterInfo[] } | undefined> {
const validate = (adapters: AdapterInfo[]) => {
const claude = adapters.find(adapter => adapter.type === "claude_local");
const runner = claude?.defaultRunner;
if (!claude || (runner !== "paperclip" && runner !== "legacy") || !claude.supportedRunners?.includes(runner)) {
throw new Error("Could not confirm Claude runner availability for this environment. Retry setup.");
const harness = adapters.find(adapter => adapter.type === input.adapterType);
const runner = harness?.defaultRunner;
if (!harness || (runner !== "paperclip" && runner !== "legacy") || !harness.supportedRunners?.includes(runner)) {
throw new Error("Could not confirm runner availability for this environment. Retry setup.");
}
return { runner, claude };
return { runner, harness };
};
let adapters = await input.readAdapters();
if (!input.isCurrent()) return undefined;
const selected = validate(adapters);
const native = adapters.find(adapter => adapter.type === "paperclip_runner");
if (selected.runner === "paperclip" || selected.claude.disabled || selected.claude.overriddenBuiltin
if (selected.runner === "paperclip" || selected.harness.disabled || selected.harness.overriddenBuiltin
|| !native || native.disabled || native.overriddenBuiltin) return { runner: selected.runner, adapters };
const probe = await input.probeEnvironment();
if (!input.isCurrent()) return undefined;
+2 -1
View File
@@ -2060,7 +2060,8 @@ export function ConfigurationTab({
|| !syncAgentRouteAfterRename(queryClient, navigate, target, updated, urlTab ?? content)) {
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(target.urlKey) });
}
pushToast({ title: "Agent saved", tone: "success" });
pushToast({ title: "Agent saved", tone: updated.configurationWarnings?.length ? "warn" : "success",
body: updated.configurationWarnings?.map(warning => warning.message).join("\n") });
},
onError: (err) => {
setAwaitingRefreshAfterSave(false);
@@ -1,3 +1,4 @@
import { normalizeClaudeRunnerOptions } from "@paperclipai/shared";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, userEvent, within, waitFor } from "storybook/test";
import { Route, Routes } from "@/lib/router";
@@ -50,9 +51,11 @@ function installUpgradeFixture({ harness = "codex_local", failOnce = false, inco
failOnce = false;
return Response.json({ error: "Could not save this agent. Try again." }, { status: 503 });
}
if (incompatible && (patch.adapterConfig?.chrome ?? agent.adapterConfig.chrome) === true) return Response.json({ error: "Paperclip Runner cannot honor these settings: chrome. Remove them or select Legacy runner in Advanced." }, { status: 422 });
const { runner: _runner, replaceAdapterConfig: _replace, adapterConfig, ...rest } = patch;
agent = { ...agent, ...rest, adapterType: "paperclip_runner", adapterConfig: { ...agent.adapterConfig, ...adapterConfig, ...profile } };
const converted = { ...agent.adapterConfig, ...adapterConfig, ...profile };
const normalized = harness === "claude_local" ? normalizeClaudeRunnerOptions(converted) : { config: converted, warnings: [] };
agent = { ...agent, ...rest, adapterType: "paperclip_runner", adapterConfig: normalized.config as typeof agent.adapterConfig };
return Response.json({ ...agent, configurationWarnings: normalized.warnings });
}
return Response.json(agent);
}
@@ -119,16 +122,15 @@ export const FailureAndRetry: Story = { parameters: { runnerUpgrade: { failOnce:
await userEvent.click(page.getByRole("button", { name: "Upgrade runner" }));
await expect(await page.findByText("Upgraded to Paperclip Runner.")).toBeVisible();
} };
export const IncompatibleClaudeSetting: Story = { parameters: { runnerUpgrade: { harness: "claude_local", incompatible: true } }, play: async ({ canvasElement }) => {
export const IncompatibleClaudeSetting: Story = { name: "Ignored Claude setting", parameters: { runnerUpgrade: { harness: "claude_local", incompatible: true } }, play: async ({ canvasElement }) => {
const page = within(canvasElement);
await expect(await page.findByText(/Chrome integration.*is ignored/)).toBeVisible();
await userEvent.click(await page.findByRole("button", { name: "Upgrade runner" }));
await expect(await page.findByRole("alert")).toHaveTextContent("chrome");
await expect(page.getByRole("switch", { name: "Enable Chrome" })).toBeChecked();
await expect(await page.findByText("Upgraded to Paperclip Runner.")).toBeVisible();
} };
export const RepairAndUpgrade: Story = { ...IncompatibleClaudeSetting, play: async context => {
await IncompatibleClaudeSetting.play!(context);
const page = within(context.canvasElement);
await userEvent.click(page.getByRole("switch", { name: "Enable Chrome" }));
export const RepairAndUpgrade: Story = { ...IncompatibleClaudeSetting, name: "Remove obsolete option and upgrade", play: async ({ canvasElement }) => {
const page = within(canvasElement);
await userEvent.click(await page.findByRole("switch", { name: "Enable Chrome" }));
await expect(page.getByText("Your unsaved changes will be included.")).toBeVisible();
await userEvent.click(page.getByRole("button", { name: "Upgrade runner" }));
await expect(await page.findByText("Upgraded to Paperclip Runner.")).toBeVisible();