mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-24 18:19:05 +02:00
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:
1 parent
52a66af29d
commit
87bf89756e
19 files changed
+314
-65
No files matched your search
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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" } });
|
||||
|
||||
@@ -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([]);
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user