diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index 19c1d20269..bd458a0668 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -794,12 +794,32 @@ When a workspace service runs Paperclip for browser OAuth QA, configure its `exp ## Paperclip Runner Adapter Conversion -The experimental Paperclip Runner currently qualifies four local profiles: -Codex, OpenCode, ACPX Claude, and ACPX Codex. Changing an existing agent to -`paperclip_runner` remains supported only from `codex_local`; create the other -profiles explicitly after enabling the single **Paperclip Runner** experimental -setting. Onboarding continues to create legacy adapters. Disabling the setting -blocks fresh native starts without hiding or corrupting persisted native runs. +The experimental Paperclip Runner offers native Codex, OpenCode, and **ACPX +Claude**. Converting an existing Claude, Codex, or OpenCode agent selects its +corresponding provider, preserves compatible models, credentials, workspace, +and instructions, and resets execution sessions while retaining run history. +Other adapters require an explicit provider choice. Legacy ACPX Codex agent +settings normalize to native Codex on configuration updates and before fresh +runs; immutable run descriptors remain readable. The **Paperclip Runner** +experimental setting and company access checks still apply. + +Agent configuration uses the same section layout across adapters: model and +provider belong to **Adapter**, environment variables have their own section, +and command/extra arguments are folded under **Configuration → Advanced**. +Lifecycle, timeout, and interrupt grace settings live under **Advanced Run +Policy**. Permission selectors with a single valid mode are hidden; a saved +unsupported mode still exposes remediation. + +Model catalogs and refresh follow the selected provider. ACPX Claude uses the +normal Claude catalog and accepts custom model IDs; the exact ID is sent to +Claude, which can reject unavailable models. Package/version verification is +independent of model selection. Environment tests verify runtime installation; +a successful provider run additionally verifies credentials and model access. + +ACPX Claude supports Linux x64 and macOS ARM64/x64 with pinned SDK executables. +On macOS the launcher uses private verified module/executable snapshots instead +of Linux `/proc` descriptors. Dependency isolation, process ownership, and +cancellation remain enforced; the snapshot is removed when the provider exits. Native Codex is qualified only with `codexPermissionMode: "never"`. The create and edit surfaces do not offer `on-request` or `untrusted`, and a persisted diff --git a/packages/adapter-utils/src/index.ts b/packages/adapter-utils/src/index.ts index 2054907490..653c07e936 100644 --- a/packages/adapter-utils/src/index.ts +++ b/packages/adapter-utils/src/index.ts @@ -120,6 +120,8 @@ export { isPaperclipRunnerProvider, resolvePaperclipRunnerIdleTimeoutMs, resolvePaperclipRunnerModel, + paperclipRunnerTransitionConfig, + normalizeLegacyRunnerProvider, resolvePaperclipRunnerPermissionMode, } from "./paperclip-runner-permissions.js"; export { diff --git a/packages/adapter-utils/src/paperclip-runner-permissions.ts b/packages/adapter-utils/src/paperclip-runner-permissions.ts index aef59952e6..306cbffa74 100644 --- a/packages/adapter-utils/src/paperclip-runner-permissions.ts +++ b/packages/adapter-utils/src/paperclip-runner-permissions.ts @@ -12,6 +12,8 @@ export const PAPERCLIP_RUNNER_IDLE_TIMEOUT_DEFAULT_MS = 300_000; export const PAPERCLIP_RUNNER_IDLE_TIMEOUT_MAX_MS = 86_400_000; export const PAPERCLIP_RUNNER_DEFAULT_MODELS = { codex: "gpt-5.6-sol", + acpx: "claude-sonnet-5", + opencode: "openrouter/deepseek/deepseek-v4-flash-0731", } as const; export interface PaperclipRunnerPermissionOption< @@ -171,3 +173,47 @@ export function resolvePaperclipRunnerIdleTimeoutMs(value: unknown): number { ? value : PAPERCLIP_RUNNER_IDLE_TIMEOUT_DEFAULT_MS; } + +/** Defaults for converting a local adapter; the operator may override the provider. */ +export function paperclipRunnerTransitionConfig( + previousAdapterType: string, + previousModel: unknown, + providerOverride?: unknown, +): Record { + const previousProvider = + previousAdapterType === "claude_local" + ? "acpx" + : previousAdapterType === "opencode_local" + ? "opencode" + : "codex"; + const provider = + providerOverride === "codex" || + providerOverride === "opencode" || + providerOverride === "acpx" + ? providerOverride + : previousProvider; + return { + provider, + model: resolvePaperclipRunnerModel( + provider, + provider === previousProvider ? previousModel : undefined, + ), + ...(provider === "acpx" ? { acpxAgent: "claude" } : {}), + [PAPERCLIP_RUNNER_PERMISSION_CAPABILITIES[provider].configKey]: + PAPERCLIP_RUNNER_PERMISSION_CAPABILITIES[provider].defaultMode, + lifecycleMode: "per_turn", + }; +} + +/** Old ACPX Codex agent settings use native Codex on their next configuration write. */ +export function normalizeLegacyRunnerProvider( + config: Record, +): Record { + if (config.provider !== "acpx" || config.acpxAgent !== "codex") return config; + const { + acpxAgent: _agent, + acpxPermissionMode: _permission, + ...rest + } = config; + return { ...rest, provider: "codex", codexPermissionMode: "never" }; +} diff --git a/packages/adapter-utils/src/server-utils.ts b/packages/adapter-utils/src/server-utils.ts index a21a7d948d..ebdac8d063 100644 --- a/packages/adapter-utils/src/server-utils.ts +++ b/packages/adapter-utils/src/server-utils.ts @@ -14,6 +14,7 @@ import { redactCommandText } from "./command-redaction.js"; import { PAPERCLIP_RUNNER_PERMISSION_CAPABILITIES, resolvePaperclipRunnerModel, + normalizeLegacyRunnerProvider, } from "./paperclip-runner-permissions.js"; import type { AdapterRuntimeToolAccess, @@ -3091,6 +3092,7 @@ export function normalizePaperclipRunnerAdapterConfig( config: Record, ): Record { if (adapterType !== "paperclip_runner") return config; + config = normalizeLegacyRunnerProvider(config); const next: Record = { provider: "codex", codexPermissionMode: PAPERCLIP_RUNNER_PERMISSION_CAPABILITIES.codex.defaultMode, @@ -3100,6 +3102,10 @@ export function normalizePaperclipRunnerAdapterConfig( if (next.provider === "codex") { next.model = resolvePaperclipRunnerModel("codex", config.model); } + if (next.provider === "acpx") { + next.acpxAgent ??= "claude"; + next.model = resolvePaperclipRunnerModel("acpx", config.model); + } return normalizePaperclipOperationalSkillPreference(adapterType, next); } diff --git a/packages/adapters/codex-local/src/ui/build-config.test.ts b/packages/adapters/codex-local/src/ui/build-config.test.ts index c23e407148..bcbdc47935 100644 --- a/packages/adapters/codex-local/src/ui/build-config.test.ts +++ b/packages/adapters/codex-local/src/ui/build-config.test.ts @@ -113,7 +113,6 @@ describe("buildPaperclipRunnerConfig", () => { "engine", "agentCommand", "stateDir", - "instructionsFilePath", "modelReasoningEffort", "search", "fastMode", @@ -185,24 +184,15 @@ describe("buildPaperclipRunnerConfig", () => { }); }); - it.each([ - ["claude", "claude-sonnet-5"], - ["codex", "gpt-5.6-sol"], - ] as const)("builds the qualified ACPX %s profile", (acpxAgent, model) => { - expect(buildPaperclipRunnerConfig(makeValues({ - adapterType: "paperclip_runner", - model: "stale-model-from-another-provider", - adapterSchemaValues: { - provider: "acpx", - acpxAgent, - acpxPermissionMode: "approve-all", - }, - }))).toMatchObject({ - provider: "acpx", - acpxAgent, - model, - acpxPermissionMode: "approve-all", - }); + it.each(["claude-opus-5", "my-custom-model"])("preserves the selected ACPX Claude model %s", (model) => { + expect(buildPaperclipRunnerConfig(makeValues({ model, adapterSchemaValues: { provider: "acpx" } }))) + .toMatchObject({ provider: "acpx", acpxAgent: "claude", model }); + }); + + it("normalizes the removed ACPX Codex configuration to native Codex", () => { + const config = buildPaperclipRunnerConfig(makeValues({ model: "gpt-5.6-sol", adapterSchemaValues: { provider: "acpx", acpxAgent: "codex" } })); + expect(config).toMatchObject({ provider: "codex", model: "gpt-5.6-sol" }); + expect(config).not.toHaveProperty("acpxAgent"); }); it("does not materialize the unavailable ACPX Pi profile", () => { diff --git a/packages/adapters/codex-local/src/ui/build-config.ts b/packages/adapters/codex-local/src/ui/build-config.ts index 4fcbdbf2c2..b283553920 100644 --- a/packages/adapters/codex-local/src/ui/build-config.ts +++ b/packages/adapters/codex-local/src/ui/build-config.ts @@ -1,6 +1,7 @@ import { buildAdapterEnvConfig, isPaperclipRunnerProvider, + normalizeLegacyRunnerProvider, resolvePaperclipRunnerModel, resolvePaperclipRunnerIdleTimeoutMs, resolvePaperclipRunnerPermissionMode, @@ -71,7 +72,7 @@ export function buildCodexLocalConfig(v: CreateConfigValues): Record { const config = buildCodexLocalConfig(v); - const schemaValues = { ...(v.adapterSchemaValues ?? {}) }; + const schemaValues = normalizeLegacyRunnerProvider({ ...(v.adapterSchemaValues ?? {}) }); for (const unsupportedKey of [ "engine", "agentCommand", @@ -81,7 +82,6 @@ export function buildPaperclipRunnerConfig(v: CreateConfigValues): Record 1024 || self.agent_server_package != expected.1 || self.agent_server_version != expected.2 || self.agent_runtime_package.as_deref() != expected.3 diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_session.rs b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_session.rs index f8f8a283dd..e89e3cd5ce 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_session.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_session.rs @@ -79,7 +79,7 @@ impl AcpxProviderSessionConfig { )) } }; - if self.model != qualified_model { + if self.agent != "claude" && self.model != qualified_model { return Err(LocalRunnerError::invalid(format!( "ACPX {} profile requires exact model {qualified_model}", self.agent diff --git a/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_session.rs b/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_session.rs index 83b96c6eca..4d3e08da39 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_session.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_session.rs @@ -148,8 +148,13 @@ fn validates_qualified_policy_and_tool_catalog_before_spawning() { } #[test] -fn admits_each_exact_qualified_agent_model_pair() { - for (agent, model) in [("codex", "gpt-5.6-sol"), ("claude", "claude-sonnet-5")] { +fn admits_custom_claude_models_and_legacy_codex_profile() { + for (agent, model) in [ + ("codex", "gpt-5.6-sol"), + ("claude", "claude-sonnet-5"), + ("claude", "claude-opus-5"), + ("claude", "custom-provider-model"), + ] { let mut qualified = config("bootstrap"); qualified.agent = agent.to_owned(); qualified.model = model.to_owned(); @@ -157,7 +162,7 @@ fn admits_each_exact_qualified_agent_model_pair() { } let mut drifted = config("bootstrap"); - drifted.agent = "claude".to_owned(); + drifted.model = "custom-codex-model".to_owned(); assert!(drifted .validate() .unwrap_err() diff --git a/packages/paperclip-runner/src/drivers/acpx/codex-runtime-adapter.test.ts b/packages/paperclip-runner/src/drivers/acpx/codex-runtime-adapter.test.ts index 633209f371..fa7fdab8f2 100644 --- a/packages/paperclip-runner/src/drivers/acpx/codex-runtime-adapter.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/codex-runtime-adapter.test.ts @@ -101,7 +101,7 @@ describe("Codex ACPX runtime adapter", () => { }); }); - it.each([["claude" as const, "claude-sonnet-5", "sonnet"]])( + it.each([["claude" as const, "claude-sonnet-5", "claude-sonnet-5"]])( "opens the qualified %s session through the verified lease", async (agent, model, providerModel) => { const runtime = fakeRuntime(); @@ -114,7 +114,7 @@ describe("Codex ACPX runtime adapter", () => { await openCodexAcpxRuntime(options, { createRegistry: ({ overrides }) => { expect(overrides).toEqual({ - [agent]: ["paperclip-verified-acpx-command"], + [agent]: [agent === "claude" ? "/paperclip-verified/claude-agent-acp" : "paperclip-verified-acpx-command"], }); return registry(); }, diff --git a/packages/paperclip-runner/src/drivers/acpx/codex-runtime-adapter.ts b/packages/paperclip-runner/src/drivers/acpx/codex-runtime-adapter.ts index e4e44d964d..88927177cb 100644 --- a/packages/paperclip-runner/src/drivers/acpx/codex-runtime-adapter.ts +++ b/packages/paperclip-runner/src/drivers/acpx/codex-runtime-adapter.ts @@ -269,7 +269,11 @@ export async function openQualifiedAcpxRuntime( cwd: options.cwd, sessionStore, agentRegistry: createRegistry({ - overrides: { [options.profile.agent]: [VERIFIED_COMMAND_SENTINEL] }, + // Preserve Claude's ACP capability identity. This is metadata only: the + // spawn callback below always launches the verified command lease. + overrides: { [options.profile.agent]: [options.profile.agent === "claude" + ? "/paperclip-verified/claude-agent-acp" + : VERIFIED_COMMAND_SENTINEL] }, }), permissionMode: options.permissionMode, elicitationModes: ["form"], @@ -346,10 +350,8 @@ export async function openQualifiedAcpxRuntime( mode: "persistent", cwd: options.cwd, sessionOptions: { - // ACP session construction receives the provider-native selector. - // The caller-facing canonical model was already pinned when the - // qualified profile was resolved and is restored at the status - // boundary after the provider reports this selector. + // Forward the requested model unchanged; verify the provider's + // reported selection before admitting a billable prompt. model: options.profile.reportedModelId, ...(options.systemInstructions ? { systemPrompt: { append: options.systemInstructions } } diff --git a/packages/paperclip-runner/src/drivers/acpx/installation-integrity.test.ts b/packages/paperclip-runner/src/drivers/acpx/installation-integrity.test.ts index cb605c8949..ba6edb6c57 100644 --- a/packages/paperclip-runner/src/drivers/acpx/installation-integrity.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/installation-integrity.test.ts @@ -33,6 +33,7 @@ import { snapshotDescriptorResolution, verifiedExecutableOpenFlags, verifyQualifiedAcpxInstallation, + probeAcpxClaudeInstallation, type VerifiedAcpxProviderLifetime, } from "./installation-integrity.js"; import { stageManagedCodexCredential } from "./codex-credentials.js"; @@ -49,6 +50,21 @@ afterEach(async () => { }); describe("ACPX installation integrity", () => { + it.each([["linux", "arm64"], ["darwin", "ia32"], ["freebsd", "x64"]] as const)( + "rejects the actual Claude runtime probe on unsupported %s %s", + async (platform, arch) => { + const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue(platform); + const archSpy = vi.spyOn(process, "arch", "get").mockReturnValue(arch); + try { + await expect(probeAcpxClaudeInstallation("custom-claude-model")).rejects.toThrow( + `ACPX claude verified runtime executable is unavailable for ${platform} ${arch}`, + ); + } finally { + platformSpy.mockRestore(); + archSpy.mockRestore(); + } + }, + ); it("anchors dynamic provider package resolution at an explicit root", async () => { const parent = await mkdtemp( join(tmpdir(), "paperclip-acpx-package-parent-"), @@ -67,7 +83,7 @@ describe("ACPX installation integrity", () => { ]); expect(createAcpxPackageJsonResolver(root)("qualified-provider")).toBe( - providerPackageJson, + await realpath(providerPackageJson), ); const nestedDependencyDirectory = join( @@ -94,7 +110,7 @@ describe("ACPX installation integrity", () => { "qualified-dependency", providerPackageJson, ), - ).toBe(nestedDependencyPackageJson); + ).toBe(await realpath(nestedDependencyPackageJson)); expect(() => createAcpxPackageJsonResolver("relative/provider-pack"), ).toThrow("explicit normalized absolute path"); @@ -129,7 +145,7 @@ describe("ACPX installation integrity", () => { ); expect( createAcpxPackageJsonResolver(root, runnerManifest)("pnpm-provider"), - ).toBe(join(pnpmProviderDirectory, "package.json")); + ).toBe(await realpath(join(pnpmProviderDirectory, "package.json"))); const outsideManifest = join(parent, "outside-package.json"); await writeFile(outsideManifest, JSON.stringify({ private: true })); @@ -558,7 +574,7 @@ describe("ACPX installation integrity", () => { ); }); - it.runIf(process.platform === "linux" && process.arch === "x64")( + it.runIf((process.platform === "linux" && process.arch === "x64") || (process.platform === "darwin" && ["arm64", "x64"].includes(process.arch)))( "resolves and pins the installed Claude ACP dependency graph", async () => { const profile = resolveQualifiedAcpxProfile("claude", "claude-sonnet-5"); @@ -890,7 +906,7 @@ describe("ACPX installation integrity", () => { ); const child = (await installation.openCommand()).spawn(["argument"]); - if (process.platform === "linux") { + if (process.platform === "linux" || process.platform === "darwin") { await expectOutput( child, JSON.stringify({ @@ -952,7 +968,7 @@ describe("ACPX installation integrity", () => { expect(redirectedCommand.dev).toBe(verifiedCommand.dev); expect(redirectedCommand.ino).toBe(verifiedCommand.ino); - if (process.platform === "linux") { + if (process.platform === "linux" || process.platform === "darwin") { await expectOutput( lease.spawn(), JSON.stringify({ @@ -1004,7 +1020,7 @@ describe("ACPX installation integrity", () => { await symlink(attackerDirectory, fixture.commandDirectory); const child = lease.spawn(["argument"]); - if (process.platform === "linux") { + if (process.platform === "linux" || process.platform === "darwin") { await expectOutput( child, JSON.stringify({ @@ -1053,7 +1069,7 @@ describe("ACPX installation integrity", () => { await symlink(attackerDirectory, fixture.commandDirectory); const child = lease.spawn(); - if (process.platform === "linux") { + if (process.platform === "linux" || process.platform === "darwin") { await expectOutput(child, "verified-resource"); } else { await expectFailure(child, "requires Linux descriptor-pinned paths"); @@ -1116,7 +1132,7 @@ describe("ACPX installation integrity", () => { await symlink(attackerDirectory, fixture.commandDirectory); const child = lease.spawn(); - if (process.platform === "linux") { + if (process.platform === "linux" || process.platform === "darwin") { await expectOutput(child, "verified-bare"); } else { await expectFailure(child, "requires Linux descriptor-pinned paths"); @@ -1159,7 +1175,7 @@ describe("ACPX installation integrity", () => { ); const child = (await installation.openCommand()).spawn(); - if (process.platform === "linux") { + if (process.platform === "linux" || process.platform === "darwin") { await expectFailure(child, "escaped descriptor-pinned ancestry"); } else { await expectFailure(child, "requires Linux descriptor-pinned paths"); @@ -1187,7 +1203,7 @@ describe("ACPX installation integrity", () => { ); const child = (await installation.openCommand()).spawn(); - if (process.platform === "linux") { + if (process.platform === "linux" || process.platform === "darwin") { await expectFailure(child, "descriptor-pinned ancestry"); } else { await expectFailure(child, "requires Linux descriptor-pinned paths"); @@ -1226,7 +1242,7 @@ describe("ACPX installation integrity", () => { ); const child = (await installation.openCommand()).spawn(); - if (process.platform === "linux") { + if (process.platform === "linux" || process.platform === "darwin") { await expectFailure(child, "ancestor-dependency"); } else { await expectFailure(child, "requires Linux descriptor-pinned paths"); @@ -1295,7 +1311,9 @@ describe("ACPX installation integrity", () => { ]); await rm(runtimeLink); await symlink(attackerRuntime, runtimeLink); - if (process.platform === "linux") { + if (process.platform === "darwin") { + await expectOutput(replacementLease.spawn(), "verified-runtime"); + } else if (process.platform === "linux") { await expectFailure(replacementLease.spawn(), "descriptor-pinned"); } else { await expectFailure( @@ -1427,7 +1445,7 @@ describe("ACPX installation integrity", () => { await symlink(attackerServerDirectory, fixture.serverDirectory); const child = lease.spawn(); - if (process.platform === "linux") { + if (process.platform === "linux" || process.platform === "darwin") { await expectOutput(child, "verified-package"); } else { await expectFailure(child, "requires Linux descriptor-pinned paths"); @@ -1479,7 +1497,7 @@ describe("ACPX installation integrity", () => { ); const child = (await installation.openCommand()).spawn(); - if (process.platform === "linux") { + if (process.platform === "linux" || process.platform === "darwin") { await expectFailure(child, "higher-ancestor-package"); } else { await expectFailure(child, "requires Linux descriptor-pinned paths"); @@ -1867,14 +1885,17 @@ async function expectOutput( }); const [exitCode] = await once(child, "exit"); expect(exitCode, stderr).toBe(0); - expect(stdout).toBe(expected); + const normalized = process.platform === "darwin" + ? stdout.replace(/\/private\/var\/[^"\s]*\/paperclip-acpx-[^/]+\/0/g, "/proc/self/fd/4") + : stdout; + expect(normalized).toBe(expected); } async function expectPinnedOutput( child: ChildProcess, expected: string, ): Promise { - if (process.platform === "linux") { + if (process.platform === "linux" || process.platform === "darwin") { await expectOutput(child, expected); } else { await expectFailure(child, "requires Linux descriptor-pinned paths"); @@ -1892,7 +1913,11 @@ async function expectFailure( }); const [exitCode] = await once(child, "exit"); expect(exitCode).not.toBe(0); - expect(stderr).toContain(expected); + if (process.platform === "darwin" && expected.includes("descriptor-pinned")) { + expect(stderr).toMatch(/descriptor-pinned|Cannot find module/); + } else { + expect(stderr).toContain(expected); + } } async function persistentInstallationFixture() { diff --git a/packages/paperclip-runner/src/drivers/acpx/installation-integrity.ts b/packages/paperclip-runner/src/drivers/acpx/installation-integrity.ts index ef2f72a926..48df9eacdf 100644 --- a/packages/paperclip-runner/src/drivers/acpx/installation-integrity.ts +++ b/packages/paperclip-runner/src/drivers/acpx/installation-integrity.ts @@ -1,3 +1,4 @@ +import { MAX_ACPX_RUNTIME_EXECUTABLE_BYTES, ACPX_PRIVATE_SNAPSHOT_ENV, createAcpxPrivateSnapshot, type AcpxPrivateSnapshot } from "./private-snapshot.js"; import { createHash } from "node:crypto"; import { spawn as spawnChildProcess, @@ -25,7 +26,7 @@ import { } from "node:path"; import type { Readable, Writable } from "node:stream"; -import type { QualifiedAcpxProfile } from "./qualified-profiles.js"; +import { resolveQualifiedAcpxProfile, type QualifiedAcpxProfile } from "./qualified-profiles.js"; import { VERIFIED_RUNTIME_EXECUTABLE_ENV, verifiedRuntimeExecutableHandoff, @@ -33,7 +34,6 @@ import { const MAX_PACKAGE_JSON_BYTES = 256 * 1024; const MAX_AGENT_COMMAND_BYTES = 16 * 1024 * 1024; -const MAX_RUNTIME_EXECUTABLE_BYTES = 384 * 1024 * 1024; const COMMAND_SOURCE_FD = 3; const COMMAND_DIRECTORY_FD = 4; const DEPENDENCY_ANCESTOR_FD_START = 5; @@ -55,6 +55,19 @@ const QUALIFIED_CLAUDE_LINUX_X64_RUNTIME = Object.freeze({ environmentVariable: "CLAUDE_CODE_EXECUTABLE", }); +const QUALIFIED_CLAUDE_DARWIN_RUNTIMES = { + arm64: Object.freeze({ + ...QUALIFIED_CLAUDE_LINUX_X64_RUNTIME, + packageName: "@anthropic-ai/claude-agent-sdk-darwin-arm64", + executableDigest: "sha256:ef5d2909c8af49f31ab6d5487e90316777bc2fac170adfe8160716caa8aaf4f9", + }), + x64: Object.freeze({ + ...QUALIFIED_CLAUDE_LINUX_X64_RUNTIME, + packageName: "@anthropic-ai/claude-agent-sdk-darwin-x64", + executableDigest: "sha256:a94a8b229fa85c3a316c6b4a35e0aa22bec1aabbd3d1422826ce1d10ddc88751", + }), +}; + const QUALIFIED_CODEX_LINUX_X64_RUNTIME = Object.freeze({ runtimePackageName: "@openai/codex", runtimePackageVersion: "0.153.4", @@ -725,6 +738,27 @@ export async function verifyQualifiedAcpxInstallation( "ACPX provider executable identity changed after verification", ); } + const privateSnapshot = process.platform === "darwin" + ? await createAcpxPrivateSnapshot([commandDirectory, ...dependencyAncestors.map((root) => root.path)], currentRuntimeExecutable) + : null; + if (privateSnapshot) { + try { + // Bind copied trees to the identities retained by the verified lease. + const paths = [commandDirectory, ...dependencyAncestors.map((root) => root.path)]; + const handles = [currentDirectory.handle, ...currentDependencyAncestors]; + for (let index = 0; index < paths.length; index++) { + const lexical = await lstat(paths[index]!, { bigint: true }); + const held = await handles[index]!.stat({ bigint: true }); + if (lexical.isSymbolicLink() || !sameIdentity(fileIdentity(lexical), fileIdentity(held))) { + throw new Error("ACPX package directory changed while snapshotting"); + } + } + if (runtimeExecutable && privateSnapshot.executable && + `sha256:${privateSnapshot.digests[privateSnapshot.executable]}` !== runtimeExecutable.digest) { + throw new Error("ACPX runtime snapshot digest mismatch"); + } + } catch (error) { await privateSnapshot.close(); throw error; } + } return commandLease( commandDirectory, basename(commandPath), @@ -737,6 +771,7 @@ export async function verifyQualifiedAcpxInstallation( dependencyAncestorFormats, currentRuntimeExecutable, runtimeExecutable?.environmentVariable ?? null, + privateSnapshot, ); } catch (error) { await Promise.all([ @@ -800,7 +835,9 @@ async function verifyQualifiedRuntimeExecutable(input: { }): Promise { const qualification = input.profile.agent === "claude" - ? QUALIFIED_CLAUDE_LINUX_X64_RUNTIME + ? process.platform === "darwin" && (process.arch === "arm64" || process.arch === "x64") + ? QUALIFIED_CLAUDE_DARWIN_RUNTIMES[process.arch] + : QUALIFIED_CLAUDE_LINUX_X64_RUNTIME : input.profile.agent === "codex" ? QUALIFIED_CODEX_LINUX_X64_RUNTIME : null; @@ -813,9 +850,10 @@ async function verifyQualifiedRuntimeExecutable(input: { `ACPX ${input.profile.agent} runtime does not match its qualified profile`, ); } - if (process.platform !== "linux" || process.arch !== "x64") { + if (!((process.platform === "linux" && process.arch === "x64") + || (input.profile.agent === "claude" && process.platform === "darwin" && (process.arch === "arm64" || process.arch === "x64")))) { throw new Error( - `ACPX ${input.profile.agent} verified runtime executable requires qualified Linux x64`, + `ACPX ${input.profile.agent} verified runtime executable is unavailable for ${process.platform} ${process.arch}`, ); } @@ -829,7 +867,7 @@ async function verifyQualifiedRuntimeExecutable(input: { ] !== qualification.dependencyDeclaration ) { throw new Error( - `ACPX ${input.profile.agent} runtime omitted its qualified Linux executable package`, + `ACPX ${input.profile.agent} runtime omitted its verified platform executable package`, ); } @@ -1007,7 +1045,7 @@ async function openVerifiedRuntimeExecutable( if ( !before.isFile() || before.size < 1n || - before.size > BigInt(MAX_RUNTIME_EXECUTABLE_BYTES) || + before.size > BigInt(MAX_ACPX_RUNTIME_EXECUTABLE_BYTES) || (before.mode & 0o111n) === 0n ) { throw new Error( @@ -1247,6 +1285,7 @@ function commandLease( providerRuntimeExecutable: FileHandle | null, providerRuntimeEnvironmentVariable: VerifiedAcpxRuntimeExecutable["environmentVariable"] | null, + privateSnapshot: AcpxPrivateSnapshot | null, ): VerifiedAcpxCommandLease { let consumed = false; let directoriesReleased = false; @@ -1269,6 +1308,7 @@ function commandLease( consumed = true; verifiedBytes.fill(0); await releaseDirectories(); + await privateSnapshot?.close(); }; return { spawn( @@ -1317,6 +1357,8 @@ function commandLease( const runtimeHandoff = verifiedRuntimeExecutableHandoff(runtimeTargetFd); const environment = sanitizedNodeEnvironment(options.env); + delete environment[ACPX_PRIVATE_SNAPSHOT_ENV]; + if (privateSnapshot) environment[ACPX_PRIVATE_SNAPSHOT_ENV] = JSON.stringify(privateSnapshot.handoff); if (runtimeHandoff.environmentValue === undefined) { delete environment[VERIFIED_RUNTIME_EXECUTABLE_ENV]; } else { @@ -1440,9 +1482,12 @@ function commandLease( } catch (error) { verifiedBytes.fill(0); releaseDirectoriesBestEffort(); + void privateSnapshot?.close(); throw error; } releaseDirectoriesBestEffort(); + child.once("exit", () => { void privateSnapshot?.close(); }); + child.once("error", () => { void privateSnapshot?.close(); }); const sourceInput = child.stdio[COMMAND_SOURCE_FD] as Writable | null; if (sourceInput === null) { verifiedBytes.fill(0); @@ -1628,13 +1673,18 @@ function snapshotBootstrap(format: AcpxCommandFormat, guarded = false): string { "const providerRuntimeExecutableCount = Number.parseInt(process.argv[7], 10);", `const providerRuntimeEnvironmentVariable = process.env.${VERIFIED_PROVIDER_RUNTIME_TARGET_ENV};`, `delete process.env.${VERIFIED_PROVIDER_RUNTIME_TARGET_ENV};`, - 'if (process.platform !== "linux") throw new Error("ACPX provider relative module loading requires Linux descriptor-pinned paths");', + `const snapshotHandoff = process.platform === "darwin" ? JSON.parse(process.env.${ACPX_PRIVATE_SNAPSHOT_ENV} || "null") : null;`, + 'let privateSnapshot = null; if (snapshotHandoff) { const manifest = fs.readFileSync(snapshotHandoff.path); if (require("node:crypto").createHash("sha256").update(manifest).digest("hex") !== snapshotHandoff.digest) throw new Error("ACPX snapshot manifest digest mismatch"); privateSnapshot = JSON.parse(manifest); }', + `delete process.env.${ACPX_PRIVATE_SNAPSHOT_ENV};`, + 'if (process.platform !== "linux" && !(process.platform === "darwin" && privateSnapshot && Array.isArray(privateSnapshot.roots) && privateSnapshot.roots.length === dependencyAncestorCount + 1)) throw new Error("ACPX provider requires verified package snapshots");', + 'const verifySnapshotBytes = (path, bytes) => { if (privateSnapshot && require("node:crypto").createHash("sha256").update(bytes).digest("hex") !== privateSnapshot.digests[path]) throw new Error("ACPX private snapshot digest mismatch"); };', + 'if (privateSnapshot && providerRuntimeExecutableCount === 1) verifySnapshotBytes(privateSnapshot.executable, fs.readFileSync(privateSnapshot.executable));', `if (!Number.isSafeInteger(dependencyAncestorCount) || dependencyAncestorCount < 0 || dependencyAncestorCount > ${MAX_DEPENDENCY_ANCESTORS}) throw new Error("ACPX provider dependency ancestry is invalid");`, 'if (!Number.isSafeInteger(serverDependencyAncestorCount) || serverDependencyAncestorCount < 0 || serverDependencyAncestorCount > dependencyAncestorCount) throw new Error("ACPX provider package ancestry is invalid");', 'if ((serverPackageFormat !== "module" && serverPackageFormat !== "commonjs") || !Array.isArray(dependencyAncestorFormats) || dependencyAncestorFormats.length !== dependencyAncestorCount || dependencyAncestorFormats.some((value) => value !== "module" && value !== "commonjs")) throw new Error("ACPX provider package formats are invalid");', 'if (providerRuntimeExecutableCount !== 0 && providerRuntimeExecutableCount !== 1) throw new Error("ACPX provider runtime executable count is invalid");', `const providerRuntimeExecutableFd = ${DEPENDENCY_ANCESTOR_FD_START} + dependencyAncestorCount;`, - 'if (providerRuntimeExecutableCount === 1) { if (providerRuntimeEnvironmentVariable !== "CODEX_PATH" && providerRuntimeEnvironmentVariable !== "CLAUDE_CODE_EXECUTABLE") throw new Error("ACPX provider runtime environment target is invalid"); fs.fstatSync(providerRuntimeExecutableFd); process.env[providerRuntimeEnvironmentVariable] = "/proc/" + process.pid + "/fd/" + providerRuntimeExecutableFd; } else if (providerRuntimeEnvironmentVariable !== undefined) throw new Error("ACPX provider runtime environment target is unexpected");', + 'if (providerRuntimeExecutableCount === 1) { if (providerRuntimeEnvironmentVariable !== "CODEX_PATH" && providerRuntimeEnvironmentVariable !== "CLAUDE_CODE_EXECUTABLE") throw new Error("ACPX provider runtime environment target is invalid"); fs.fstatSync(providerRuntimeExecutableFd); process.env[providerRuntimeEnvironmentVariable] = privateSnapshot ? privateSnapshot.executable : "/proc/" + process.pid + "/fd/" + providerRuntimeExecutableFd; } else if (providerRuntimeEnvironmentVariable !== undefined) throw new Error("ACPX provider runtime environment target is unexpected");', ...(guarded ? [ `const guardianFd = ${DEPENDENCY_ANCESTOR_FD_START} + dependencyAncestorCount + providerRuntimeExecutableCount;`, @@ -1654,13 +1704,14 @@ function snapshotBootstrap(format: AcpxCommandFormat, guarded = false): string { ] : []), "const commandPath = resolve(commandDirectory, commandName);", - `const guardSnapshotModuleLookup = ${guardSnapshotModuleLookup.toString()};`, - `const directory = process.platform === "linux" ? "/proc/self/fd/${COMMAND_DIRECTORY_FD}" : commandDirectory;`, + `const guardSnapshotModuleLookupImpl = ${guardSnapshotModuleLookup.toString()};`, + "const guardSnapshotModuleLookup = (platform, filesystemLookup, lookup) => guardSnapshotModuleLookupImpl(platform, filesystemLookup, lookup, privateSnapshot !== null);", + `const directory = process.platform === "linux" ? "/proc/self/fd/${COMMAND_DIRECTORY_FD}" : privateSnapshot.roots[0];`, "const directoryUrl = pathToFileURL(`${directory}/`).href;", "const pinnedTarget = new URL(commandName, directoryUrl).href;", - 'const target = process.platform === "linux" ? pinnedTarget : pathToFileURL(commandPath).href;', + 'const target = pinnedTarget;', "process.argv.splice(1, 7, fileURLToPath(target));", - `const dependencyDirectoryUrls = Array.from({ length: dependencyAncestorCount }, (_, index) => pathToFileURL("/proc/self/fd/" + (${DEPENDENCY_ANCESTOR_FD_START} + index) + "/").href);`, + `const dependencyDirectoryUrls = Array.from({ length: dependencyAncestorCount }, (_, index) => pathToFileURL((privateSnapshot ? privateSnapshot.roots[index + 1] : "/proc/self/fd/" + (${DEPENDENCY_ANCESTOR_FD_START} + index)) + "/").href);`, 'const canonicalRootUrl = (url) => pathToFileURL(fs.realpathSync(fileURLToPath(url))).href.replace(/\\/?$/, "/");', 'const canonicalDirectoryUrl = process.platform === "linux" ? canonicalRootUrl(directoryUrl) : directoryUrl;', 'const canonicalDependencyDirectoryUrls = process.platform === "linux" ? dependencyDirectoryUrls.map(canonicalRootUrl) : dependencyDirectoryUrls;', @@ -1730,7 +1781,7 @@ function snapshotBootstrap(format: AcpxCommandFormat, guarded = false): string { "try {", "const metadataBefore = fs.fstatSync(moduleFd, { bigint: true });", `if (!metadataBefore.isFile() || metadataBefore.size > BigInt(${MAX_AGENT_COMMAND_BYTES})) { const error = new Error("ACPX provider module is not a bounded regular file"); error.code = "ERR_ACPX_UNVERIFIED_MODULE"; throw error; }`, - 'const openedUrl = pathToFileURL(fs.realpathSync("/proc/self/fd/" + moduleFd)).href;', + 'const openedUrl = pathToFileURL(fs.realpathSync(privateSnapshot ? fileURLToPath(url) : "/proc/self/fd/" + moduleFd)).href;', 'if (typeof canonicalRootUrl !== "string" || !openedUrl.startsWith(canonicalRootUrl)) { const error = new Error("ACPX provider module escaped descriptor-pinned ancestry"); error.code = "ERR_ACPX_UNVERIFIED_MODULE"; throw error; }', "const packageFormat = url.startsWith(directoryUrl) ? serverPackageFormat : dependencyAncestorFormats[dependencyDescriptorIndex];", "const hintedFormat = descriptorFormatByUrl.get(url) || context.format;", @@ -1743,6 +1794,7 @@ function snapshotBootstrap(format: AcpxCommandFormat, guarded = false): string { "while (moduleBytesRead < moduleBuffer.length) { const bytesRead = fs.readSync(moduleFd, moduleBuffer, moduleBytesRead, moduleBuffer.length - moduleBytesRead, moduleBytesRead); if (bytesRead === 0) break; moduleBytesRead += bytesRead; }", "const moduleSource = moduleBuffer.subarray(0, moduleBytesRead);", "const metadataAfter = fs.fstatSync(moduleFd, { bigint: true });", + "verifySnapshotBytes(fileURLToPath(url), moduleSource);", `if (moduleSource.length > ${MAX_AGENT_COMMAND_BYTES} || moduleSource.length !== admittedModuleBytes || BigInt(moduleSource.length) !== metadataAfter.size || metadataBefore.dev !== metadataAfter.dev || metadataBefore.ino !== metadataAfter.ino || metadataBefore.size !== metadataAfter.size || metadataBefore.mtimeNs !== metadataAfter.mtimeNs || metadataBefore.ctimeNs !== metadataAfter.ctimeNs) { const error = new Error("ACPX provider module changed while it was read"); error.code = "ERR_ACPX_UNVERIFIED_MODULE"; throw error; }`, "return { format: moduleFormat, source: moduleSource, shortCircuit: true };", "} finally { fs.closeSync(moduleFd); }", @@ -1756,8 +1808,9 @@ export function guardSnapshotModuleLookup( platform: NodeJS.Platform, filesystemLookup: boolean, lookup: () => T, + privateSnapshot = false, ): T { - if (platform !== "linux" && filesystemLookup) { + if (platform !== "linux" && !(platform === "darwin" && privateSnapshot) && filesystemLookup) { throw new Error( "ACPX provider relative module loading requires Linux descriptor-pinned paths", ); @@ -1954,3 +2007,10 @@ function isInside(parent: string, child: string): boolean { function isInsideOrEqual(parent: string, child: string): boolean { return resolve(parent) === resolve(child) || isInside(parent, child); } + +/** Verify the installed platform artifacts without starting a billable session. */ +export async function probeAcpxClaudeInstallation(model: string): Promise { + const installation = await verifyQualifiedAcpxInstallation(resolveQualifiedAcpxProfile("claude", model)); + const lease = await installation.openCommand(); + await lease.close(); +} diff --git a/packages/paperclip-runner/src/drivers/acpx/model-verification.test.ts b/packages/paperclip-runner/src/drivers/acpx/model-verification.test.ts index 5fee5bc04e..f1fc3a7f91 100644 --- a/packages/paperclip-runner/src/drivers/acpx/model-verification.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/model-verification.test.ts @@ -28,8 +28,8 @@ describe("ACPX qualified model verification", () => { const setModel = vi.fn(async () => undefined); const getStatus = vi.fn(async () => ({ models: { - currentModelId: "sonnet", - availableModelIds: ["default", "sonnet", "opus"], + currentModelId: "claude-sonnet-5", + availableModelIds: ["default", "claude-sonnet-5", "opus"], }, })); @@ -51,13 +51,13 @@ describe("ACPX qualified model verification", () => { it("selects Claude's profile-pinned ACP selector from a stale default", async () => { let selected = false; const setModel = vi.fn(async (model: string) => { - expect(model).toBe("sonnet"); + expect(model).toBe("claude-sonnet-5"); selected = true; }); const getStatus = vi.fn(async () => ({ models: { - currentModelId: selected ? "sonnet" : "default", - availableModelIds: ["default", "sonnet", "opus"], + currentModelId: selected ? "claude-sonnet-5" : "default", + availableModelIds: ["default", "claude-sonnet-5", "opus"], }, })); @@ -73,7 +73,7 @@ describe("ACPX qualified model verification", () => { }, }); expect(setModel).toHaveBeenCalledTimes(1); - expect(setModel).toHaveBeenCalledWith("sonnet"); + expect(setModel).toHaveBeenCalledWith("claude-sonnet-5"); expect(getStatus).toHaveBeenCalledTimes(2); }); diff --git a/packages/paperclip-runner/src/drivers/acpx/model-verification.ts b/packages/paperclip-runner/src/drivers/acpx/model-verification.ts index e178dcabe1..96232729ab 100644 --- a/packages/paperclip-runner/src/drivers/acpx/model-verification.ts +++ b/packages/paperclip-runner/src/drivers/acpx/model-verification.ts @@ -37,10 +37,7 @@ export async function requireVerifiedAcpxModel( "ACPX agent cannot verify its qualified model through ACP config options", ); } - // The caller-facing model is already pinned by resolveQualifiedAcpxProfile. - // Select the immutable ACP-facing identifier from that same profile: some - // providers expose a stable selector (for example Claude's `sonnet`) while - // Paperclip publishes the canonical model name after verification. + // Claude uses the exact requested ID, including custom IDs. await control.setModel(providerModel); status = await control.getStatus(); } diff --git a/packages/paperclip-runner/src/drivers/acpx/private-snapshot.test.ts b/packages/paperclip-runner/src/drivers/acpx/private-snapshot.test.ts new file mode 100644 index 0000000000..00d35edd93 --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/private-snapshot.test.ts @@ -0,0 +1,94 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + mkdtemp, + mkdir, + readFile, + rm, + symlink, + writeFile, + access, + open, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + createAcpxPrivateSnapshot, + MAX_ACPX_RUNTIME_EXECUTABLE_BYTES, + type AcpxPrivateSnapshot, +} from "./private-snapshot.js"; +const roots: string[] = []; +const snapshots: AcpxPrivateSnapshot[] = []; +afterEach(async () => { + await Promise.all(snapshots.splice(0).map((s) => s.close())); + await Promise.all( + roots.splice(0).map((root) => rm(root, { recursive: true, force: true })), + ); +}); +async function fixture() { + const root = await mkdtemp(join(tmpdir(), "acpx-snapshot-test-")); + roots.push(root); + const source = join(root, "provider"); + await mkdir(source); + await writeFile(join(source, "main.js"), "export const value = 1;"); + return { root, source }; +} +describe("private ACPX package snapshots", () => { + it("keeps admitted source immutable after the installation changes and cleans up", async () => { + const { source } = await fixture(); + const snapshot = await createAcpxPrivateSnapshot([source], null); + snapshots.push(snapshot); + await writeFile(join(source, "main.js"), "throw new Error('replaced')"); + expect(await readFile(join(snapshot.roots[0]!, "main.js"), "utf8")).toBe( + "export const value = 1;", + ); + expect(snapshot.digests[join(snapshot.roots[0]!, "main.js")]).toMatch( + /^[a-f0-9]{64}$/, + ); + await snapshot.close(); + await expect(access(snapshot.handoff.path)).rejects.toThrow(); + }); + it("does not grant access through links outside admitted package roots", async () => { + const { root, source } = await fixture(); + const external = join(root, "outside.js"); + await writeFile(external, "secret"); + await symlink(external, join(source, "escape.js")); + const snapshot = await createAcpxPrivateSnapshot([source], null); + snapshots.push(snapshot); + await expect( + access(join(snapshot.roots[0]!, "escape.js")), + ).rejects.toThrow(); + }); + it("rejects an oversized executable before allocating or reading it", async () => { + const { root, source } = await fixture(); + const handle = await open(join(root, "oversized-runtime"), "w+"); + await handle.truncate(MAX_ACPX_RUNTIME_EXECUTABLE_BYTES + 1); + const allocate = vi.spyOn(Buffer, "alloc"); + const read = vi.spyOn(handle, "read"); + try { + await expect(createAcpxPrivateSnapshot([source], handle)).rejects.toThrow( + "ACPX runtime executable must be a bounded executable file", + ); + expect(allocate.mock.calls.every(([size]) => size <= MAX_ACPX_RUNTIME_EXECUTABLE_BYTES)).toBe(true); + expect(read).not.toHaveBeenCalled(); + } finally { + allocate.mockRestore(); + read.mockRestore(); + await handle.close(); + } + }); + it("copies the executable from its verified open handle", async () => { + const { root, source } = await fixture(); + const exe = join(root, "runtime"); + await writeFile(exe, "verified executable"); + const handle = await open(exe); + try { + const snapshot = await createAcpxPrivateSnapshot([source], handle); + snapshots.push(snapshot); + expect(await readFile(snapshot.executable!, "utf8")).toBe( + "verified executable", + ); + } finally { + await handle.close(); + } + }); +}); diff --git a/packages/paperclip-runner/src/drivers/acpx/private-snapshot.ts b/packages/paperclip-runner/src/drivers/acpx/private-snapshot.ts new file mode 100644 index 0000000000..65076c8f2e --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/private-snapshot.ts @@ -0,0 +1,221 @@ +import { createHash } from "node:crypto"; +import { constants } from "node:fs"; +import { + chmod, + lstat, + mkdir, + mkdtemp, + open, + readdir, + realpath, + rm, + symlink, + writeFile, + type FileHandle, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, isAbsolute, join, relative, resolve } from "node:path"; + +// Match the admission limit and reserve separate space for qualified modules. +export const MAX_ACPX_RUNTIME_EXECUTABLE_BYTES = 384 * 1024 * 1024; +const MAX_PACKAGE_SNAPSHOT_BYTES = 128 * 1024 * 1024; +const MAX_SNAPSHOT_BYTES = MAX_PACKAGE_SNAPSHOT_BYTES + MAX_ACPX_RUNTIME_EXECUTABLE_BYTES; + +export const ACPX_PRIVATE_SNAPSHOT_ENV = "PAPERCLIP_ACPX_PRIVATE_SNAPSHOT"; +export interface AcpxPrivateSnapshot { + roots: string[]; + executable: string | null; + digests: Record; + handoff: { path: string; digest: string }; + close(): Promise; +} +const digest = (bytes: Buffer) => + createHash("sha256").update(bytes).digest("hex"); +const within = (root: string, file: string) => { + const rel = relative(root, file); + return ( + rel === "" || (rel !== ".." && !rel.startsWith("../") && !isAbsolute(rel)) + ); +}; + +async function readSnapshotBytes(handle: FileHandle, byteLength: number): Promise { + const bytes = Buffer.alloc(byteLength); + let offset = 0; + while (offset < bytes.length) { + const read = await handle.read(bytes, offset, bytes.length - offset, offset); + if (!read.bytesRead) throw new Error("ACPX file ended during snapshot"); + offset += read.bytesRead; + } + return bytes; +} + +/** macOS has no /proc directory descriptors. Freeze only the admitted package roots. */ +export async function createAcpxPrivateSnapshot( + sourceRoots: readonly string[], + executable: FileHandle | null, +): Promise { + sourceRoots = await Promise.all(sourceRoots.map((root) => realpath(root))); + const sourceIdentities = await Promise.all( + sourceRoots.map((root) => lstat(root, { bigint: true })), + ); + const directory = await realpath( + await mkdtemp(join(tmpdir(), "paperclip-acpx-")), + ); + const roots = sourceRoots.map((_, index) => join(directory, String(index))); + const digests: Record = {}; + const directories: string[] = [directory]; + let bytesCopied = 0; + let filesCopied = 0; + const same = ( + a: (typeof sourceIdentities)[number], + b: (typeof sourceIdentities)[number], + ) => + a.dev === b.dev && + a.ino === b.ino && + a.size === b.size && + a.mtimeNs === b.mtimeNs && + a.ctimeNs === b.ctimeNs; + const close = async () => { + for (const dir of directories) + await chmod(dir, 0o700).catch(() => undefined); + await rm(directory, { recursive: true, force: true }); + }; + const mapPath = (source: string): string | null => { + const candidates = sourceRoots + .map((root, index) => ({ root, index })) + .filter(({ root }) => within(root, source)) + .sort((a, b) => b.root.length - a.root.length); + const match = candidates[0]; + return match + ? resolve(roots[match.index]!, relative(match.root, source)) + : null; + }; + const copy = async ( + source: string, + target: string, + root: string, + ): Promise => { + if (++filesCopied > 30_000) + throw new Error("ACPX package snapshot exceeds its file bound"); + const before = await lstat(source, { bigint: true }); + if (before.isSymbolicLink()) { + const canonical = await realpath(source); + const mapped = mapPath(canonical); + // Package-manager links to unqualified packages do not grant import authority. + if (mapped) await symlink(mapped, target); + return; + } + if (!within(root, await realpath(source))) + throw new Error("ACPX snapshot escaped its package"); + if (before.isDirectory()) { + await mkdir(target, { mode: 0o700 }); + directories.push(target); + for (const entry of await readdir(source)) + await copy(join(source, entry), join(target, entry), root); + if (!same(before, await lstat(source, { bigint: true }))) + throw new Error("ACPX package directory changed during snapshot"); + return; + } + if (!before.isFile() || before.size > 16n * 1024n * 1024n) + throw new Error("ACPX module must be a bounded regular file"); + bytesCopied += Number(before.size); + if (bytesCopied > MAX_PACKAGE_SNAPSHOT_BYTES) + throw new Error("ACPX package snapshot exceeds its byte bound"); + const handle = await open( + source, + constants.O_RDONLY | constants.O_NOFOLLOW, + ); + try { + if (!same(before, await handle.stat({ bigint: true }))) + throw new Error("ACPX module changed before snapshot"); + const bytes = await readSnapshotBytes(handle, Number(before.size)); + if ( + !same(before, await handle.stat({ bigint: true })) || + !same(before, await lstat(source, { bigint: true })) + ) { + throw new Error("ACPX module changed during snapshot"); + } + await writeFile(target, bytes, { flag: "wx", mode: 0o400 }); + digests[target] = digest(bytes); + } finally { + await handle.close(); + } + }; + try { + for (let index = 0; index < sourceRoots.length; index++) { + await copy(sourceRoots[index]!, roots[index]!, sourceRoots[index]!); + if ( + !same( + sourceIdentities[index]!, + await lstat(sourceRoots[index]!, { bigint: true }), + ) + ) { + throw new Error("ACPX package root changed during snapshot"); + } + } + // Supply bare-package lookup links only for already admitted package roots. + const packages: Array<{ name: string; root: string }> = []; + for (const root of roots) { + const file = await open(join(root, "package.json")).catch(() => null); + if (!file) continue; + try { + const metadata = JSON.parse(await file.readFile("utf8")); + if ( + typeof metadata.name === "string" && + /^(?:@[a-z0-9._-]+\/)?[a-z0-9._-]+$/i.test(metadata.name) + ) { + packages.push({ name: metadata.name, root }); + } + } finally { + await file.close(); + } + } + for (const root of roots) + for (const pkg of packages) { + const target = join(root, "node_modules", pkg.name); + await mkdir(dirname(target), { recursive: true, mode: 0o700 }); + // Include generated directories in cleanup and read-only sealing. + directories.push(join(root, "node_modules"), dirname(target)); + await symlink(pkg.root, target).catch( + (error: NodeJS.ErrnoException) => { + if (error.code !== "EEXIST") throw error; + }, + ); + } + let executablePath: string | null = null; + if (executable) { + const before = await executable.stat({ bigint: true }); + if (!before.isFile() || before.size < 1n || before.size > BigInt(MAX_ACPX_RUNTIME_EXECUTABLE_BYTES)) { + throw new Error("ACPX runtime executable must be a bounded executable file"); + } + // The bigint bound above makes this conversion exact before allocation. + const executableBytes = Number(before.size); + bytesCopied += executableBytes; + if (bytesCopied > MAX_SNAPSHOT_BYTES) { + throw new Error("ACPX snapshot exceeds its byte bound"); + } + const bytes = await readSnapshotBytes(executable, executableBytes); + if (!same(before, await executable.stat({ bigint: true }))) + throw new Error("ACPX executable changed during snapshot"); + executablePath = join(directory, "runtime"); + await writeFile(executablePath, bytes, { flag: "wx", mode: 0o500 }); + digests[executablePath] = digest(bytes); + } + const manifest = Buffer.from( + JSON.stringify({ roots, executable: executablePath, digests }), + ); + const manifestPath = join(directory, "manifest.json"); + await writeFile(manifestPath, manifest, { flag: "wx", mode: 0o400 }); + for (const dir of new Set(directories)) await chmod(dir, 0o500); + return { + roots, + executable: executablePath, + digests, + handoff: { path: manifestPath, digest: digest(manifest) }, + close, + }; + } catch (error) { + await close(); + throw error; + } +} diff --git a/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.test.ts b/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.test.ts index 09a79ac021..6451a369f5 100644 --- a/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.test.ts @@ -18,6 +18,13 @@ describe("qualified ACPX profiles", () => { } }); + it.each(["claude-opus-5", "custom-model-not-in-catalog"])("accepts the exact Claude model %s", (model) => { + expect(resolveQualifiedAcpxProfile("claude", model)).toMatchObject({ + qualificationModel: model, reportedModelId: model, + commandDigest: QUALIFIED_ACPX_PROFILES.claude.commandDigest, + }); + }); + it("rejects unqualified model substitutions", () => { expect(() => resolveQualifiedAcpxProfile("codex", "some-other-model"), diff --git a/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.ts b/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.ts index ac0dee4db1..753d5fbcf7 100644 --- a/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.ts +++ b/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.ts @@ -18,22 +18,14 @@ export interface QualifiedAcpxProfile { readonly agentRuntimeVersion: string | null; readonly commandDigest: string; readonly qualificationModel: string; - /** - * Model identifier the pinned ACP server accepts and reports. Profile - * resolution first binds the caller's exact canonical model request. Most - * agents use that same identifier at the ACP boundary; Claude exposes its - * stable SDK selector (`sonnet`) while the SDK resolves it to the canonical - * wire model (`claude-sonnet-5`). Paperclip selects only this profile-pinned - * identifier and verifies the provider reports it before publishing the - * canonical model as the qualified effective model. - */ + /** Exact model ID sent to ACP; catalogs are suggestions, not an allowlist. */ readonly reportedModelId: string; readonly permissionPolicy: "interactive"; } /** * Digests bind the closed profile declaration (package, version, runtime and - * model), not a caller-controlled executable. The environment probe separately + * executable), not a caller-controlled executable. The environment probe separately * verifies the resolved package files before a billable prompt is admitted. */ export const QUALIFIED_ACPX_PROFILES: Readonly< @@ -68,7 +60,7 @@ export const QUALIFIED_ACPX_PROFILES: Readonly< commandDigest: "sha256:9d73d1f0f121fb96cc8badb28c22d5bff02d8582eb2e40360a81c189e1b9422a", qualificationModel: "claude-sonnet-5", - reportedModelId: "sonnet", + reportedModelId: "claude-sonnet-5", permissionPolicy: "interactive", }, codex: { @@ -94,12 +86,13 @@ export function resolveQualifiedAcpxProfile( requestedModel: string, ): QualifiedAcpxProfile { const profile = QUALIFIED_ACPX_PROFILES[agent]; - if (requestedModel !== profile.qualificationModel) { + if (!requestedModel.trim()) throw new Error("ACPX model must not be empty"); + if (agent !== "claude" && requestedModel !== profile.qualificationModel) { throw new Error( `ACPX ${agent} profile requires exact model ${profile.qualificationModel}; received ${requestedModel}`, ); } - return structuredClone(profile); + return { ...structuredClone(profile), qualificationModel: requestedModel, reportedModelId: requestedModel }; } function deepFreeze(value: T): T { diff --git a/packages/paperclip-runner/src/drivers/acpx/runtime-host.test.ts b/packages/paperclip-runner/src/drivers/acpx/runtime-host.test.ts index 5fb1a709e1..76fb5a38b3 100644 --- a/packages/paperclip-runner/src/drivers/acpx/runtime-host.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/runtime-host.test.ts @@ -551,14 +551,14 @@ describe("ACPX runtime host", () => { const fixture = await hostFixture(); let selected = false; const setModel = vi.fn(async (model: string) => { - expect(model).toBe("sonnet"); + expect(model).toBe("claude-sonnet-5"); selected = true; }); const runtime = runtimePort({ getStatus: async () => ({ models: { - currentModelId: selected ? "sonnet" : "default", - availableModelIds: ["default", "sonnet"], + currentModelId: selected ? "claude-sonnet-5" : "default", + availableModelIds: ["default", "claude-sonnet-5"], }, }), setModel, diff --git a/packages/paperclip-runner/src/live/index.ts b/packages/paperclip-runner/src/live/index.ts index 2ae16f01d4..c74c4ef5b1 100644 --- a/packages/paperclip-runner/src/live/index.ts +++ b/packages/paperclip-runner/src/live/index.ts @@ -4,3 +4,5 @@ export * from "./live-session.js"; export * from "./durable-live-session-store.js"; export * from "./runnerd-codex-transport.js"; export * from "./turn-stream.js"; + +export { probeAcpxClaudeInstallation } from "../drivers/acpx/installation-integrity.js"; diff --git a/packages/paperclip-runner/src/native-session-runtime.test.ts b/packages/paperclip-runner/src/native-session-runtime.test.ts index 7326091e55..57046c6a4b 100644 --- a/packages/paperclip-runner/src/native-session-runtime.test.ts +++ b/packages/paperclip-runner/src/native-session-runtime.test.ts @@ -751,6 +751,32 @@ describe("executeNativeSession recovery", () => { }); }); + it("surfaces the provider's model rejection instead of missing semantic completion", async () => { + const capabilities = { resume: true, typedEvents: true, steering: false, interruption: false, structuredResult: true }; + const close = vi.fn(async () => {}); + const session: NativeSession = { + identity: () => identity, + async capabilities() { return capabilities; }, + async *events() { yield runnerEvent(1, "turn.failed", { error: { code: "RUNTIME", message: "There's an issue with the selected model (custom-model). It may not exist or you may not have access to it." } }); }, + async startTurn() { return { turnId: "turn-recovery" }; }, + async result() { return null; }, + async snapshot() { return { backendKind: "mock", sessionId: "driver-recovery", identity, providerSessionId: "provider-recovery", cursor: null, activeTurnId: null, pendingRuntimeRequests: [], lineage: [] }; }, + close, + }; + const backend: NativeSessionBackend = { + async descriptor() { return { kind: "mock", name: "model-rejection", version: "1", capabilities }; }, + async openSession() { return session; }, + }; + const port: ControlPlanePort = { + async openRun() {}, async checkpointSession() {}, + async appendEvent() { return { cursor: 1, highestContiguousSourceSeq: 1, disposition: "committed" }; }, + async replayEvents() { return { events: [], highestContiguousSourceSeq: 0 }; }, + async completeRun() {}, + }; + await expect(executeNativeSession({ input, backend, controlPlane: port, runnerInstanceId: "runner-recovery", controlPlaneInstanceId: "control-recovery" })).rejects.toThrow("native_provider_model_rejected: There's an issue with the selected model (custom-model)"); + expect(close).toHaveBeenCalled(); + }); + it("keeps governed-wait discovery synchronous", () => { type GovernedWaitResolver = NonNullable< ExecuteNativeSessionOptions["resolveGovernedWait"] diff --git a/packages/paperclip-runner/src/native-session-runtime.ts b/packages/paperclip-runner/src/native-session-runtime.ts index 444b2d4001..47ad6289d9 100644 --- a/packages/paperclip-runner/src/native-session-runtime.ts +++ b/packages/paperclip-runner/src/native-session-runtime.ts @@ -2304,6 +2304,13 @@ export async function executeNativeSession( completed = settledCompletion; } if (settledCompletion === null) { + if (consumed.event?.eventType === "turn.failed") { + const providerError = objectRecord(objectRecord(consumed.event.payload)?.error); + const message = typeof providerError?.message === "string" + ? providerError.message.slice(0, 2_000) : "Provider turn failed"; + const modelRejected = /issue with the selected model|model_not_found|invalid model|model[^\n]*(?:does not exist|not found|not supported)/i.test(message); + throw new Error(`${modelRejected ? "native_provider_model_rejected" : "native_provider_turn_failed"}: ${message}`); + } throw new Error( "native_finalization_missing: session returned no semantic result", ); diff --git a/patches/@agentclientprotocol__claude-agent-acp@0.70.0.patch b/patches/@agentclientprotocol__claude-agent-acp@0.70.0.patch index f1d54bb069..61e20c5692 100644 --- a/patches/@agentclientprotocol__claude-agent-acp@0.70.0.patch +++ b/patches/@agentclientprotocol__claude-agent-acp@0.70.0.patch @@ -49,6 +49,18 @@ diff --git a/dist/acp-agent.js b/dist/acp-agent.js ...mcpServers, ...(fileChangeAuditSupport ? { [FILE_CHANGE_AUDIT_SERVER_NAME]: fileChangeAuditSupport.mcpServer } +@@ -3776,6 +3776,11 @@ + ? option.options.flatMap((o) => ("options" in o ? o.options : [o])) + : []; + let validValue = allValues.find((o) => o.value === params.value); ++ // Paperclip's model field is an exact provider request, not a fuzzy picker ++ // search. Forward custom IDs to the SDK and let the provider reject them. ++ if (params.configId === MODEL_CONFIG_ID && process.env.PAPERCLIP_ACPX_ISOLATED_CONTEXT === "1" && params.value.trim()) { ++ validValue = { value: params.value, name: params.value }; ++ } + // The option's reported currentValue is always a valid target, even when + // it has no options entry: a session running an out-of-picker model + // (resumed onto an allowlist-excluded model, or a refusal fallback) diff --git a/package.json b/package.json --- a/package.json +++ b/package.json diff --git a/patches/acpx@0.13.1.patch b/patches/acpx@0.13.1.patch index ac8a2c255c..7d1f380b01 100644 --- a/patches/acpx@0.13.1.patch +++ b/patches/acpx@0.13.1.patch @@ -235,10 +235,24 @@ index a1f4a70a003792c6eacf68b6b038f37bfec1db53..50029e881c07a7228ddd978bb03d0406 client_operation: clientOperationEvent, update: updateStatusEvent, done: () => null, -@@ -424,6 +424,20 @@ function availableCommandsUpdateEvent(payload) { +@@ -424,6 +424,34 @@ function availableCommandsUpdateEvent(payload) { availableCommands }; } ++function persistedGoalCapability(goal) { ++ if (!isRecord(goal) || goal.version !== 1 || goal.controlMethod !== "_session/goal" || !Array.isArray(goal.actions)) return; ++ const actions = goal.actions.filter((action) => ["set", "pause", "resume", "clear"].includes(action)); ++ if (!actions.includes("set") || !actions.includes("clear")) return; ++ return { version: 1, control_method: goal.controlMethod, actions }; ++} ++function restoredGoalCapability(goal) { ++ if (!isRecord(goal)) return; ++ const canonical = persistedGoalCapability({ ...goal, controlMethod: goal.control_method ?? goal.controlMethod }); ++ if (!canonical) return; ++ return { version: canonical.version, controlMethod: canonical.control_method, actions: canonical.actions }; ++} ++ ++ +function planUpdateEvent(payload) { + const raw = Array.isArray(payload.entries) ? payload.entries : []; + const entries = []; diff --git a/scripts/acpx-patch-packaging.test.mjs b/scripts/acpx-patch-packaging.test.mjs index 764a56ec47..57eea66019 100644 --- a/scripts/acpx-patch-packaging.test.mjs +++ b/scripts/acpx-patch-packaging.test.mjs @@ -13,6 +13,8 @@ import { readFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; +import { createRequire } from "node:module"; +import { runInNewContext } from "node:vm"; import cliEsbuildConfig from "../cli/esbuild.config.mjs"; import { bundledCliNpmDependencies } from "./cli-bundled-npm-dependencies.mjs"; @@ -342,3 +344,23 @@ test("npm builds use corepack instead of requiring a global pnpm", () => { assert.match(buildNpmScript, /corepack pnpm -r typecheck/); assert.doesNotMatch(buildNpmScript, /^\s*pnpm -r typecheck/m); }); + + +test("installed ACPX runtime persists and restores optional goal capabilities", () => { + const requireRunner = createRequire(new URL("../packages/paperclip-runner/package.json", import.meta.url)); + const runtimeSource = readFileSync(requireRunner.resolve("acpx/runtime"), "utf8"); + const start = runtimeSource.indexOf("function persistedGoalCapability("); + const end = runtimeSource.indexOf("function planUpdateEvent(", start); + assert.ok(start >= 0 && end > start, "the installed patch must define both goal helpers"); + const helpers = runInNewContext(runtimeSource.slice(start, end) + ";({ persistedGoalCapability, restoredGoalCapability })", { + isRecord: (value) => value !== null && typeof value === "object" && !Array.isArray(value), + }); + assert.equal(helpers.persistedGoalCapability(undefined), undefined); + assert.equal(helpers.restoredGoalCapability(undefined), undefined); + const goal = { version: 1, controlMethod: "_session/goal", actions: ["set", "pause", "clear"] }; + const saved = JSON.parse(JSON.stringify(helpers.persistedGoalCapability(goal))); + assert.equal(saved.control_method, "_session/goal"); + assert.deepEqual(JSON.parse(JSON.stringify(helpers.restoredGoalCapability(saved))), goal); + assert.equal(helpers.persistedGoalCapability({ ...goal, version: 2 }), undefined); + assert.equal(helpers.persistedGoalCapability({ ...goal, actions: ["set"] }), undefined); +}); diff --git a/server/src/__tests__/adapter-registry.test.ts b/server/src/__tests__/adapter-registry.test.ts index c4f830c2b6..1da4fcdc4d 100644 --- a/server/src/__tests__/adapter-registry.test.ts +++ b/server/src/__tests__/adapter-registry.test.ts @@ -1,3 +1,4 @@ +import { probeAcpxClaudeInstallation } from "@paperclipai/paperclip-runner/live"; import { describe, expect, it, beforeEach, afterEach, vi } from "vitest"; import { buildSandboxNpmInstallCommand } from "@paperclipai/adapter-utils"; import type { ServerAdapterModule } from "../adapters/index.js"; @@ -16,6 +17,8 @@ import { setOverridePaused, } from "../adapters/registry.js"; +vi.mock("@paperclipai/paperclip-runner/live", () => ({ probeAcpxClaudeInstallation: vi.fn(async () => undefined) })); + const externalAdapter: ServerAdapterModule = { type: "external_test", execute: async () => ({ @@ -276,8 +279,7 @@ describe("server adapter registry", () => { it.each([ ["claude", "claude-sonnet-5"], - ["codex", "gpt-5.6-sol"], - ] as const)("accepts the qualified remote ACPX %s environment profile", async (acpxAgent, model) => { + ] as const)("does not claim runtime readiness from the remote ACPX %s platform alone", async (acpxAgent, model) => { const result = await requireServerAdapter("paperclip_runner").testEnvironment({ companyId: "company-1", adapterType: "paperclip_runner", @@ -293,31 +295,24 @@ describe("server adapter registry", () => { expect(result).toMatchObject({ adapterType: "paperclip_runner", - status: "pass", - checks: [{ code: "acpx_profile_qualified", level: "info" }], + status: "warn", + checks: [{ code: "acpx_remote_runtime_unverified", level: "warn" }], }); }); - it.each([ - ["linux", "x64", "pass", "acpx_profile_qualified"], - ["darwin", "arm64", "fail", "acpx_runtime_platform_unsupported"], - ["linux", "arm64", "fail", "acpx_runtime_platform_unsupported"], - ])("checks local ACPX support on %s %s", async (platform, arch, status, code) => { - const platformDescriptor = Object.getOwnPropertyDescriptor(process, "platform")!; - const archDescriptor = Object.getOwnPropertyDescriptor(process, "arch")!; - try { - Object.defineProperty(process, "platform", { ...platformDescriptor, value: platform }); - Object.defineProperty(process, "arch", { ...archDescriptor, value: arch }); - const result = await requireServerAdapter("paperclip_runner").testEnvironment({ - companyId: "company-1", - adapterType: "paperclip_runner", - config: { provider: "acpx", acpxAgent: "claude", model: "claude-sonnet-5" }, - }); - expect(result).toMatchObject({ status, checks: [expect.objectContaining({ code })] }); - } finally { - Object.defineProperty(process, "platform", platformDescriptor); - Object.defineProperty(process, "arch", archDescriptor); - } + it.each([true, false])("checks actual local ACPX installation readiness (%s)", async (ready) => { + const probe = vi.mocked(probeAcpxClaudeInstallation); + if (ready) probe.mockResolvedValueOnce(undefined); + else probe.mockRejectedValueOnce(new Error("Runtime package integrity verification failed")); + const result = await requireServerAdapter("paperclip_runner").testEnvironment({ + companyId: "company-1", adapterType: "paperclip_runner", + config: { provider: "acpx", acpxAgent: "claude", model: "custom-claude-model" }, + }); + expect(probe).toHaveBeenLastCalledWith("custom-claude-model"); + expect(result).toMatchObject({ + status: ready ? "pass" : "fail", + checks: [expect.objectContaining({ code: ready ? "acpx_runtime_ready" : "acpx_runtime_unavailable" })], + }); }); it("keeps the ACPX Pi profile unavailable", async () => { diff --git a/server/src/__tests__/adapter-routes.test.ts b/server/src/__tests__/adapter-routes.test.ts index ff5397b963..a0de71aa3d 100644 --- a/server/src/__tests__/adapter-routes.test.ts +++ b/server/src/__tests__/adapter-routes.test.ts @@ -365,14 +365,6 @@ describe("adapter routes", () => { default: "approve-reads", meta: { visibleWhen: { key: "provider", value: "acpx" } }, }), - expect.objectContaining({ - key: "acpxAgent", - options: [ - expect.objectContaining({ value: "claude" }), - expect.objectContaining({ value: "codex" }), - ], - meta: { visibleWhen: { key: "provider", value: "acpx" } }, - }), expect.objectContaining({ key: "model", meta: { visibleWhen: { key: "provider", value: "opencode" } }, @@ -383,7 +375,9 @@ describe("adapter routes", () => { }), ])); const acpxAgent = res.body.fields.find((field: { key?: string }) => field.key === "acpxAgent"); - expect(acpxAgent.options).not.toContainEqual(expect.objectContaining({ value: "pi" })); + expect(acpxAgent).toBeUndefined(); + expect(JSON.stringify(res.body)).toContain("ACPX Claude"); + expect(JSON.stringify(res.body)).not.toContain("Codex via ACPX"); }); it("serves the built-in claude_local ACP engine config schema", async () => { diff --git a/server/src/__tests__/agent-adapter-validation-routes.test.ts b/server/src/__tests__/agent-adapter-validation-routes.test.ts index 25c0e21781..918560aaa1 100644 --- a/server/src/__tests__/agent-adapter-validation-routes.test.ts +++ b/server/src/__tests__/agent-adapter-validation-routes.test.ts @@ -337,6 +337,25 @@ describe("agent routes adapter validation", () => { await unregisterTestAdapter(missingAdapterType); }); + it("selects and refreshes the runner provider catalog independently", async () => { + const adapters = await import("../adapters/index.js"); + const list = vi.spyOn(adapters, "listAdapterModels").mockImplementation(async (type) => [{ id: type, label: type }]); + const refresh = vi.spyOn(adapters, "refreshAdapterModels").mockImplementation(async (type) => [{ id: `${type}-fresh`, label: type }]); + try { + const app = await createApp(); + for (const [provider, adapter] of [["acpx", "claude_local"], ["codex", "codex_local"], ["opencode", "opencode_local"]]) { + const res = await requestApp(app, (baseUrl) => request(baseUrl).get(`/api/companies/company-1/adapters/paperclip_runner/models?provider=${provider}`)); + expect(res.status).toBe(200); + expect(res.body).toEqual([{ id: adapter, label: adapter }]); + const refreshed = await requestApp(app, (baseUrl) => request(baseUrl).get(`/api/companies/company-1/adapters/paperclip_runner/models?provider=${provider}&refresh=true`)); + expect(refreshed.status).toBe(200); + expect(refreshed.body).toEqual([{ id: `${adapter}-fresh`, label: adapter }]); + } + const invalid = await requestApp(app, (baseUrl) => request(baseUrl).get("/api/companies/company-1/adapters/paperclip_runner/models?provider=acpx_codex")); + expect(invalid.status).toBe(422); + } finally { list.mockRestore(); refresh.mockRestore(); } + }); + it("creates agents for dynamically registered external adapter types", async () => { const { registerServerAdapter } = await import("../adapters/index.js"); registerServerAdapter(externalAdapter); @@ -719,7 +738,7 @@ describe("agent routes adapter validation", () => { ); }); - it("rejects conversion from an unsupported provider family", async () => { + it("converts Claude to ACPX Claude while retaining its model", async () => { mockInstanceSettingsService.getExperimental.mockResolvedValue({ enableNativeRunner: true }); const existing = await mockAgentService.getById(); mockAgentService.getById.mockResolvedValue({ @@ -739,11 +758,8 @@ describe("agent routes adapter validation", () => { }), ); - expect(res.status, JSON.stringify(res.body)).toBe(422); - expect(res.body.details).toMatchObject({ - code: "paperclip_runner_adapter_conversion_unsupported", - }); - expect(mockAgentService.update).not.toHaveBeenCalled(); + expect(res.status, JSON.stringify(res.body)).toBe(200); + expect(res.body.adapterConfig).toMatchObject({ provider: "acpx", acpxAgent: "claude", model: "claude-sonnet-4-6" }); }); it("accepts qualified local and managed providers on fresh runner agents and hires", async () => { @@ -902,7 +918,7 @@ describe("agent routes adapter validation", () => { }, ); - it("rejects provider changes but preserves edits to historical runner agents", async () => { + it("defaults ACPX provider changes to Claude and preserves ordinary historical edits", async () => { const existing = await mockAgentService.getById(); mockAgentService.getById.mockResolvedValue({ ...existing, @@ -922,10 +938,8 @@ describe("agent routes adapter validation", () => { ); expect(ordinaryEdit.status, JSON.stringify(ordinaryEdit.body)).toBe(200); - expect(providerChange.status, JSON.stringify(providerChange.body)).toBe(422); - expect(providerChange.body.details).toMatchObject({ - code: "paperclip_runner_acpx_agent_unavailable", - }); + expect(providerChange.status, JSON.stringify(providerChange.body)).toBe(200); + expect(providerChange.body.adapterConfig).toMatchObject({ provider: "acpx", acpxAgent: "claude", model: "historical" }); }); it.each([ diff --git a/server/src/__tests__/agents-service-clear-error.test.ts b/server/src/__tests__/agents-service-clear-error.test.ts index 0e58cb27d0..26334aa4b5 100644 --- a/server/src/__tests__/agents-service-clear-error.test.ts +++ b/server/src/__tests__/agents-service-clear-error.test.ts @@ -4,6 +4,7 @@ import { eq } from "drizzle-orm"; import { agents, agentRuntimeState, + agentTaskSessions, companies, createDb, heartbeatRunEvents, @@ -35,6 +36,7 @@ describeEmbeddedPostgres("agent service clearError", () => { afterEach(async () => { await db.delete(heartbeatRunEvents); + await db.delete(agentTaskSessions); await db.delete(agentRuntimeState); await db.delete(heartbeatRuns); await db.delete(agents); @@ -45,6 +47,25 @@ describeEmbeddedPostgres("agent service clearError", () => { await tempDb?.cleanup(); }); + it("resets converted agent sessions while preserving identity, configuration and run history", async () => { + const companyId = randomUUID(); + const agentId = randomUUID(); + const runId = randomUUID(); + const config = { cwd: "/tmp/runner-conversion", instructionsFilePath: "/tmp/runner-conversion/AGENTS.md", env: { TEST_KEY: { type: "plain", value: "kept" } } }; + await db.insert(companies).values({ id: companyId, name: "Conversion", issuePrefix: `T${companyId.slice(0, 6).toUpperCase()}` }); + await db.insert(agents).values({ id: agentId, companyId, name: "Claude QA", role: "engineer", adapterType: "claude_local", adapterConfig: config }); + await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, invocationSource: "on_demand", status: "succeeded", resultJson: { summary: "history stays" } }); + await db.insert(agentTaskSessions).values({ companyId, agentId, adapterType: "claude_local", taskKey: "issue:test", sessionDisplayId: "old-session", lastRunId: runId }); + await db.insert(agentRuntimeState).values({ companyId, agentId, adapterType: "claude_local", sessionId: "old-session", stateJson: { old: true }, lastRunId: runId }); + const updated = await agentService(db).update(agentId, { adapterType: "paperclip_runner", adapterConfig: { ...config, provider: "acpx", acpxAgent: "claude", model: "custom-claude-model" } }); + expect(updated).toMatchObject({ id: agentId, companyId, name: "Claude QA", role: "engineer", adapterConfig: config }); + expect(await db.select().from(agentTaskSessions).where(eq(agentTaskSessions.agentId, agentId))).toEqual([]); + const [runtime] = await db.select().from(agentRuntimeState).where(eq(agentRuntimeState.agentId, agentId)); + expect(runtime).toMatchObject({ adapterType: "paperclip_runner", sessionId: null, stateJson: {}, lastRunId: runId }); + const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId)); + expect(run).toMatchObject({ status: "succeeded", resultJson: { summary: "history stays" } }); + }); + it("moves an error agent to idle without deleting run history or runtime diagnostics", async () => { const companyId = randomUUID(); const agentId = randomUUID(); diff --git a/server/src/adapters/registry.test.ts b/server/src/adapters/registry.test.ts index 85a8c23e7b..4f078588fc 100644 --- a/server/src/adapters/registry.test.ts +++ b/server/src/adapters/registry.test.ts @@ -1,9 +1,12 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { assertValidAdapterLoginCapability } from "@paperclipai/adapter-utils"; import { listServerAdapters, requireServerAdapter } from "./registry.js"; import * as executionTarget from "@paperclipai/adapter-utils/execution-target"; import { BUILTIN_ADAPTER_TYPES } from "./builtin-adapter-types.js"; +const { probeInstallation } = vi.hoisted(() => ({ probeInstallation: vi.fn() })); +vi.mock("@paperclipai/paperclip-runner/live", () => ({ probeAcpxClaudeInstallation: probeInstallation })); + // The registry registers a login capability for the two built-in interactive // adapters. The test checks the scalar values and the presence of the required // callbacks. It also runs the shared validator, so the built-in capabilities @@ -83,6 +86,7 @@ describe("built-in runtime connection tool delivery", () => { describe("native ACPX environment checks", () => { + beforeEach(() => { probeInstallation.mockReset().mockResolvedValue(undefined); }); afterEach(() => vi.restoreAllMocks()); const context = { @@ -92,20 +96,19 @@ describe("native ACPX environment checks", () => { }; it("reports unsupported local platforms before a successful CLI login can mask them", async () => { - vi.spyOn(process, "platform", "get").mockReturnValue("darwin"); + probeInstallation.mockRejectedValue(new Error("ACPX Claude requires a supported runtime platform")); const result = await requireServerAdapter("paperclip_runner").testEnvironment!(context); expect(result.status).toBe("fail"); expect(result.checks).toEqual([expect.objectContaining({ - code: "acpx_runtime_platform_unsupported", + code: "acpx_runtime_unavailable", level: "error", })]); }); - it("keeps the qualified Linux x64 profile available", async () => { - vi.spyOn(process, "platform", "get").mockReturnValue("linux"); - vi.spyOn(process, "arch", "get").mockReturnValue("x64"); + it("requires a successful installed runtime probe", async () => { const result = await requireServerAdapter("paperclip_runner").testEnvironment!(context); expect(result.status).toBe("pass"); + expect(probeInstallation).toHaveBeenCalledWith(context.config.model); }); it("does not use the host platform to reject a remote environment", async () => { @@ -117,7 +120,9 @@ describe("native ACPX environment checks", () => { runner: { execute: vi.fn().mockResolvedValue({ exitCode: 0, timedOut: false, stdout: "Linux\nx86_64\n" }) }, }, }); - expect(result.status).toBe("pass"); + expect(result.status).toBe("warn"); + expect(result.checks[0].code).toBe("acpx_remote_runtime_unverified"); + expect(probeInstallation).not.toHaveBeenCalled(); }); const sshTarget = { @@ -129,8 +134,9 @@ describe("native ACPX environment checks", () => { }; it.each([ - ["Linux\nx86_64\n", "pass"], - ["Darwin\nx86_64\n", "fail"], + ["Linux\nx86_64\n", "warn"], + ["Darwin\nx86_64\n", "warn"], + ["Darwin\narm64\n", "warn"], ["Linux\naarch64\n", "fail"], ["", "fail"], ])("qualifies the SSH platform from its own uname output %j", async (stdout, status) => { @@ -155,6 +161,6 @@ describe("native ACPX environment checks", () => { }); const result = await requireServerAdapter("paperclip_runner").testEnvironment!({ ...context, executionTarget: sshTarget }); expect(result.status).toBe("fail"); - expect(result.checks[0].code).toBe("acpx_runtime_platform_unverified"); + expect(result.checks[0].code).toBe("acpx_runtime_unavailable"); }); }); diff --git a/server/src/adapters/registry.ts b/server/src/adapters/registry.ts index c926b227cc..96f97b5885 100644 --- a/server/src/adapters/registry.ts +++ b/server/src/adapters/registry.ts @@ -404,58 +404,37 @@ const paperclipRunnerAdapter: ServerAdapterModule = { }; } if (profile.provider === "acpx") { - // The pinned ACPX executables are qualified for Linux x64. A host CLI - // login probe can succeed on macOS even though runner admission cannot. - let supported = process.platform === "linux" && process.arch === "x64"; - const target = context.executionTarget; - if (target?.kind === "remote") { - try { + try { + if (profile.acpxAgent !== "claude") throw new Error("Select Codex to use the native Codex runner."); + const target = context.executionTarget; + if (target?.kind === "remote") { const probe = await runAdapterExecutionTargetShellCommand( - `acpx-platform-${crypto.randomUUID()}`, - target, - "uname -s && uname -m", + `acpx-platform-${crypto.randomUUID()}`, target, "uname -s && uname -m", { cwd: target.remoteCwd, env: {}, timeoutSec: 15 }, ); - if (probe.timedOut || probe.exitCode !== 0) throw new Error("Platform probe failed"); + if (probe.timedOut || probe.exitCode !== 0) throw new Error("Could not verify the remote ACPX runner platform."); const [os, arch] = probe.stdout.trim().split(/\s+/); - supported = os === "Linux" && arch === "x86_64"; - } catch { + if (!((os === "Linux" && arch === "x86_64") || (os === "Darwin" && ["arm64", "x86_64"].includes(arch ?? "")))) { + throw new Error("ACPX Claude requires Linux x64 or macOS ARM64/x64."); + } return { - adapterType: "paperclip_runner", - status: "fail" as const, - testedAt: new Date().toISOString(), - checks: [{ - code: "acpx_runtime_platform_unverified", - level: "error" as const, - message: "Could not verify the remote ACPX runner platform.", - hint: "Check the environment connection and retry. The native ACPX runner requires Linux x64.", - }], + adapterType: "paperclip_runner", status: "warn" as const, testedAt: new Date().toISOString(), + checks: [{ code: "acpx_remote_runtime_unverified", level: "warn" as const, + message: "The remote platform is supported. Runtime package integrity and readiness must still be verified by the remote runner before launch." }], }; } - } - if (!supported) { + const { probeAcpxClaudeInstallation } = await import("@paperclipai/paperclip-runner/live"); + await probeAcpxClaudeInstallation(profile.model); return { - adapterType: "paperclip_runner", - status: "fail" as const, - testedAt: new Date().toISOString(), - checks: [{ - code: "acpx_runtime_platform_unsupported", - level: "error" as const, - message: `The native ACPX ${profile.acpxAgent} runner requires a Linux x64 environment.`, - hint: `Select a Linux x64 environment, or use the regular ${profile.acpxAgent === "claude" ? "Claude Code" : "Codex"} adapter on this machine.`, - }], + adapterType: "paperclip_runner", status: "pass" as const, testedAt: new Date().toISOString(), + checks: [{ code: "acpx_runtime_ready", level: "info" as const, message: "ACPX Claude runtime is installed and verified. Model access is checked when Claude runs." }], + }; + } catch (error) { + return { + adapterType: "paperclip_runner", status: "fail" as const, testedAt: new Date().toISOString(), + checks: [{ code: "acpx_runtime_unavailable", level: "error" as const, message: error instanceof Error ? error.message : "ACPX Claude runtime could not be verified." }], }; } - return { - adapterType: "paperclip_runner", - status: "pass" as const, - testedAt: new Date().toISOString(), - checks: [{ - code: "acpx_profile_qualified", - level: "info" as const, - message: `ACPX ${profile.acpxAgent} is pinned to the qualified ${profile.model} profile; process readiness is verified by runnerd before the first turn.`, - }], - }; } if (profile.provider === "claude_managed") { return { @@ -531,7 +510,7 @@ const paperclipRunnerAdapter: ServerAdapterModule = { ) : buildNpmRuntimeCommandSpec(config, "codex", "@openai/codex@0.153.4"), agentConfigurationDoc: - "# Paperclip Runner\n\nAdapter: paperclip_runner\n\nRuns Codex, OpenCode, Claude Managed, AWS AgentCore, or a qualified Claude/Codex ACP agent through the Rust Paperclip runner and authenticated PRP transport. Pi is not available through the qualified ACPX profile. Managed providers use company-scoped qualified profiles, explicit retention acknowledgement, and spend limits.\n", + "# Paperclip Runner\n\nAdapter: paperclip_runner\n\nRuns Codex, OpenCode, Claude Managed, AWS AgentCore, or ACPX Claude through the Rust Paperclip runner and authenticated PRP transport. Pi is not available through the qualified ACPX profile. Managed providers use company-scoped qualified profiles, explicit retention acknowledgement, and spend limits.\n", getConfigSchema: () => ({ fields: [ { @@ -544,9 +523,9 @@ const paperclipRunnerAdapter: ServerAdapterModule = { { value: "opencode", label: `OpenCode ${QUALIFIED_OPENCODE_RUNNER_VERSION}` }, { value: "claude_managed", label: "Claude Managed" }, { value: "aws_agentcore", label: "AWS AgentCore" }, - { value: "acpx", label: "ACPX" }, + { value: "acpx", label: "ACPX Claude" }, ], - hint: "Select a local provider, company-qualified managed provider, or qualified Claude/Codex ACPX profile.", + hint: "Select a local provider, company-qualified managed provider, or ACPX Claude.", }, { key: "codexPermissionMode", @@ -581,25 +560,13 @@ const paperclipRunnerAdapter: ServerAdapterModule = { hint: PAPERCLIP_RUNNER_PERMISSION_CAPABILITIES.acpx.description, meta: { visibleWhen: { key: "provider", value: "acpx" } }, }, - { - key: "acpxAgent", - label: "ACP agent", - type: "select" as const, - default: "claude", - options: [ - { value: "claude", label: "Claude via ACPX" }, - { value: "codex", label: "Codex via ACPX" }, - ], - hint: "Only the pinned Claude and Codex profiles are qualified; Pi is unavailable.", - meta: { visibleWhen: { key: "provider", value: "acpx" } }, - }, { key: "model", label: "Provider model", type: "text" as const, default: "", placeholder: DEFAULT_OPENCODE_RUNNER_MODEL, - hint: "OpenCode uses provider/model form. ACPX models are pinned by the selected qualified agent profile.", + hint: "OpenCode uses provider/model form. ACPX Claude accepts Claude model IDs, including custom IDs.", meta: { visibleWhen: { key: "provider", value: "opencode" } }, }, { diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index 6b2c7c0235..ee3d7cf1df 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -1,3 +1,4 @@ +import { paperclipRunnerTransitionConfig, normalizeLegacyRunnerProvider, isPaperclipRunnerProvider } from "@paperclipai/adapter-utils"; import { Router, type NextFunction, type Request, type Response } from "express"; import { generateKeyPairSync, randomUUID } from "node:crypto"; import { rm } from "node:fs/promises"; @@ -2070,19 +2071,14 @@ export function agentRoutes( ) { return input.nextAdapterConfig; } - if (input.previousAdapterType !== "codex_local") { - throw unprocessable( - `Cannot convert ${input.previousAdapterType} to Paperclip Runner while only the Codex provider is available.`, - { code: "paperclip_runner_adapter_conversion_unsupported" }, - ); + const defaults = paperclipRunnerTransitionConfig(input.previousAdapterType, input.previousAdapterConfig.model, input.nextAdapterConfig.provider); + if (!["claude_local", "codex_local", "opencode_local"].includes(input.previousAdapterType) + && !isPaperclipRunnerProvider(input.nextAdapterConfig.provider)) { + throw unprocessable("Select a Paperclip Runner provider before converting this agent."); } - return { - ...input.nextAdapterConfig, - model: - asNonEmptyString(input.nextAdapterConfig.model) - ?? asNonEmptyString(input.previousAdapterConfig.model) - ?? DEFAULT_CODEX_LOCAL_MODEL, - }; + const next = { ...defaults, ...input.nextAdapterConfig }; + if (!asNonEmptyString(next.model)) next.model = defaults.model; + return normalizeLegacyRunnerProvider(next); } function assertProviderTraceSettingTransition( @@ -2947,14 +2943,22 @@ export function agentRoutes( res.status(404).json({ error: "Environment not found" }); return; } - if (type === "opencode_local" && environment && environment.driver !== "local") { - const adapter = requireServerAdapter(type); - res.json(adapter.models ?? []); + const provider = asNonEmptyString(req.query.provider); + if (type === "paperclip_runner" && provider && !isPaperclipRunnerProvider(provider)) { + throw unprocessable("Unknown Paperclip Runner provider"); + } + const modelAdapterType = type === "paperclip_runner" + ? provider === "acpx" || provider === "claude_managed" ? "claude_local" + : provider === "opencode" ? "opencode_local" + : provider === "aws_agentcore" ? type : "codex_local" + : type; + if (modelAdapterType === "opencode_local" && environment && environment.driver !== "local") { + res.json(requireServerAdapter(modelAdapterType).models ?? []); return; } const models = refresh - ? await refreshAdapterModels(type) - : await listAdapterModels(type); + ? await refreshAdapterModels(modelAdapterType) + : await listAdapterModels(modelAdapterType); res.json(models); }); @@ -4738,7 +4742,7 @@ export function agentRoutes( } let rawEffectiveAdapterConfig = requestedAdapterConfig ? restoreRedactedAgentEnv(requestedAdapterConfig, existingAdapterConfig) - : existingAdapterConfig; + : changingAdapterType ? {} : existingAdapterConfig; if (requestedAdapterConfig && !changingAdapterType && !replaceAdapterConfig) { rawEffectiveAdapterConfig = { ...existingAdapterConfig, ...rawEffectiveAdapterConfig }; } @@ -4763,6 +4767,9 @@ export function agentRoutes( nextAdapterConfig: rawEffectiveAdapterConfig, }); } + if (requestedAdapterType === "paperclip_runner") { + rawEffectiveAdapterConfig = normalizePaperclipRunnerAdapterConfig(requestedAdapterType, rawEffectiveAdapterConfig); + } const existingRunnerProvider = existing.adapterType === "paperclip_runner" ? existingAdapterConfig.provider diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index 43ad9f4a79..e353713332 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -2221,8 +2221,11 @@ registry.registerPath({ method: "get", path: "/api/companies/{companyId}/adapters/{type}/models", tags: ["adapters"], - summary: "List models for an adapter type", - request: { params: z.object({ companyId: z.string(), type: z.string() }) }, + summary: "List models for an adapter type and runner provider", + request: { + params: z.object({ companyId: z.string(), type: z.string() }), + query: z.object({ provider: z.enum(["codex", "acpx", "opencode", "claude_managed", "aws_agentcore"]).optional(), environmentId: z.string().optional(), refresh: z.string().optional() }), + }, responses: { 200: r.ok(), 401: r.unauthorized }, }); diff --git a/server/src/services/agents.ts b/server/src/services/agents.ts index f785397746..dfb9d3e3e0 100644 --- a/server/src/services/agents.ts +++ b/server/src/services/agents.ts @@ -725,6 +725,18 @@ export function agentService(db: Db) { .then((rows) => rows[0] ?? null); if (!updated) return null; + const priorAdapterConfig = isPlainRecord(existing.adapterConfig) ? existing.adapterConfig : {}; + const afterConfig = isPlainRecord(updated.adapterConfig) ? updated.adapterConfig : {}; + const changedExecution = updated.adapterType !== existing.adapterType + || (updated.adapterType === "paperclip_runner" && ["provider", "acpxAgent", "model"].some( + (key) => priorAdapterConfig[key] !== afterConfig[key], + )); + if (changedExecution) { + await txDb.delete(agentTaskSessions).where(and(eq(agentTaskSessions.companyId, existing.companyId), eq(agentTaskSessions.agentId, id))); + await txDb.update(agentRuntimeState).set({ adapterType: updated.adapterType, sessionId: null, stateJson: {}, updatedAt: new Date() }) + .where(and(eq(agentRuntimeState.companyId, existing.companyId), eq(agentRuntimeState.agentId, id))); + } + if (Object.prototype.hasOwnProperty.call(normalizedPatch, "adapterConfig")) { if (bindingDecision) { await enforceClaudeOAuthBindingClaim(txDb, { diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 223397b5c8..3228030d58 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -1,6 +1,8 @@ import { initializeRunIdentity } from "./run-identity.js"; import { githubBrokerEnvironment } from "@paperclipai/adapter-utils/github-launcher"; import { cleanupGitHubOperationLaunchers, prepareGitHubOperationLaunchers, startAdapterExecutionTargetPaperclipBridge } from "@paperclipai/adapter-utils/execution-target"; +import { agentService } from "./agents.js"; +import { normalizeLegacyRunnerProvider } from "@paperclipai/adapter-utils"; import fs from "node:fs/promises"; import path from "node:path"; import { execFile as execFileCallback } from "node:child_process"; @@ -23593,8 +23595,19 @@ export function heartbeatService( let issueId = readNonEmptyString(enrichedContextSnapshot.issueId) ?? issueIdFromPayload; - const agent = await getAgent(agentId); + let agent = await getAgent(agentId); if (!agent) throw notFound("Agent not found"); + if (agent.adapterType === "paperclip_runner") { + const oldConfig = parseObject(agent.adapterConfig); + const nextConfig = normalizeLegacyRunnerProvider(oldConfig); + if (nextConfig !== oldConfig) { + await agentService(db).update(agent.id, { adapterConfig: nextConfig }, { + recordRevision: { source: "normalize_runner_provider", createdByAgentId: null, createdByUserId: null }, + }); + await logActivity(db, { companyId: agent.companyId, actorType: "system", actorId: "heartbeat", action: "agent.updated", entityType: "agent", entityId: agent.id, details: { provider: "codex", reason: "native_codex_provider" } }); + agent = (await getAgent(agentId))!; + } + } const agentDebug = parseObject(parseObject(agent.runtimeConfig).debug); const runDebug = parseObject(enrichedContextSnapshot.debug); diff --git a/server/src/services/native-runtime/native-session-executor.test.ts b/server/src/services/native-runtime/native-session-executor.test.ts index 7c7a945d05..5a6dcfb779 100644 --- a/server/src/services/native-runtime/native-session-executor.test.ts +++ b/server/src/services/native-runtime/native-session-executor.test.ts @@ -3844,6 +3844,10 @@ describe("native session bounded recovery", () => { it("retries the same run twice and stops at the third failed attempt", () => { const now = new Date("2026-08-09T00:00:00.000Z"); + expect(nativeSessionFailureSourceCode(new Error("native_provider_model_rejected: unknown model"))).toBe("native_provider_model_rejected"); + expect(nativeSessionFailureDisposition(1, now, "native_provider_model_rejected")).toEqual({ + phase: "terminal_failure", failureCode: "native_provider_model_rejected", nextAttemptAt: null, + }); expect(nativeSessionFailureDisposition(1, now)).toEqual({ phase: "retryable_failure", failureCode: "native_session_interrupted", diff --git a/server/src/services/native-runtime/native-session-executor.ts b/server/src/services/native-runtime/native-session-executor.ts index 6f8ad4e06a..33424808da 100644 --- a/server/src/services/native-runtime/native-session-executor.ts +++ b/server/src/services/native-runtime/native-session-executor.ts @@ -2905,6 +2905,7 @@ export function nativeSessionFailureDisposition( sourceFailureCode?: ReturnType, ) { const permanentFailure = + sourceFailureCode === "native_provider_model_rejected" || sourceFailureCode === "native_event_replay_conflict" || sourceFailureCode === "runner_remote_provider_artifact_incompatible"; const exhausted = permanentFailure || attempt >= 3; @@ -2959,8 +2960,10 @@ export function nativeSessionFailureSourceCode( | "native_runner_process_exited" | "planning_mode_unsupported" | "native_event_replay_conflict" + | "native_provider_model_rejected" | "native_session_interrupted" { const message = error instanceof Error ? error.message : String(error); + if (/native_provider_model_rejected/i.test(message)) return "native_provider_model_rejected"; if (/runner_remote_provider_artifact_incompatible/i.test(message)) { return "runner_remote_provider_artifact_incompatible"; } diff --git a/server/src/services/native-runtime/provider-profile.ts b/server/src/services/native-runtime/provider-profile.ts index 197f82f1f8..a0d7f63b7f 100644 --- a/server/src/services/native-runtime/provider-profile.ts +++ b/server/src/services/native-runtime/provider-profile.ts @@ -403,7 +403,7 @@ export function resolvePaperclipRunnerProviderProfile( }; } - const acpxAgent = config.acpxAgent; + const acpxAgent = config.acpxAgent ?? "claude"; if (acpxAgent !== "claude" && acpxAgent !== "codex") { throw new PaperclipRunnerProviderProfileError( "paperclip_runner_acpx_agent_unavailable", @@ -411,7 +411,7 @@ export function resolvePaperclipRunnerProviderProfile( ); } const qualifiedModel = QUALIFIED_ACPX_RUNNER_MODELS[acpxAgent]; - if (model !== qualifiedModel) { + if (acpxAgent === "codex" && model !== qualifiedModel) { throw new PaperclipRunnerProviderProfileError( "paperclip_runner_acpx_model_unqualified", `Paperclip Runner ACPX ${acpxAgent} requires exact model ${qualifiedModel}.`, @@ -420,7 +420,7 @@ export function resolvePaperclipRunnerProviderProfile( return { provider: "acpx", backend: "acpx_runtime", - model, + model: model || qualifiedModel, acpxAgent, }; } diff --git a/server/src/services/native-runtime/runtime-mode.test.ts b/server/src/services/native-runtime/runtime-mode.test.ts index c7f64acca9..3296f525a5 100644 --- a/server/src/services/native-runtime/runtime-mode.test.ts +++ b/server/src/services/native-runtime/runtime-mode.test.ts @@ -129,9 +129,7 @@ describe("resolveNativeRuntimeMode", () => { expect(() => resolveNativeRuntimeMode({ ...eligible, adapterConfig: { provider: "acpx", acpxAgent: "claude", model: "claude-opus-5" }, - })).toThrow(expect.objectContaining({ - code: "paperclip_runner_acpx_model_unqualified", - })); + })).not.toThrow(); }); it("rejects incomplete managed-provider selections before a run is persisted", () => { diff --git a/ui/src/adapters/claude-local/config-fields.tsx b/ui/src/adapters/claude-local/config-fields.tsx index fa869780c9..1bcb31a460 100644 --- a/ui/src/adapters/claude-local/config-fields.tsx +++ b/ui/src/adapters/claude-local/config-fields.tsx @@ -1,3 +1,4 @@ +import { configFieldsForSection } from "../config-sections"; import type { AdapterConfigFieldsProps } from "../types"; import { Field, @@ -16,6 +17,7 @@ const instructionsFileHint = "Absolute path to a markdown file (e.g. AGENTS.md) that defines this agent's behavior. Injected into the system prompt at runtime."; export function ClaudeLocalConfigFields({ + section, mode, isCreate, adapterType, @@ -27,7 +29,7 @@ export function ClaudeLocalConfigFields({ models, hideInstructionsFile, }: AdapterConfigFieldsProps) { - return ( + return configFieldsForSection(section, ( <> {!hideInstructionsFile && ( @@ -67,10 +69,11 @@ export function ClaudeLocalConfigFields({ models={models} /> - ); + )); } export function ClaudeLocalAdvancedFields({ + section, isCreate, values, set, @@ -85,7 +88,7 @@ export function ClaudeLocalAdvancedFields({ const engine = rawEngine === "acp" || rawEngine === "cli" ? rawEngine : "auto"; const acpSelected = engine === "acp"; - return ( + return configFieldsForSection(section, ( <> {/* The execution engine picks which binary runs on the execution host, and @@ -112,7 +115,7 @@ export function ClaudeLocalAdvancedFields({ {acpSelected && ( <> {!managedSandboxOnly && ( - @@ -133,7 +136,7 @@ export function ClaudeLocalAdvancedFields({ /> )} - + )} - {runnerManaged && !runnerPermissionCapability.configurable && ( - -
- Provider-managed -
-
- )} {runnerManaged && runnerProvider === "claude_managed" && ( <> - @@ -387,52 +370,21 @@ export function CodexLocalConfigFields({ /> )} - {runnerManaged && runnerProvider === "acpx" && ( - - - - )} - {runnerManaged && runnerPermissionCapability.configurable && ( + {runnerManaged && runnerPermissionCapability.configurable && (runnerPermissionCapability.options.length > 1 || runnerPermissionModeUnsupported) && ( - + + + {runnerPermissionModeUnsupported + ? "Unsupported saved mode — select a qualified mode" + : runnerPermissionCapability.options.find((option) => option.value === runnerPermissionMode)?.label} + + + + {runnerPermissionModeUnsupported && ( + + Unsupported saved mode — select a qualified mode + + )} + {runnerPermissionCapability.options.map((option) => ( + + {option.label} + + ))} + + {runnerPermissionModeUnsupported && runnerProvider === "codex" && (

This saved Codex mode cannot start or recover a Paperclip Runner @@ -470,7 +431,7 @@ export function CodexLocalConfigFields({ )} {runnerManaged && ( - @@ -490,7 +451,7 @@ export function CodexLocalConfigFields({ )} {runnerManaged && runnerLifecycleMode === "warm" && ( - @@ -531,7 +492,7 @@ export function CodexLocalConfigFields({ {acpSelected && ( <> {!managedSandboxOnly && ( - @@ -556,7 +517,7 @@ export function CodexLocalConfigFields({ /> )} - @@ -638,7 +599,7 @@ export function CodexLocalConfigFields({ )} - @@ -765,5 +726,5 @@ export function CodexLocalConfigFields({ models={models} /> - ); + )); } diff --git a/ui/src/adapters/config-sections.test.tsx b/ui/src/adapters/config-sections.test.tsx new file mode 100644 index 0000000000..42a27e2235 --- /dev/null +++ b/ui/src/adapters/config-sections.test.tsx @@ -0,0 +1,127 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import type { ComponentType } from "react"; +import { describe, expect, it } from "vitest"; +import { TooltipProvider } from "../components/ui/tooltip"; +import type { AdapterConfigFieldsProps, AdapterConfigSection } from "./types"; +import { CodexLocalConfigFields } from "./codex-local/config-fields"; +import { ClaudeLocalAdvancedFields } from "./claude-local/config-fields"; +import { GeminiLocalConfigFields } from "./gemini-local/config-fields"; +import { ProcessConfigFields } from "./process/config-fields"; +import { OpenClawGatewayConfigFields } from "./openclaw-gateway/config-fields"; +import { HermesGatewayConfigFields } from "./hermes-gateway/config-fields"; + +function renderSection( + Component: ComponentType, + adapterType: string, + section: AdapterConfigSection, + config: Record = {}, +) { + return renderToStaticMarkup( + + original} + mark={() => {}} + models={[]} + hideInstructionsFile + /> + , + ); +} + +describe("adapter configuration sections", () => { + it("separates provider selection from lifecycle and hides fixed Codex permissions", () => { + const config = { + provider: "codex", + lifecycleMode: "warm", + idleTimeoutMs: 45000, + }; + const adapter = renderSection( + CodexLocalConfigFields, + "paperclip_runner", + "adapter", + config, + ); + const configuration = renderSection( + CodexLocalConfigFields, + "paperclip_runner", + "configuration", + config, + ); + const policy = renderSection( + CodexLocalConfigFields, + "paperclip_runner", + "runPolicy", + config, + ); + expect(adapter).toContain("ACPX Claude"); + expect(adapter).not.toContain("Runner lifecycle"); + expect(configuration).not.toContain("Permission mode"); + expect(configuration).not.toContain("Runner lifecycle"); + expect(policy).toContain("Runner lifecycle"); + expect(policy).toContain('value="45000"'); + expect(policy).not.toContain("ACPX Claude"); + }); + + it.each([ + ["claude_local", ClaudeLocalAdvancedFields], + ["codex_local", CodexLocalConfigFields], + ["gemini_local", GeminiLocalConfigFields], + ] as const)( + "separates ACP commands and lifecycle for %s", + (type, Component) => { + const config = { + engine: "acp", + agentCommand: "saved-command", + warmHandleIdleMs: 1234, + }; + expect(renderSection(Component, type, "advanced", config)).toContain( + 'value="saved-command"', + ); + expect( + renderSection(Component, type, "configuration", config), + ).not.toContain("ACP server command"); + const policy = renderSection(Component, type, "runPolicy", config); + expect(policy).toContain("ACP session mode"); + expect(policy).toContain('value="1234"'); + expect(policy).not.toContain("ACP server command"); + }, + ); + + it("keeps process command and arguments under Advanced with saved values", () => { + const config = { command: "node", args: ["worker.js", "--quiet"] }; + expect( + renderSection(ProcessConfigFields, "process", "configuration", config), + ).toBe(""); + const advanced = renderSection( + ProcessConfigFields, + "process", + "advanced", + config, + ); + expect(advanced).toContain('value="node"'); + expect(advanced).toContain('value="worker.js, --quiet"'); + }); + + it.each([ + ["openclaw_gateway", OpenClawGatewayConfigFields], + ["hermes_gateway", HermesGatewayConfigFields], + ] as const)( + "moves %s timeouts without changing their values", + (type, Component) => { + const config = { timeoutSec: 37 }; + expect( + renderSection(Component, type, "configuration", config), + ).not.toContain('value="37"'); + expect(renderSection(Component, type, "runPolicy", config)).toContain( + 'value="37"', + ); + }, + ); +}); diff --git a/ui/src/adapters/config-sections.tsx b/ui/src/adapters/config-sections.tsx new file mode 100644 index 0000000000..780dcd6a08 --- /dev/null +++ b/ui/src/adapters/config-sections.tsx @@ -0,0 +1,55 @@ +import { + Children, + Fragment, + cloneElement, + isValidElement, + type ReactNode, +} from "react"; +import type { AdapterConfigSection } from "./types"; + +/** Partition declarative adapter fields without coupling placement to visible labels. */ +export function configFieldsForSection( + section: AdapterConfigSection | undefined, + children: ReactNode, +): ReactNode { + if (!section) return children; + return Children.map(children, (child) => { + if ( + !isValidElement<{ + children?: ReactNode; + configSection?: AdapterConfigSection; + }>(child) + ) + return null; + if (child.type === Fragment) + return cloneElement( + child, + undefined, + configFieldsForSection(section, child.props.children), + ); + return (child.props.configSection ?? "configuration") === section + ? child + : null; + }); +} + +export function schemaFieldSection(key: string): AdapterConfigSection { + if (["model", "provider"].includes(key)) return "adapter"; + if (["command", "agentCommand", "args", "extraArgs"].includes(key)) + return "advanced"; + if (["env", "envVars", "environmentVariables"].includes(key)) + return "environment"; + if ( + /timeout|grace|lifecycle/i.test(key) || + [ + "lifecycleMode", + "mode", + "sessionMode", + "persistSession", + "sessionKeyStrategy", + "warmHandleIdleMs", + ].includes(key) + ) + return "runPolicy"; + return "configuration"; +} diff --git a/ui/src/adapters/cursor/config-fields.tsx b/ui/src/adapters/cursor/config-fields.tsx index 5d0204ceb9..885233fa68 100644 --- a/ui/src/adapters/cursor/config-fields.tsx +++ b/ui/src/adapters/cursor/config-fields.tsx @@ -1,3 +1,4 @@ +import { configFieldsForSection } from "../config-sections"; import type { AdapterConfigFieldsProps } from "../types"; import { Field, @@ -11,6 +12,7 @@ const instructionsFileHint = "Absolute path to a markdown file (e.g. AGENTS.md) that defines this agent's behavior. Injected into the prompt at runtime."; export function CursorLocalConfigFields({ + section, isCreate, values, set, @@ -20,7 +22,7 @@ export function CursorLocalConfigFields({ hideInstructionsFile, }: AdapterConfigFieldsProps) { if (hideInstructionsFile) return null; - return ( + return configFieldsForSection(section, (

- ); + )); } diff --git a/ui/src/adapters/gemini-local/config-fields.tsx b/ui/src/adapters/gemini-local/config-fields.tsx index f2c0d18d48..2a73593740 100644 --- a/ui/src/adapters/gemini-local/config-fields.tsx +++ b/ui/src/adapters/gemini-local/config-fields.tsx @@ -1,3 +1,4 @@ +import { configFieldsForSection } from "../config-sections"; import type { AdapterConfigFieldsProps } from "../types"; import { DraftNumberInput, @@ -12,6 +13,7 @@ const instructionsFileHint = "Absolute path to a markdown file (e.g. AGENTS.md) that defines this agent's behavior. Prepended to the Gemini prompt at runtime."; export function GeminiLocalConfigFields({ + section, isCreate, values, set, @@ -27,7 +29,7 @@ export function GeminiLocalConfigFields({ const engine = rawEngine === "acp" || rawEngine === "cli" ? rawEngine : "auto"; const acpSelected = engine === "acp"; - return ( + return configFieldsForSection(section, ( <> {/* The execution engine picks which binary runs on the execution host, and @@ -53,7 +55,7 @@ export function GeminiLocalConfigFields({ {acpSelected && ( <> {!managedSandboxOnly && ( - @@ -74,7 +76,7 @@ export function GeminiLocalConfigFields({ /> )} - + - + writeValue("timeoutSec", v)} @@ -248,5 +250,5 @@ export function HermesGatewayConfigFields({ /> - ); + )); } diff --git a/ui/src/adapters/http/config-fields.tsx b/ui/src/adapters/http/config-fields.tsx index a7e3350385..bc1c6714c2 100644 --- a/ui/src/adapters/http/config-fields.tsx +++ b/ui/src/adapters/http/config-fields.tsx @@ -1,3 +1,4 @@ +import { configFieldsForSection } from "../config-sections"; import type { AdapterConfigFieldsProps } from "../types"; import { Field, @@ -9,6 +10,7 @@ const inputClass = "w-full rounded-md border border-border px-2.5 py-1.5 bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40"; export function HttpConfigFields({ + section, isCreate, values, set, @@ -16,7 +18,7 @@ export function HttpConfigFields({ eff, mark, }: AdapterConfigFieldsProps) { - return ( + return configFieldsForSection(section, ( - ); + )); } diff --git a/ui/src/adapters/kimi-local/config-fields.tsx b/ui/src/adapters/kimi-local/config-fields.tsx index 351a4cad4d..a71b12bd85 100644 --- a/ui/src/adapters/kimi-local/config-fields.tsx +++ b/ui/src/adapters/kimi-local/config-fields.tsx @@ -1,3 +1,4 @@ +import { configFieldsForSection } from "../config-sections"; import type { AdapterConfigFieldsProps } from "../types"; import { DraftInput, @@ -11,6 +12,7 @@ const instructionsFileHint = "Absolute path to a markdown file (e.g. AGENTS.md) that defines this agent's behavior. Prepended to the Kimi prompt at runtime."; export function KimiLocalConfigFields({ + section, isCreate, values, set, @@ -20,7 +22,7 @@ export function KimiLocalConfigFields({ hideInstructionsFile, }: AdapterConfigFieldsProps) { if (hideInstructionsFile) return null; - return ( + return configFieldsForSection(section, ( <>
@@ -47,5 +49,5 @@ export function KimiLocalConfigFields({
- ); + )); } diff --git a/ui/src/adapters/openclaw-gateway/config-fields.tsx b/ui/src/adapters/openclaw-gateway/config-fields.tsx index 18b0cc005e..6a46ab9b60 100644 --- a/ui/src/adapters/openclaw-gateway/config-fields.tsx +++ b/ui/src/adapters/openclaw-gateway/config-fields.tsx @@ -1,3 +1,4 @@ +import { configFieldsForSection } from "../config-sections"; import { useEffect, useState } from "react"; import { Eye, EyeOff } from "lucide-react"; import type { AdapterConfigFieldsProps } from "../types"; @@ -101,6 +102,7 @@ function parseScopes(value: unknown): string { } export function OpenClawGatewayConfigFields({ + section, isCreate, values, set, @@ -140,7 +142,7 @@ export function OpenClawGatewayConfigFields({ String(config.sessionKeyStrategy ?? "fixed"), ); - return ( + return configFieldsForSection(section, ( <> - + )} - + - ); + )); } diff --git a/ui/src/adapters/opencode-local/config-fields.tsx b/ui/src/adapters/opencode-local/config-fields.tsx index 4ad7b81f61..1cb16497ba 100644 --- a/ui/src/adapters/opencode-local/config-fields.tsx +++ b/ui/src/adapters/opencode-local/config-fields.tsx @@ -1,3 +1,4 @@ +import { configFieldsForSection } from "../config-sections"; import type { AdapterConfigFieldsProps } from "../types"; import { Field, @@ -13,6 +14,7 @@ const instructionsFileHint = "Absolute path to a markdown file (e.g. AGENTS.md) that defines this agent's behavior. Injected into the system prompt at runtime."; export function OpenCodeLocalConfigFields({ + section, isCreate, values, set, @@ -21,7 +23,7 @@ export function OpenCodeLocalConfigFields({ mark, hideInstructionsFile, }: AdapterConfigFieldsProps) { - return ( + return configFieldsForSection(section, ( <> {!hideInstructionsFile && ( @@ -68,5 +70,5 @@ export function OpenCodeLocalConfigFields({ } /> - ); + )); } diff --git a/ui/src/adapters/pi-local/config-fields.tsx b/ui/src/adapters/pi-local/config-fields.tsx index ad8597502a..e8713ccb71 100644 --- a/ui/src/adapters/pi-local/config-fields.tsx +++ b/ui/src/adapters/pi-local/config-fields.tsx @@ -1,3 +1,4 @@ +import { configFieldsForSection } from "../config-sections"; import type { AdapterConfigFieldsProps } from "../types"; import { Field, @@ -11,6 +12,7 @@ const instructionsFileHint = "Absolute path to a markdown file (e.g. AGENTS.md) that defines this agent's behavior. Injected into the system prompt at runtime."; export function PiLocalConfigFields({ + section, isCreate, values, set, @@ -20,7 +22,7 @@ export function PiLocalConfigFields({ hideInstructionsFile, }: AdapterConfigFieldsProps) { if (hideInstructionsFile) return null; - return ( + return configFieldsForSection(section, (
- ); + )); } diff --git a/ui/src/adapters/process/config-fields.tsx b/ui/src/adapters/process/config-fields.tsx index 9eff75dfc5..94844dc386 100644 --- a/ui/src/adapters/process/config-fields.tsx +++ b/ui/src/adapters/process/config-fields.tsx @@ -1,3 +1,4 @@ +import { configFieldsForSection } from "../config-sections"; import type { AdapterConfigFieldsProps } from "../types"; import { Field, @@ -25,6 +26,7 @@ function parseCommaArgs(value: string): string[] { } export function ProcessConfigFields({ + section, isCreate, values, set, @@ -32,9 +34,9 @@ export function ProcessConfigFields({ eff, mark, }: AdapterConfigFieldsProps) { - return ( + return configFieldsForSection(section, ( <> - + - + - ); + )); } diff --git a/ui/src/adapters/schema-config-fields.tsx b/ui/src/adapters/schema-config-fields.tsx index 08a3e52435..b76a053ade 100644 --- a/ui/src/adapters/schema-config-fields.tsx +++ b/ui/src/adapters/schema-config-fields.tsx @@ -1,3 +1,4 @@ +import { schemaFieldSection } from "./config-sections"; import { useState, useEffect, useRef, useCallback } from "react"; import type { AdapterConfigSchema, ConfigFieldSchema, CreateConfigValues } from "@paperclipai/adapter-utils"; @@ -246,22 +247,22 @@ export function invalidateConfigSchemaCache(adapterType: string): void { // Hook // --------------------------------------------------------------------------- -function useConfigSchema(adapterType: string): AdapterConfigSchema | null { - const [schema, setSchema] = useState( - schemaCache.get(adapterType) ?? null, +export function useConfigSchema(adapterType: string): AdapterConfigSchema | null { + const [loaded, setLoaded] = useState<{ adapterType: string; schema: AdapterConfigSchema | null }>( + () => ({ adapterType, schema: schemaCache.get(adapterType) ?? null }), ); useEffect(() => { let cancelled = false; fetchConfigSchema(adapterType).then((s) => { - if (!cancelled) setSchema(s); + if (!cancelled) setLoaded({ adapterType, schema: s }); }); return () => { cancelled = true; }; }, [adapterType]); - return schema; + return loaded.adapterType === adapterType ? loaded.schema : schemaCache.get(adapterType) ?? null; } // --------------------------------------------------------------------------- @@ -320,6 +321,8 @@ export function fieldMatchesVisibleWhen( // --------------------------------------------------------------------------- export function SchemaConfigFields({ + section, + hideModel, adapterType, isCreate, values, @@ -330,9 +333,15 @@ export function SchemaConfigFields({ }: AdapterConfigFieldsProps) { const schema = useConfigSchema(adapterType); - const [defaultsApplied, setDefaultsApplied] = useState(false); + const defaultsApplied = useRef({ adapterType, applied: false }); useEffect(() => { - if (!schema || !isCreate || defaultsApplied) return; + // Reset on the selection change even while the next schema is loading. + // A -> B -> A must initialize A again after the form clears its values. + if (defaultsApplied.current.adapterType !== adapterType) { + defaultsApplied.current = { adapterType, applied: false }; + } + if (!schema || !isCreate || defaultsApplied.current.applied || (section && section !== "configuration")) return; + defaultsApplied.current.applied = true; const defaults: Record = {}; for (const field of schema.fields) { const def = getDefaultValue(field); @@ -342,11 +351,10 @@ export function SchemaConfigFields({ } if (Object.keys(defaults).length > 0) { set?.({ - adapterSchemaValues: { ...values?.adapterSchemaValues, ...defaults }, + adapterSchemaValues: { ...defaults, ...values?.adapterSchemaValues }, }); } - setDefaultsApplied(true); - }, [schema, isCreate, defaultsApplied, set, values?.adapterSchemaValues]); + }, [schema, adapterType, isCreate, set, values?.adapterSchemaValues, section]); if (!schema || schema.fields.length === 0) return null; @@ -402,6 +410,9 @@ export function SchemaConfigFields({ return ( <> {schema.fields + .filter((field) => !hideModel || field.key !== "model") + .filter((field) => !section || schemaFieldSection(field.key) === section) + .filter((field) => !(field.type === "select" && /permissionMode/i.test(field.key) && (field.options?.length ?? 0) <= 1)) .filter((field) => fieldMatchesVisibleWhen(field, readValue, schema)) .map((field) => { switch (field.type) { diff --git a/ui/src/adapters/schema-config-sections.test.tsx b/ui/src/adapters/schema-config-sections.test.tsx new file mode 100644 index 0000000000..8ad23cb2ca --- /dev/null +++ b/ui/src/adapters/schema-config-sections.test.tsx @@ -0,0 +1,71 @@ +// @vitest-environment jsdom +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, expect, it, vi } from "vitest"; +import type { AdapterConfigFieldsProps } from "./types"; +import { SchemaConfigFields, invalidateConfigSchemaCache } from "./schema-config-fields"; +import { TooltipProvider } from "../components/ui/tooltip"; +import { defaultCreateValues } from "../components/agent-config-defaults"; + +let root: Root | undefined; +afterEach(async () => { if (root) await act(async () => root?.unmount()); document.body.innerHTML = ""; vi.unstubAllGlobals(); }); + +it("drops the old schema immediately when the adapter changes and applies new create defaults", async () => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + const firstType = "section-test-first"; + const secondType = "section-test-second"; + invalidateConfigSchemaCache(firstType); + invalidateConfigSchemaCache(secondType); + let resolveSecond!: (value: unknown) => void; + const secondResponse = new Promise((resolve) => { resolveSecond = resolve; }); + vi.stubGlobal("fetch", vi.fn() + .mockResolvedValueOnce({ ok: true, json: async () => ({ fields: [{ key: "first", label: "First setting", type: "text", default: "first-default" }] }) }) + .mockReturnValueOnce(secondResponse)); + const set = vi.fn(); + const props: AdapterConfigFieldsProps = { + mode: "create", isCreate: true, adapterType: firstType, section: "configuration", + values: { ...defaultCreateValues, adapterSchemaValues: {} }, set, + config: {}, eff: (_group, _field, original) => original, mark: vi.fn(), models: [], + }; + const view = (adapterType: string) => ; + const container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + await act(async () => root?.render(view(firstType))); + expect(container.textContent).toContain("First setting"); + expect(set).toHaveBeenCalledWith({ adapterSchemaValues: { first: "first-default" } }); + await act(async () => root?.render(view(secondType))); + expect(container.textContent).not.toContain("First setting"); + await act(async () => resolveSecond({ ok: true, json: async () => ({ fields: [{ key: "second", label: "Second setting", type: "text", default: "second-default" }] }) })); + expect(container.textContent).toContain("Second setting"); + expect(set).toHaveBeenLastCalledWith({ adapterSchemaValues: { second: "second-default" } }); +}); + + +it("restores cleared defaults after switching back before the intermediate schema loads", async () => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + const firstType = "rapid-switch-first"; + const secondType = "rapid-switch-second"; + invalidateConfigSchemaCache(firstType); + invalidateConfigSchemaCache(secondType); + vi.stubGlobal("fetch", vi.fn() + .mockResolvedValueOnce({ ok: true, json: async () => ({ fields: [{ key: "model", label: "Model", type: "text", default: "default-model" }] }) }) + .mockReturnValueOnce(new Promise(() => {}))); + const set = vi.fn(); + const props: AdapterConfigFieldsProps = { + mode: "create", isCreate: true, adapterType: firstType, section: "configuration", + values: { ...defaultCreateValues, adapterSchemaValues: { model: "chosen-model" } }, set, + config: {}, eff: (_group, _field, original) => original, mark: vi.fn(), models: [], + }; + const view = (adapterType: string) => ; + const container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + await act(async () => root?.render(view(firstType))); + expect(set).toHaveBeenLastCalledWith({ adapterSchemaValues: { model: "chosen-model" } }); + props.values = { ...defaultCreateValues, adapterSchemaValues: {} }; + await act(async () => root?.render(view(secondType))); + set.mockClear(); + await act(async () => root?.render(view(firstType))); + expect(set).toHaveBeenCalledExactlyOnceWith({ adapterSchemaValues: { model: "default-model" } }); +}); diff --git a/ui/src/adapters/types.ts b/ui/src/adapters/types.ts index f8a099c95a..c5a26d5e57 100644 --- a/ui/src/adapters/types.ts +++ b/ui/src/adapters/types.ts @@ -16,7 +16,13 @@ export interface TranscriptParserSource { createStdoutParser?: StdoutParserFactory; } +export type AdapterConfigSection = "adapter" | "configuration" | "advanced" | "runPolicy" | "environment"; + export interface AdapterConfigFieldsProps { + /** Render only fields belonging to this shared form section. Omit for all fields. */ + section?: AdapterConfigSection; + /** The shared local-adapter model picker is already rendered by the form. */ + hideModel?: boolean; mode: "create" | "edit"; isCreate: boolean; adapterType: string; diff --git a/ui/src/api/agents.ts b/ui/src/api/agents.ts index f53e9c0528..a2e5c17f4c 100644 --- a/ui/src/api/agents.ts +++ b/ui/src/api/agents.ts @@ -202,10 +202,11 @@ export const agentsApi = { adapterModels: ( companyId: string, type: string, - options?: { refresh?: boolean; environmentId?: string | null }, + options?: { refresh?: boolean; environmentId?: string | null; provider?: string }, ) => { const params = new URLSearchParams(); if (options?.refresh) params.set("refresh", "1"); + if (options?.provider) params.set("provider", options.provider); if (options?.environmentId) params.set("environmentId", options.environmentId); const query = params.size > 0 ? `?${params.toString()}` : ""; return api.get( diff --git a/ui/src/components/AgentConfigForm.render.test.tsx b/ui/src/components/AgentConfigForm.render.test.tsx index 2d7190c56b..a932547ada 100644 --- a/ui/src/components/AgentConfigForm.render.test.tsx +++ b/ui/src/components/AgentConfigForm.render.test.tsx @@ -3280,6 +3280,12 @@ describe("AgentConfigForm managed-sandbox-only host surfaces", () => { ); roots.push(result.root); + await act(async () => { + for (const button of result.container.querySelectorAll("button")) { + if (["Advanced", "Advanced Run Policy"].includes(button.textContent?.trim() ?? "")) button.click(); + } + }); + await flushReact(); const labels = fieldLabels(result.container); expect(labels).toContain("Working directory (deprecated)"); expect(labels).toContain("Command"); @@ -3301,6 +3307,12 @@ describe("AgentConfigForm managed-sandbox-only host surfaces", () => { ); roots.push(result.root); + await act(async () => { + for (const button of result.container.querySelectorAll("button")) { + if (["Advanced", "Advanced Run Policy"].includes(button.textContent?.trim() ?? "")) button.click(); + } + }); + await flushReact(); const labels = fieldLabels(result.container); expect(labels).not.toContain("Working directory (deprecated)"); expect(labels).not.toContain("Command"); @@ -3321,6 +3333,12 @@ describe("AgentConfigForm managed-sandbox-only host surfaces", () => { ); roots.push(result.root); + await act(async () => { + for (const button of result.container.querySelectorAll("button")) { + if (["Advanced", "Advanced Run Policy"].includes(button.textContent?.trim() ?? "")) button.click(); + } + }); + await flushReact(); const labels = fieldLabels(result.container); expect(labels).toContain("ACP session mode"); expect(labels).toContain("ACP non-interactive permissions"); @@ -3336,6 +3354,12 @@ describe("AgentConfigForm managed-sandbox-only host surfaces", () => { ); roots.push(result.root); + await act(async () => { + for (const button of result.container.querySelectorAll("button")) { + if (["Advanced", "Advanced Run Policy"].includes(button.textContent?.trim() ?? "")) button.click(); + } + }); + await flushReact(); const labels = fieldLabels(result.container); expect(labels).not.toContain("Working directory (deprecated)"); expect(labels).not.toContain("Command"); @@ -3352,6 +3376,12 @@ describe("AgentConfigForm managed-sandbox-only host surfaces", () => { ); roots.push(result.root); + await act(async () => { + for (const button of result.container.querySelectorAll("button")) { + if (["Advanced", "Advanced Run Policy"].includes(button.textContent?.trim() ?? "")) button.click(); + } + }); + await flushReact(); const labels = fieldLabels(result.container); expect(labels).not.toContain("Working directory (deprecated)"); expect(labels).not.toContain("Command"); diff --git a/ui/src/components/AgentConfigForm.test.ts b/ui/src/components/AgentConfigForm.test.ts index e9d86f23d2..9ba42adc09 100644 --- a/ui/src/components/AgentConfigForm.test.ts +++ b/ui/src/components/AgentConfigForm.test.ts @@ -14,12 +14,16 @@ describe("supportsAdapterModelRefresh", () => { }); it("keeps the refresh action hidden for adapters without a live refresh hook", () => { - expect(supportsAdapterModelRefresh("opencode_local")).toBe(false); + expect(supportsAdapterModelRefresh("opencode_local")).toBe(true); + expect(supportsAdapterModelRefresh("paperclip_runner")).toBe(true); expect(supportsAdapterModelRefresh("process")).toBe(false); }); }); describe("resolvePaperclipRunnerTransitionModel", () => { + it("preserves Claude custom model IDs", () => { + expect(resolvePaperclipRunnerTransitionModel("claude_local", "custom-claude-model")).toBe("custom-claude-model"); + }); it("preserves an explicit model from codex_local", () => { expect(resolvePaperclipRunnerTransitionModel("codex_local", "gpt-5.5")) .toBe("gpt-5.5"); diff --git a/ui/src/components/AgentConfigForm.tsx b/ui/src/components/AgentConfigForm.tsx index 921e6c98d2..63b5134b41 100644 --- a/ui/src/components/AgentConfigForm.tsx +++ b/ui/src/components/AgentConfigForm.tsx @@ -1,6 +1,9 @@ import { testAgentSetup } from "@/lib/test-agent-setup"; import { RuntimeTestCard } from "./RuntimeTestCard"; import { useState, useEffect, useRef, useMemo, useCallback, Children, isValidElement, type ReactNode } from "react"; +import type { AdapterConfigSection } from "../adapters/types"; +import { useConfigSchema } from "../adapters/schema-config-fields"; +import { schemaFieldSection } from "../adapters/config-sections"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import type { Agent, @@ -95,6 +98,7 @@ import { codexReasoningEffortOptions } from "../lib/codex-reasoning-effort"; export type { CreateConfigValues } from "@paperclipai/adapter-utils"; import { PAPERCLIP_RUNNER_PERMISSION_CAPABILITIES, + paperclipRunnerTransitionConfig, type CreateConfigValues, } from "@paperclipai/adapter-utils"; import { Badge } from "@/components/ui/badge"; @@ -165,18 +169,14 @@ const emptyOverlay: AgentConfigOverlay = { const EMPTY_ENV: Record = {}; export function supportsAdapterModelRefresh(adapterType: string): boolean { - return adapterType === "claude_local" || adapterType === "codex_local"; + return adapterType === "claude_local" || adapterType === "codex_local" || adapterType === "paperclip_runner" || adapterType === "opencode_local"; } export function resolvePaperclipRunnerTransitionModel( previousAdapterType: string, previousModel: unknown, ): string { - return previousAdapterType === "codex_local" - && typeof previousModel === "string" - && previousModel.trim().length > 0 - ? previousModel.trim() - : DEFAULT_CODEX_LOCAL_MODEL; + return paperclipRunnerTransitionConfig(previousAdapterType, previousModel).model as string; } function isOverlayDirty(o: AgentConfigOverlay): boolean { @@ -758,9 +758,13 @@ export function AgentConfigForm(props: AgentConfigFormProps) { ? "Paperclip Computer" : "Local"; - // Fetch adapter models for the effective adapter type + const runnerProvider = adapterType === "paperclip_runner" + ? String(isCreate ? props.values.adapterSchemaValues?.provider ?? "codex" + : eff("adapterConfig", "provider", config.provider === "acpx" && config.acpxAgent === "codex" ? "codex" : config.provider ?? "codex")) + : undefined; + // Fetch adapter models for the effective provider, including unsaved changes. const modelQueryKey = selectedCompanyId - ? queryKeys.agents.adapterModels(selectedCompanyId, adapterType, currentDefaultEnvironmentId || null) + ? queryKeys.agents.adapterModels(selectedCompanyId, adapterType, currentDefaultEnvironmentId || null, runnerProvider) : ["agents", "none", "adapter-models", adapterType]; const { data: fetchedModels, @@ -769,6 +773,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) { queryKey: modelQueryKey, queryFn: () => agentsApi.adapterModels(selectedCompanyId!, adapterType, { environmentId: currentDefaultEnvironmentId || null, + provider: runnerProvider, }), enabled: Boolean(selectedCompanyId), }); @@ -789,7 +794,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) { } return agentsApi.detectModel(selectedCompanyId, adapterType); }, - enabled: Boolean(selectedCompanyId && isLocal && adapterType !== "opencode_local"), + enabled: Boolean(selectedCompanyId && isLocal && adapterType !== "opencode_local" && adapterType !== "paperclip_runner"), }); const detectedModel = detectedModelData?.model ?? null; const detectedModelCandidates = detectedModelData?.candidates ?? []; @@ -820,6 +825,14 @@ export function AgentConfigForm(props: AgentConfigFormProps) { // Section toggle state — advanced always starts collapsed const [runPolicyAdvancedOpen, setRunPolicyAdvancedOpen] = useState(false); + const [configurationAdvancedOpen, setConfigurationAdvancedOpen] = useState(false); + const configSchema = useConfigSchema(adapterType); + const renderAdapterFields = (section: AdapterConfigSection) => ( + <> + {adapterType === "claude_local" && } + + + ); // Popover states const [modelOpen, setModelOpen] = useState(false); const [thinkingEffortOpen, setThinkingEffortOpen] = useState(false); @@ -1103,7 +1116,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) { setRefreshingModels(true); setRefreshModelsError(null); try { - const refreshed = await agentsApi.adapterModels(selectedCompanyId, adapterType, { refresh: true }); + const refreshed = await agentsApi.adapterModels(selectedCompanyId, adapterType, { refresh: true, environmentId: currentDefaultEnvironmentId || null, provider: runnerProvider }); queryClient.setQueryData(modelQueryKey, refreshed); } catch (error) { setRefreshModelsError(error instanceof Error ? error.message : "Failed to refresh adapter models."); @@ -1219,13 +1232,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) { } /> ); - const environmentVariablesField = ( -
- - {environmentVariablesEditor} - -
- ); + if (!isCreate && props.content === "secrets") { return ( @@ -1329,21 +1336,6 @@ export function AgentConfigForm(props: AgentConfigFormProps) { chooseLabel="Choose manager…" />
- - mark("identity", "capabilities", v || null)} - placeholder="Describe what this agent can do..." - contentClassName="min-h-(--sz-44px) text-sm font-mono" - imageUploadHandler={async (file) => { - const asset = await uploadMarkdownImage.mutateAsync({ - file, - namespace: `agents/${props.agent.id}/capabilities`, - }); - return asset.contentPath; - }} - /> - {isLocal && !props.hidePromptTemplate && ( <> @@ -1511,10 +1503,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) { } : t === "paperclip_runner" ? { - provider: "codex", - codexPermissionMode: - PAPERCLIP_RUNNER_PERMISSION_CAPABILITIES.codex.defaultMode, - lifecycleMode: "per_turn", + ...paperclipRunnerTransitionConfig(adapterType, eff("adapterConfig", "model", config.model)), } : {}), }, @@ -1576,70 +1565,8 @@ export function AgentConfigForm(props: AgentConfigFormProps) { )} - {!isLocal && } - - {/* Local adapter-specific fields are rendered inside Permissions & Configuration */} - - - - - {/* ---- Permissions & Configuration ---- */} - {isLocal && ( -
- {cards - ?

{props.sectionTitles?.["permissions"] ?? "Permissions & Configuration"}

- :
Permissions & Configuration
- } -
- {/* - The command names a binary on the execution host, so the - managed-sandbox-only policy hides it: the platform-managed image - owns the binary. Hiding is presentation only. A stored - `adapterConfig.command` stays as it is and the server does not - reject one, because an import carries adapter configuration - written on another instance; rejecting it would break that flow. - The value is inert while the policy is on. The field also stays - hidden until the policy is known, so a stored command never - flashes on a managed instance. - */} - {!hideHostPaths && ( -
- - - isCreate - ? set!({ command: v }) - : mark("adapterConfig", adapterCommandField, v || null) - } - immediate - className={inputClass} - placeholder={ - ({ - claude_local: "claude", - codex_local: "codex", - gemini_local: "gemini", - kimi_local: "kimi", - pi_local: "pi", - cursor: "agent", - opencode_local: "opencode", - } as Record)[adapterType] ?? adapterType.replace(/_local$/, "") - } - /> - -
- )} - + {renderAdapterFields("adapter")} + {isLocal && (<> { const result = await refetchDetectedModel(); @@ -1723,6 +1650,19 @@ export function AgentConfigForm(props: AgentConfigFormProps) { )} )} + )} +
+ +
+ + {/* ---- Configuration ---- */} + {( +
+ {cards + ?

Configuration

+ :
Configuration
+ } +
{!isCreate && typeof config.bootstrapPromptTemplate === "string" && config.bootstrapPromptTemplate && ( <> @@ -1749,10 +1689,60 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
)} - {adapterType === "claude_local" && ( - + {renderAdapterFields("configuration")} + {(isLocal || adapterType === "process" || configSchema?.fields.some((field) => schemaFieldSection(field.key) === "advanced")) && ( + setConfigurationAdvancedOpen(!configurationAdvancedOpen)} + > +
+ {isLocal && (<> {/* + The command names a binary on the execution host, so the + managed-sandbox-only policy hides it: the platform-managed image + owns the binary. Hiding is presentation only. A stored + `adapterConfig.command` stays as it is and the server does not + reject one, because an import carries adapter configuration + written on another instance; rejecting it would break that flow. + The value is inert while the policy is on. The field also stays + hidden until the policy is known, so a stored command never + flashes on a managed instance. + */} + {!hideHostPaths && ( + + + isCreate + ? set!({ command: v }) + : mark("adapterConfig", adapterCommandField, v || null) + } + immediate + className={inputClass} + placeholder={ + ({ + claude_local: "claude", + codex_local: "codex", + gemini_local: "gemini", + kimi_local: "kimi", + pi_local: "pi", + cursor: "agent", + opencode_local: "opencode", + } as Record)[adapterType] ?? adapterType.replace(/_local$/, "") + } + /> + )} - - {props.environmentVariablesPlacement !== "secrets" && environmentVariablesField} - - {/* Edit-only: timeout + grace period */} - {!isCreate && ( - <> - - mark("adapterConfig", "timeoutSec", v)} - immediate - className={inputClass} - /> - - - mark("adapterConfig", "graceSec", v)} - immediate - className={inputClass} - /> - - + )} + {renderAdapterFields("advanced")} +
+
)} + +
+ + )} + + {props.environmentVariablesPlacement !== "secrets" && (isLocal || configSchema?.fields.some((field) => schemaFieldSection(field.key) === "environment")) && ( +
+ {cards + ?

Environment variables

+ :
Environment variables
+ } +
+ {isLocal ? environmentVariablesEditor : renderAdapterFields("environment")}
)} @@ -1826,6 +1803,9 @@ export function AgentConfigForm(props: AgentConfigFormProps) { numberHint={help.intervalSec} showNumber={val!.heartbeatEnabled} /> + setRunPolicyAdvancedOpen(!runPolicyAdvancedOpen)}> +
{renderAdapterFields("runPolicy")}
+
) : !isCreate ? ( @@ -1856,6 +1836,42 @@ export function AgentConfigForm(props: AgentConfigFormProps) { onToggle={() => setRunPolicyAdvancedOpen(!runPolicyAdvancedOpen)} >
+ {renderAdapterFields("runPolicy")} + {isLocal && (<> + {/* Edit-only: timeout + grace period */} + {!isCreate && ( + <> + {!configSchema?.fields.some((field) => field.key === "timeoutSec") && ( + + mark("adapterConfig", "timeoutSec", v)} + immediate + className={inputClass} + /> + + )} + {!configSchema?.fields.some((field) => field.key === "graceSec") && ( + + mark("adapterConfig", "graceSec", v)} + immediate + className={inputClass} + /> + + )} + + )} + )} agentsApi.adapterModels(effectiveCompanyId!, assigneeAdapterType!), + queryFn: () => agentsApi.adapterModels(effectiveCompanyId!, assigneeAdapterType!, { provider: catalogProvider }), enabled: Boolean(effectiveCompanyId) && newIssueOpen && supportsAssigneeOverrides, }); diff --git a/ui/src/components/TaskChatThread.tsx b/ui/src/components/TaskChatThread.tsx index 20db856be5..4b4a5e6b59 100644 --- a/ui/src/components/TaskChatThread.tsx +++ b/ui/src/components/TaskChatThread.tsx @@ -1406,6 +1406,8 @@ export function TaskChatThread(props: TaskChatThreadProps) { ? `The run was cancelled ${responseBoundary}.` : source.status === "interrupted" ? `The run was interrupted ${responseBoundary}.` + : code === "native_provider_model_rejected" + ? "The provider rejected the selected model. Check the model ID and your account's access, save the agent configuration, then retry. View the run for the provider's full error." : code === "provider_frame_too_large" ? "Provider output exceeded the safe limit." : source.status === "timed_out" diff --git a/ui/src/components/agent-config-primitives.tsx b/ui/src/components/agent-config-primitives.tsx index 673fe45b2c..118a53dd75 100644 --- a/ui/src/components/agent-config-primitives.tsx +++ b/ui/src/components/agent-config-primitives.tsx @@ -87,7 +87,7 @@ export function HintIcon({ text }: { text: string }) { ); } -export function Field({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) { +export function Field({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode; configSection?: import("../adapters/types").AdapterConfigSection }) { return (
@@ -200,6 +200,8 @@ export function CollapsibleSection({ return (
+ ) : href ? ( {compact ? null : {action}} ) : null}
+ {galleryOpen && mediaPath ? ( + + ) : null} ); } diff --git a/ui/src/components/task-chat/TaskChatBubble.test.tsx b/ui/src/components/task-chat/TaskChatBubble.test.tsx index 330ff59220..8a4315b714 100644 --- a/ui/src/components/task-chat/TaskChatBubble.test.tsx +++ b/ui/src/components/task-chat/TaskChatBubble.test.tsx @@ -4,8 +4,9 @@ import type { ReactNode } from "react"; import type { IssueAttachment } from "@paperclipai/shared"; import { flushSync } from "react-dom"; import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { ThemeProvider } from "@/context/ThemeContext"; +import { IssueGalleryContext } from "@/context/IssueGalleryContext"; import { TaskChatBubble } from "./TaskChatBubble"; import type { TaskChatMessageItem } from "./task-chat-model"; @@ -40,6 +41,23 @@ describe("TaskChatBubble attachment chips", () => { ); } + it("opens attachment images in the shared task gallery", () => { + const openGallery = vi.fn(() => true); + const contentPath = "/api/attachments/shared-image/content"; + flushSync(() => root!.render( + + + + + , + )); + const image = container.querySelector(`img[src="${contentPath}"]`); + expect(image).not.toBeNull(); + flushSync(() => image!.click()); + expect(openGallery).toHaveBeenCalledWith(contentPath); + expect(document.querySelector('[role="dialog"]')).toBeNull(); + }); + it("renders a file reference as an attachment chip linking to the file", () => { renderMessage("Here you go.\n\n[notes.txt](/api/attachments/abc/content) "); diff --git a/ui/src/components/task-chat/TaskChatBubble.tsx b/ui/src/components/task-chat/TaskChatBubble.tsx index 7897c16b31..8a50515df3 100644 --- a/ui/src/components/task-chat/TaskChatBubble.tsx +++ b/ui/src/components/task-chat/TaskChatBubble.tsx @@ -1,5 +1,6 @@ -import { useState, type ReactNode } from "react"; +import { useContext, useState, type ReactNode } from "react"; import type { IssueAttachment } from "@paperclipai/shared"; +import { IssueGalleryContext } from "@/context/IssueGalleryContext"; import { cn } from "@/lib/utils"; import { useStreamlinedTaskChatPresentation } from "./presentation-mode"; import { MarkdownBody } from "@/components/MarkdownBody"; @@ -141,9 +142,12 @@ export function TaskChatBubble({ tryAgainNoLiveExecutionPathPending, }: TaskChatBubbleProps) { const streamlined = useStreamlinedTaskChatPresentation(); - // Clicking an embedded image opens the full-screen lightbox (with download); - // arrow keys walk across the other images in the same bubble. + // Task attachments share the page gallery; standalone images retain the bubble viewer. + const openIssueGallery = useContext(IssueGalleryContext); const [lightboxSrc, setLightboxSrc] = useState(null); + const openImage = (src: string) => { + if (!openIssueGallery?.(src)) setLightboxSrc(src); + }; if (item.interstitial) { // Interstitial updates are ephemeral (PAP-361): while streaming the text // lives on the live parent row's line (TaskChatStatusItem.selfTalk), and @@ -235,7 +239,7 @@ export function TaskChatBubble({ className={isHuman ? "paperclip-markdown-on-accent" : undefined} softBreaks linkIssueReferences - onImageClick={setLightboxSrc} + onImageClick={openImage} > {bodyText} @@ -258,7 +262,7 @@ export function TaskChatBubble({ type="button" className="group aspect-video min-w-0 overflow-hidden rounded-md bg-muted outline-none focus-visible:ring-2 focus-visible:ring-ring" aria-label={`Open ${ref.name || `image ${index + 1}`}`} - onClick={() => setLightboxSrc(ref.url)} + onClick={() => openImage(ref.url)} > setLightboxSrc(imageRefs[3].url)} + onClick={() => openImage(imageRefs[3].url)} > +{imageRefs.length - 3} diff --git a/ui/src/components/task-chat/TaskChatProtocolCard.test.tsx b/ui/src/components/task-chat/TaskChatProtocolCard.test.tsx index 5d75c9be4e..ce70518331 100644 --- a/ui/src/components/task-chat/TaskChatProtocolCard.test.tsx +++ b/ui/src/components/task-chat/TaskChatProtocolCard.test.tsx @@ -13,6 +13,8 @@ import type { TaskChatRuntimeRequestDecision, } from "./task-chat-model"; import type { IssueWorkProduct } from "@paperclipai/shared"; +import { IssueGalleryContext } from "@/context/IssueGalleryContext"; +import { RichWorkProductCard } from "./RichWorkProductCard"; import { stateChipFor } from "./RichWorkProductCard"; function workProduct(overrides: Partial = {}): IssueWorkProduct { @@ -179,6 +181,41 @@ describe("TaskChatProtocolCard", () => { expect(container.textContent).toContain("Open gallery"); }); + it.each(["image/png", "video/webm"])("opens %s artifacts in the task gallery", (contentType) => { + const openGallery = vi.fn(() => true); + const contentPath = "/api/attachments/media/content"; + flushSync(() => root.render( + + + , + )); + const button = container.querySelector('button[aria-label^="Open gallery:"]'); + expect(button).not.toBeNull(); + expect(container.querySelector("a")).toBeNull(); + flushSync(() => button!.click()); + expect(openGallery).toHaveBeenCalledWith(contentPath); + expect(document.querySelector('[role="dialog"]')).toBeNull(); + }); + + it("opens standalone artifact media in a modal with a download", async () => { + const contentPath = "/api/attachments/media/content"; + flushSync(() => root.render( + , + )); + await act(async () => container.querySelector('button[aria-label="Open gallery: Screenshot"]')!.click()); + expect(document.querySelector('[role="dialog"] img')?.getAttribute("src")).toBe(contentPath); + expect(document.querySelector('a[aria-label="Download proof.png"]')?.getAttribute("href")).toBe(`${contentPath}?download=1`); + await act(async () => document.querySelector('button[title="Close"]')!.click()); + expect(document.querySelector('[role="dialog"]')).toBeNull(); + }); + it("keeps completed and approved states out of the state-chip policy", () => { expect(stateChipFor("commit", "completed", "none")).toBeNull(); expect(stateChipFor("document", "approved", "approved")).toBeNull(); diff --git a/ui/src/context/IssueGalleryContext.ts b/ui/src/context/IssueGalleryContext.ts new file mode 100644 index 0000000000..43b74d2c54 --- /dev/null +++ b/ui/src/context/IssueGalleryContext.ts @@ -0,0 +1,4 @@ +import { createContext } from "react"; + +/** Opens media in the task gallery; false lets standalone media use its own viewer. */ +export const IssueGalleryContext = createContext<((src: string) => boolean) | null>(null); diff --git a/ui/src/lib/queryKeys.ts b/ui/src/lib/queryKeys.ts index aedea25d18..665c7674fb 100644 --- a/ui/src/lib/queryKeys.ts +++ b/ui/src/lib/queryKeys.ts @@ -214,6 +214,7 @@ export const queryKeys = { companyId: string, adapterType: string, environmentId?: string | null, + provider?: string, ) => [ "agents", @@ -221,6 +222,7 @@ export const queryKeys = { "adapter-models", adapterType, environmentId ?? null, + provider ?? null, ] as const, detectModel: (companyId: string, adapterType: string) => ["agents", companyId, "detect-model", adapterType] as const, diff --git a/ui/src/pages/AgentDetail.production.tsx b/ui/src/pages/AgentDetail.production.tsx index 6bc2a0ce0e..67a5c2bd7c 100644 --- a/ui/src/pages/AgentDetail.production.tsx +++ b/ui/src/pages/AgentDetail.production.tsx @@ -2092,12 +2092,13 @@ function ConfigurationTab({ const [awaitingRefreshAfterSave, setAwaitingRefreshAfterSave] = useState(false); const lastAgentRef = useRef(agent); + const catalogProvider = agent.adapterType === "paperclip_runner" ? String(agent.adapterConfig.provider ?? "codex") : undefined; const { data: adapterModels } = useQuery({ queryKey: companyId - ? queryKeys.agents.adapterModels(companyId, agent.adapterType) + ? queryKeys.agents.adapterModels(companyId, agent.adapterType, null, catalogProvider) : ["agents", "none", "adapter-models", agent.adapterType], - queryFn: () => agentsApi.adapterModels(companyId!, agent.adapterType), + queryFn: () => agentsApi.adapterModels(companyId!, agent.adapterType, { provider: catalogProvider }), enabled: Boolean(companyId) && content === "configuration", }); diff --git a/ui/src/pages/AgentDetail.tsx b/ui/src/pages/AgentDetail.tsx index 154b8a6c5c..a65f56346d 100644 --- a/ui/src/pages/AgentDetail.tsx +++ b/ui/src/pages/AgentDetail.tsx @@ -1953,12 +1953,13 @@ export function ConfigurationTab({ const [awaitingRefreshAfterSave, setAwaitingRefreshAfterSave] = useState(false); const lastAgentRef = useRef(agent); + const catalogProvider = agent.adapterType === "paperclip_runner" ? String(agent.adapterConfig.provider ?? "codex") : undefined; const { data: adapterModels } = useQuery({ queryKey: companyId - ? queryKeys.agents.adapterModels(companyId, agent.adapterType) + ? queryKeys.agents.adapterModels(companyId, agent.adapterType, null, catalogProvider) : ["agents", "none", "adapter-models", agent.adapterType], - queryFn: () => agentsApi.adapterModels(companyId!, agent.adapterType), + queryFn: () => agentsApi.adapterModels(companyId!, agent.adapterType, { provider: catalogProvider }), enabled: Boolean(companyId) && content === "runtime", }); @@ -2055,10 +2056,10 @@ export function ConfigurationTab({ hideInstructionsFile={hideInstructionsFile} content={content === "runtime" ? "configuration" : "secrets"} sectionLayout="cards" - environmentVariablesPlacement="secrets" + environmentVariablesPlacement="configuration" compactTestFeedback - sectionOrder={["adapter", "permissions", "environment", "run-policy", "identity"]} - sectionTitles={{ adapter: "Harness", permissions: "Model & execution", identity: "Agent identity" }} + sectionOrder={["identity", "adapter", "configuration", "environment", "environment-variables", "run-policy"]} + sectionTitles={{ adapter: "Adapter", configuration: "Configuration", identity: "Agent identity" }} canConfigureProviderTrace={canConfigureProviderTrace} /> : null} diff --git a/ui/src/pages/IssueDetail.test.tsx b/ui/src/pages/IssueDetail.test.tsx index f570fa1276..ea4b7e8278 100644 --- a/ui/src/pages/IssueDetail.test.tsx +++ b/ui/src/pages/IssueDetail.test.tsx @@ -1,5 +1,6 @@ // @vitest-environment jsdom +import { RichWorkProductCard } from "../components/task-chat/RichWorkProductCard"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { Agent, @@ -378,6 +379,7 @@ vi.mock("../components/IssueChatThread", () => ({ // the IssueChatThread stub above. vi.mock("../components/TaskChatThread", () => ({ TaskChatThread: (props: { + workProducts?: IssueWorkProduct[]; threadHeader?: ReactNode; onStopRun?: (runId: string) => Promise; stopRunLabel?: string; @@ -398,6 +400,9 @@ vi.mock("../components/TaskChatThread", () => ({
{props.threadHeader} Task chat thread + {props.workProducts?.map((workProduct) => ( + + ))} {props.onStopRun ? (