fix: repair runner configuration, macOS execution, and artifact galleries (#13062)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Agent adapters select a provider, a model, and a runtime.
> - Runner conversion rejected existing Claude agents. The model list
mixed providers.
> - The native Claude runner rejected custom models and could not launch
on macOS.
> - This pull request fixes conversion, model selection, and verified
macOS execution.
> - It also groups configuration fields consistently across adapters and
opens artifact images in the task gallery.
> - Operators can change an agent configuration and run the selected
model on their Mac.

## Linked Issues or Issue Description

**What happened?**

Converting an existing Claude agent to Paperclip Runner failed with a
Codex-only restriction. ACPX Claude showed unrelated models and required
`claude-sonnet-5`. Its native runtime rejected macOS. Configuration
mixed common model settings with process controls. Artifact cards
labeled “Open gallery” navigated to attachment URLs instead of opening
the task gallery.

**Expected behavior**

Conversion keeps agent identity and compatible settings. ACPX Claude
uses the normal Claude catalog and accepts typed model IDs. Codex uses
the native runner. The verified Claude runtime can launch on macOS ARM64
and x64. Common configuration sections place the same fields together
across adapters. Artifact images open in the shared task gallery with
navigation and downloads.

**Steps to reproduce**

1. Open the configuration of an existing Claude agent.
2. Convert it to Paperclip Runner.
3. Select ACPX Claude and a different catalog model or a typed model ID.
4. Save the agent and run a disposable task on macOS.
5. Inspect configuration and advanced run-policy controls across
adapters.

**Paperclip version or commit**

The bugs were reproduced on `165ca56a22adb60e5fda56045442d9c8498116a8`.
This branch was rebased onto `7ed122911`.

**Deployment mode**

Built from source. Local test-drive instance on macOS ARM64 with an
isolated database.

Related work: #11798 addresses unsupported ACP session options in the
existing adapter path. #13048 addresses working-folder preservation.
This change fixes native runner configuration and launch behavior.

## What Changed

- Remove the Codex-only conversion restriction. Preserve agent identity,
instructions, directories, credentials, and compatible model settings.
Reset incompatible sessions while retaining history.
- Show ACPX Claude and native Codex as distinct provider choices. Remove
ACPX Codex from advertised configuration. Normalize legacy
configurations before fresh runs without rewriting historical run
descriptors.
- Select model catalogs and cache entries by provider. Support refresh
and typed model IDs. Pass exact Claude IDs through session creation,
model changes, and recovery.
- Add verified macOS ARM64 and x64 Claude SDK snapshots. Bound
executable allocation and total snapshot size. Preserve package checks,
dependency isolation, process ownership, cancellation, and Linux
descriptor loading.
- Probe local runtime readiness. Report remote platform checks as
incomplete until the remote runner verifies its runtime.
- Surface actual model rejection and allow correction and retry.
- Repair missing ACPX goal-capability helpers exposed by the post-rebase
live test. Persist and restore the optional capability without breaking
session startup.
- Put Agent identity first and intentionally remove the Capabilities
editor, as requested. This is removal of UI editing, not relocation:
preserve existing capability metadata and API compatibility without
adding another editor. Use the themed select for configurable permission
modes, with normal text instead of monospace.
- Put model and provider under Adapter. Give environment variables their
own section. Fold command and arguments under Configuration. Fold
lifecycle, timeout, and interrupt grace under Advanced Run Policy. Hide
single-option permission controls.

- Open image and video artifact cards in the existing task gallery,
including cards in the artifacts panel. Chat attachment images use the
same gallery. Preserve standalone media previews and download links.

## Verification

- Rebased focused UI/API/database suites: 293 tests passed.
- Rebased native runtime and ACPX suites: 242 passed, 7 skipped.
- Repository typecheck, build, and token gates passed for the runner
changes. Gallery follow-up UI typecheck, build, and token gates also
passed.
- Follow-up UI suites passed (86 tests), packaging checks passed (14
tests), and the final focused runtime suites passed (126 passed, 7
skipped).
- Linux container isolation and lifecycle fixtures passed before rebase
(57 passed, 2 skipped). Rust ACPX provider-session tests passed after
rebase (8 tests).
- Browser tests completed actual Claude and native Codex tasks on macOS
ARM64. They covered conversion, catalog refresh, a non-default catalog
model, a typed `haiku` ID, save/reload, cancel, follow-up session
continuity, invalid-model errors, and recovery.
- Final-revision live tests completed a typed Claude task, a follow-up
with the same provider session, and a native Codex task on macOS ARM64.
- Browser tests confirmed the moved interrupt-grace field saves and
survives reload. Cross-adapter tests cover Claude, Codex, Gemini,
process, gateway, and schema forms.
- Full local run: 7,080 passed, 30 skipped, and two timeouts. Both
timeout suites passed on isolated rerun (84 tests); the failures were
the plugin login-worker exit diagnostic and the runner real-server
vertical slice.
- Final follow-up checks: 50 registry tests and 45 snapshot/installation
tests passed (6 platform-specific skips). Oversized executable rejection
is covered before allocation or reading; unsupported-platform tests
invoke the real installation probe.
- Runner head `ddb5101c483a297f74875ab96b3c66035b002d50`: all CI gates
green, including full runner verification, repository build, typecheck,
general/serialized server suites, browser tests, and canary dry run. [CI
run](https://github.com/paperclipai/paperclip/actions/runs/34286178670).
- Greptile: 5/5 on that runner head. All four review threads resolved.
Superagent, Socket, and Snyk checks green.
- After snapshot hardening, another real Claude task completed on this
Mac using the rebuilt runtime.

- Gallery follow-up: 148 focused tests passed, covering artifact
selection, shared attachment collections, deduplication, image/video
cards, standalone previews, downloads, and closing. Live browser
verification completed on the settings follow-up: artifact selection,
6-image pagination with wrapping, download action, and closing all
stayed on the same task URL. All checks passed on gallery head
`96136da58ff195bf6ca00b281eb3022ad12d7bd8`: [CI
run](https://github.com/paperclipai/paperclip/actions/runs/34287987536).
Greptile returned 5/5 on that exact head with no unresolved threads.

- Final settings polish: 96 focused tests, UI typecheck/build, and token
gates passed. A real browser walkthrough verified readable permission
options, identity placement, Capabilities removal, and permission
save/reload. Original test-agent permission mode restored. All 31 checks
passed on final head `e46540d6bf32bfb0566dca16b2f4a75ba437618c`: [CI
run](https://github.com/paperclipai/paperclip/actions/runs/34292797886).
Greptile returned 5/5 with no unresolved threads.

## Risks

- Capabilities intentionally has no editable UI field after this change.
Existing values remain readable and API-compatible; removing the field
does not erase stored metadata.

- macOS launch now copies verified package files into private snapshots.
The implementation must retain isolation and clean up snapshots on exit.
- Runtime provider or model changes reset the current session.
Historical runs remain available.
- The macOS x64 SDK executable digest was verified, but a live Intel Mac
run was not available. Linux verification used container fixtures, not a
real Claude task.
- Remote environment tests report a warning when only the platform has
been checked. They do not claim package readiness from the server host.

## Model Used

OpenAI Codex, based on GPT-6. The exact served model identifier and
context-window limit are not exposed in this session. Used reasoning,
repository inspection, code execution, Rust and TypeScript tests, and
browser automation.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass (focused suites and both
timeout suites on rerun; full-run counts above)
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-09-08 19:10:09 -05:00 committed by GitHub
parent 8f099c3f83
commit 2043e0c735
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
78 changed files with 1697 additions and 635 deletions

View File

@ -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

View File

@ -120,6 +120,8 @@ export {
isPaperclipRunnerProvider,
resolvePaperclipRunnerIdleTimeoutMs,
resolvePaperclipRunnerModel,
paperclipRunnerTransitionConfig,
normalizeLegacyRunnerProvider,
resolvePaperclipRunnerPermissionMode,
} from "./paperclip-runner-permissions.js";
export {

View File

@ -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<string, unknown> {
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<string, unknown>,
): Record<string, unknown> {
if (config.provider !== "acpx" || config.acpxAgent !== "codex") return config;
const {
acpxAgent: _agent,
acpxPermissionMode: _permission,
...rest
} = config;
return { ...rest, provider: "codex", codexPermissionMode: "never" };
}

View File

@ -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<string, unknown>,
): Record<string, unknown> {
if (adapterType !== "paperclip_runner") return config;
config = normalizeLegacyRunnerProvider(config);
const next: Record<string, unknown> = {
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);
}

View File

@ -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", () => {

View File

@ -1,6 +1,7 @@
import {
buildAdapterEnvConfig,
isPaperclipRunnerProvider,
normalizeLegacyRunnerProvider,
resolvePaperclipRunnerModel,
resolvePaperclipRunnerIdleTimeoutMs,
resolvePaperclipRunnerPermissionMode,
@ -71,7 +72,7 @@ export function buildCodexLocalConfig(v: CreateConfigValues): Record<string, unk
/** Build a provider profile accepted by the experimental Rust runner. */
export function buildPaperclipRunnerConfig(v: CreateConfigValues): Record<string, unknown> {
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<string
"warmHandleIdleMs",
"dangerouslyBypassApprovalsAndSandbox",
"dangerouslyBypassSandbox",
"instructionsFilePath",
"modelReasoningEffort",
"search",
"fastMode",
@ -95,7 +95,7 @@ export function buildPaperclipRunnerConfig(v: CreateConfigValues): Record<string
const provider = isPaperclipRunnerProvider(providerCandidate)
? providerCandidate
: "codex";
const acpxAgent = schemaValues.acpxAgent === "codex" ? "codex" : "claude";
const schemaModel = typeof schemaValues.model === "string"
? schemaValues.model.trim()
: "";
@ -233,8 +233,8 @@ export function buildPaperclipRunnerConfig(v: CreateConfigValues): Record<string
: {}),
...(provider === "acpx"
? {
acpxAgent,
model: acpxAgent === "claude" ? "claude-sonnet-5" : "gpt-5.6-sol",
acpxAgent: "claude",
model: configuredModel || schemaModel || resolvePaperclipRunnerModel("acpx", undefined),
}
: {}),
...(provider === "claude_managed"

View File

@ -218,8 +218,8 @@ pnpm --filter @paperclipai/paperclip-runner test:runner-workflow-evals
pnpm --filter @paperclipai/paperclip-runner report:runner-chaos-evals
```
`report:runner-live-evals` is a paid, provider-backed command. Native Codex and
the ACPX Codex profile require `OPENAI_API_KEY`; ACPX Claude requires
`report:runner-live-evals` is a paid, provider-backed command. Native Codex
requires `OPENAI_API_KEY`; ACPX Claude requires
`ANTHROPIC_API_KEY`; OpenCode candidates require `OPENROUTER_API_KEY`. The live
matrix admits no Pi profile and does not persist credential values. Set
`PAPERCLIP_EVAL_MAX_CAMPAIGN_COST_USD` to a positive finite number to bound

View File

@ -172,7 +172,9 @@ impl AcpxProviderDescriptor {
|| self.driver != "acpx_runtime"
|| self.provider_version != "0.13.1"
|| self.acpx_version != "0.13.1"
|| self.model != expected.0
|| (self.agent != "claude" && self.model != expected.0)
|| self.model.trim().is_empty()
|| self.model.len() > 1024
|| self.agent_server_package != expected.1
|| self.agent_server_version != expected.2
|| self.agent_runtime_package.as_deref() != expected.3

View File

@ -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

View File

@ -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()

View File

@ -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();
},

View File

@ -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 } }

View File

@ -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<void> {
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() {

View File

@ -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<VerifiedAcpxRuntimeExecutable | null> {
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<T>(
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<void> {
const installation = await verifyQualifiedAcpxInstallation(resolveQualifiedAcpxProfile("claude", model));
const lease = await installation.openCommand();
await lease.close();
}

View File

@ -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);
});

View File

@ -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();
}

View File

@ -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();
}
});
});

View File

@ -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<string, string>;
handoff: { path: string; digest: string };
close(): Promise<void>;
}
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<Buffer> {
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<AcpxPrivateSnapshot> {
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<string, string> = {};
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<void> => {
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;
}
}

View File

@ -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"),

View File

@ -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<T>(value: T): T {

View File

@ -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,

View File

@ -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";

View File

@ -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"]

View File

@ -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",
);

View File

@ -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

View File

@ -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 = [];

View File

@ -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);
});

View File

@ -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 () => {

View File

@ -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 () => {

View File

@ -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([

View File

@ -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();

View File

@ -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");
});
});

View File

@ -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" } },
},
{

View File

@ -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

View File

@ -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 },
});

View File

@ -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, {

View File

@ -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);

View File

@ -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",

View File

@ -2905,6 +2905,7 @@ export function nativeSessionFailureDisposition(
sourceFailureCode?: ReturnType<typeof nativeSessionFailureSourceCode>,
) {
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";
}

View File

@ -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,
};
}

View File

@ -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", () => {

View File

@ -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 && (
<Field label="Agent instructions file" hint={instructionsFileHint}>
@ -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 && (
<Field
<Field configSection="advanced"
label="ACP server command"
hint="Optional override for the Claude ACP server command. Defaults to the package-local claude-agent-acp binary."
>
@ -133,7 +136,7 @@ export function ClaudeLocalAdvancedFields({
/>
</Field>
)}
<Field label="ACP session mode" hint="Persistent keeps ACP session state between runs. One-shot starts fresh each run.">
<Field configSection="runPolicy" label="ACP session mode" hint="Persistent keeps ACP session state between runs. One-shot starts fresh each run.">
<select
className={inputClass}
value={
@ -199,7 +202,7 @@ export function ClaudeLocalAdvancedFields({
</div>
</Field>
)}
<Field
<Field configSection="runPolicy"
label="ACP warm process idle ms"
hint="Defaults to 0, which closes the ACP process after each run while retaining persistent session state."
>
@ -279,5 +282,5 @@ export function ClaudeLocalAdvancedFields({
)}
</Field>
</>
);
));
}

View File

@ -31,7 +31,7 @@ describe("Paperclip Runner Codex configuration", () => {
expect(html).toContain('<option value="codex" selected="">Codex</option>');
expect(html).toContain("OpenCode 1.18.29");
expect(html).toContain("ACPX");
expect(html).toContain("Automatic (isolated)");
expect(html).not.toContain("Permission mode");
expect(html).not.toContain("Ask when requested");
expect(html).not.toContain("Ask for untrusted operations");
expect(html).toContain("Claude Managed");
@ -48,36 +48,31 @@ describe("Paperclip Runner Codex configuration", () => {
expect(html).toContain(
'<option value="opencode" selected="">OpenCode 1.18.29</option>',
);
expect(html).toContain(
'<option value="allow" selected="">Full auto (allow)</option>',
);
expect(html).toContain("Ask for permission");
expect(html).toContain("Deny operations");
expect(html).toContain("Full auto (allow)");
expect(html).toContain('aria-label="Permission mode"');
expect(html).toContain("font-sans");
expect(html).not.toContain("Ask for untrusted operations");
});
it("renders only the qualified ACPX Claude and Codex profiles", () => {
it("offers ACPX Claude without a redundant agent selector", () => {
const html = renderRunner({
provider: "acpx",
acpxAgent: "claude",
acpxPermissionMode: "approve-reads",
});
expect(html).toContain('<option value="acpx" selected="">ACPX</option>');
expect(html).toContain(
'<option value="claude" selected="">Claude via ACPX</option>',
);
expect(html).toContain("Codex via ACPX");
expect(html).toContain('<option value="acpx" selected="">ACPX Claude</option>');
expect(html).not.toContain("ACP agent");
expect(html).not.toContain("Codex via ACPX");
expect(html).not.toContain("ACPX Codex");
expect(html).not.toContain("Pi via ACPX");
expect(html).toContain(
'<option value="approve-reads" selected="">Conservative (fail closed)</option>',
);
expect(html).toContain("Conservative (fail closed)");
});
it("falls back to the fail-closed Codex permission mode", () => {
const html = renderRunner({ codexPermissionMode: "unrestricted" });
expect(html).toContain('value="__unsupported__" disabled="" selected=""');
expect(html).toContain("Unsupported saved mode — select a qualified mode");
expect(html).toContain("cannot start or recover a Paperclip Runner run");
expect(html).toContain("Select Automatic (isolated) to remediate it");
expect(html).not.toContain("Full auto (never ask)");

View File

@ -1,3 +1,5 @@
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../components/ui/select";
import { configFieldsForSection } from "../config-sections";
import type { AdapterConfigFieldsProps } from "../types";
import {
Field,
@ -30,14 +32,12 @@ const inputClass =
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. Note: Codex may still auto-apply repo-scoped AGENTS.md files from the workspace.";
const defaultOpenCodeRunnerModel = "openrouter/deepseek/deepseek-v4-flash-0731";
const acpxRunnerModels = {
claude: "claude-sonnet-5",
codex: "gpt-5.6-sol",
} as const;
const defaultAcpxClaudeModel = "claude-sonnet-5";
const defaultClaudeManagedModel = "claude-sonnet-5";
const defaultAwsAgentCoreModel = "global.anthropic.claude-sonnet-4-6";
export function CodexLocalConfigFields({
section,
mode,
isCreate,
adapterType,
@ -59,7 +59,7 @@ export function CodexLocalConfigFields({
const configuredRunnerProvider = runnerManaged
? isCreate
? values!.adapterSchemaValues?.provider
: eff("adapterConfig", "provider", config.provider ?? "codex")
: eff("adapterConfig", "provider", config.provider === "acpx" && config.acpxAgent === "codex" ? "codex" : config.provider ?? "codex")
: "codex";
const runnerProvider: PaperclipRunnerProvider = isPaperclipRunnerProvider(
configuredRunnerProvider,
@ -113,13 +113,6 @@ export function CodexLocalConfigFields({
mark("adapterConfig", key, value);
}
};
const configuredAcpxAgent =
runnerManaged && runnerProvider === "acpx"
? isCreate
? values!.adapterSchemaValues?.acpxAgent
: eff("adapterConfig", "acpxAgent", config.acpxAgent ?? "claude")
: "claude";
const acpxAgent = configuredAcpxAgent === "codex" ? "codex" : "claude";
const runnerLifecycleMode = runnerManaged
? isCreate
? (values!.paperclipRunnerLifecycleMode ?? "per_turn")
@ -163,7 +156,7 @@ export function CodexLocalConfigFields({
? "Fast mode consumes credits/tokens much faster than standard Codex runs."
: `Fast mode currently only works on ${supportedModelsLabel} or manual model IDs. Paperclip will ignore this toggle until the model is switched.`;
return (
return configFieldsForSection(section, (
<>
{!hideEngineChoice && (
<Field
@ -196,7 +189,7 @@ export function CodexLocalConfigFields({
</Field>
)}
{runnerManaged && (
<Field
<Field configSection="adapter"
label="Provider"
hint="The runner persists this provider with each run so recovery cannot drift after configuration changes."
>
@ -215,7 +208,7 @@ export function CodexLocalConfigFields({
: provider === "aws_agentcore"
? defaultAwsAgentCoreModel
: provider === "acpx"
? acpxRunnerModels.claude
? defaultAcpxClaudeModel
: DEFAULT_CODEX_LOCAL_MODEL;
if (isCreate) {
set!({
@ -239,20 +232,10 @@ export function CodexLocalConfigFields({
<option value="opencode">OpenCode 1.18.29</option>
<option value="claude_managed">Claude Managed</option>
<option value="aws_agentcore">AWS AgentCore</option>
<option value="acpx">ACPX</option>
<option value="acpx">ACPX Claude</option>
</select>
</Field>
)}
{runnerManaged && !runnerPermissionCapability.configurable && (
<Field
label="Permission mode"
hint={runnerPermissionCapability.description}
>
<div className={`${inputClass} text-muted-foreground`}>
Provider-managed
</div>
</Field>
)}
{runnerManaged && runnerProvider === "claude_managed" && (
<>
<Field
@ -359,7 +342,7 @@ export function CodexLocalConfigFields({
className={inputClass}
/>
</Field>
<Field
<Field configSection="runPolicy"
label="Invocation timeout (seconds)"
hint="Qualified range is 1300 seconds."
>
@ -387,52 +370,21 @@ export function CodexLocalConfigFields({
/>
</>
)}
{runnerManaged && runnerProvider === "acpx" && (
<Field
label="ACP agent"
hint="Only the pinned Claude and Codex profiles are qualified; Pi is unavailable."
>
<select
className={inputClass}
value={acpxAgent}
onChange={(event) => {
const agent = event.target.value === "codex" ? "codex" : "claude";
const model = acpxRunnerModels[agent];
if (isCreate) {
set!({
model,
adapterSchemaValues: {
...values!.adapterSchemaValues,
acpxAgent: agent,
},
});
} else {
mark("adapterConfig", "acpxAgent", agent);
mark("adapterConfig", "model", model);
}
}}
>
<option value="claude">Claude via ACPX</option>
<option value="codex">Codex via ACPX</option>
</select>
</Field>
)}
{runnerManaged && runnerPermissionCapability.configurable && (
{runnerManaged && runnerPermissionCapability.configurable && (runnerPermissionCapability.options.length > 1 || runnerPermissionModeUnsupported) && (
<Field
label="Permission mode"
hint={`${runnerPermissionCapability.description} The selected mode does not widen Paperclip's workspace, network, credential, or planning boundaries.`}
>
<select
className={inputClass}
<Select
value={
runnerPermissionModeUnsupported
? "__unsupported__"
: runnerPermissionMode
}
onChange={(event) => {
onValueChange={(selectedMode) => {
const value = resolvePaperclipRunnerPermissionMode(
runnerProvider,
event.target.value,
selectedMode,
) as PaperclipRunnerPermissionMode;
if (isCreate) {
set!({
@ -450,17 +402,26 @@ export function CodexLocalConfigFields({
}
}}
>
{runnerPermissionModeUnsupported && (
<option value="__unsupported__" disabled>
Unsupported saved mode select a qualified mode
</option>
)}
{runnerPermissionCapability.options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
<SelectTrigger aria-label="Permission mode" className="w-full font-sans">
<SelectValue>
{runnerPermissionModeUnsupported
? "Unsupported saved mode — select a qualified mode"
: runnerPermissionCapability.options.find((option) => option.value === runnerPermissionMode)?.label}
</SelectValue>
</SelectTrigger>
<SelectContent>
{runnerPermissionModeUnsupported && (
<SelectItem value="__unsupported__" disabled>
Unsupported saved mode select a qualified mode
</SelectItem>
)}
{runnerPermissionCapability.options.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{runnerPermissionModeUnsupported && runnerProvider === "codex" && (
<p className="mt-1 text-xs text-destructive" role="alert">
This saved Codex mode cannot start or recover a Paperclip Runner
@ -470,7 +431,7 @@ export function CodexLocalConfigFields({
</Field>
)}
{runnerManaged && (
<Field
<Field configSection="runPolicy"
label="Runner lifecycle"
hint="Turn by turn suspends after each run. Warm keeps the same provider process available between governed runs."
>
@ -490,7 +451,7 @@ export function CodexLocalConfigFields({
</Field>
)}
{runnerManaged && runnerLifecycleMode === "warm" && (
<Field
<Field configSection="runPolicy"
label="Warm idle timeout (ms)"
hint="After this much inactivity, runnerd checkpoints and suspends the provider session. The maximum is 24 hours."
>
@ -531,7 +492,7 @@ export function CodexLocalConfigFields({
{acpSelected && (
<>
{!managedSandboxOnly && (
<Field
<Field configSection="advanced"
label="ACP server command"
hint="Optional override for the Codex ACP server command. Defaults to the package-local codex-acp binary."
>
@ -556,7 +517,7 @@ export function CodexLocalConfigFields({
/>
</Field>
)}
<Field
<Field configSection="runPolicy"
label="ACP session mode"
hint="Persistent keeps ACP session state between runs. One-shot starts fresh each run."
>
@ -638,7 +599,7 @@ export function CodexLocalConfigFields({
</div>
</Field>
)}
<Field
<Field configSection="runPolicy"
label="ACP warm process idle ms"
hint="Defaults to 0, which closes the ACP process after each run while retaining persistent session state."
>
@ -765,5 +726,5 @@ export function CodexLocalConfigFields({
models={models}
/>
</>
);
));
}

View File

@ -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<AdapterConfigFieldsProps>,
adapterType: string,
section: AdapterConfigSection,
config: Record<string, unknown> = {},
) {
return renderToStaticMarkup(
<TooltipProvider>
<Component
mode="edit"
isCreate={false}
adapterType={adapterType}
section={section}
values={null}
set={null}
config={config}
eff={(_group, _key, original) => original}
mark={() => {}}
models={[]}
hideInstructionsFile
/>
</TooltipProvider>,
);
}
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"',
);
},
);
});

View File

@ -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";
}

View File

@ -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, (
<Field label="Agent instructions file" hint={instructionsFileHint}>
<div className="flex items-center gap-2">
<DraftInput
@ -45,5 +47,5 @@ export function CursorLocalConfigFields({
<ChoosePathButton />
</div>
</Field>
);
));
}

View File

@ -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 && (
<Field
<Field configSection="advanced"
label="ACP server command"
hint="Optional override for the Gemini ACP server command. Defaults to gemini --acp."
>
@ -74,7 +76,7 @@ export function GeminiLocalConfigFields({
/>
</Field>
)}
<Field label="ACP session mode" hint="Persistent keeps ACP session state between runs. One-shot starts fresh each run.">
<Field configSection="runPolicy" label="ACP session mode" hint="Persistent keeps ACP session state between runs. One-shot starts fresh each run.">
<select
className={inputClass}
value={
@ -140,7 +142,7 @@ export function GeminiLocalConfigFields({
</div>
</Field>
)}
<Field
<Field configSection="runPolicy"
label="ACP warm process idle ms"
hint="Defaults to 0, which closes the ACP process after each run while retaining persistent session state."
>
@ -193,5 +195,5 @@ export function GeminiLocalConfigFields({
</Field>
)}
</>
);
));
}

View File

@ -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. Paperclip stages it into the Grok workspace as Agents.md when possible.";
export function GrokLocalConfigFields({
section,
isCreate,
values,
set,
@ -20,7 +22,7 @@ export function GrokLocalConfigFields({
hideInstructionsFile,
}: AdapterConfigFieldsProps) {
if (hideInstructionsFile) return null;
return (
return configFieldsForSection(section, (
<>
<Field label="Agent instructions file" hint={instructionsFileHint}>
<div className="flex items-center gap-2">
@ -47,5 +49,5 @@ export function GrokLocalConfigFields({
</div>
</Field>
</>
);
));
}

View File

@ -1,3 +1,4 @@
import { configFieldsForSection } from "../config-sections";
import { useEffect, useState } from "react";
import { Eye, EyeOff } from "lucide-react";
import type { AdapterConfigFieldsProps, CreateConfigValues } from "../types";
@ -97,6 +98,7 @@ function SecretField({
}
export function HermesGatewayConfigFields({
section,
isCreate,
values,
set,
@ -138,7 +140,7 @@ export function HermesGatewayConfigFields({
? String(readCreateValue(values, "headers", "") ?? "")
: headersDraft;
return (
return configFieldsForSection(section, (
<>
<Field
label="API base URL"
@ -174,7 +176,7 @@ export function HermesGatewayConfigFields({
/>
</Field>
<Field
<Field configSection="runPolicy"
label="Session key strategy"
hint="Controls X-Hermes-Session-Key. Issue scoped prevents cross-task memory bleed by default."
>
@ -190,7 +192,7 @@ export function HermesGatewayConfigFields({
</select>
</Field>
<Field label="Timeout seconds">
<Field configSection="runPolicy" label="Timeout seconds">
<DraftNumberInput
value={Number.isFinite(timeoutSec) ? timeoutSec : DEFAULT_TIMEOUT_SEC}
onCommit={(v) => writeValue("timeoutSec", v)}
@ -248,5 +250,5 @@ export function HermesGatewayConfigFields({
/>
</Field>
</>
);
));
}

View File

@ -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, (
<Field label="Webhook URL" hint={help.webhookUrl}>
<DraftInput
value={
@ -34,5 +36,5 @@ export function HttpConfigFields({
placeholder="https://..."
/>
</Field>
);
));
}

View File

@ -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, (
<>
<Field label="Agent instructions file" hint={instructionsFileHint}>
<div className="flex items-center gap-2">
@ -47,5 +49,5 @@ export function KimiLocalConfigFields({
</div>
</Field>
</>
);
));
}

View File

@ -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, (
<>
<Field label="Gateway URL" hint={help.webhookUrl}>
<DraftInput
@ -325,7 +327,7 @@ export function OpenClawGatewayConfigFields({
/>
</Field>
<Field label="Timeout (seconds)">
<Field configSection="runPolicy" label="Timeout (seconds)">
<DraftInput
value={
isCreate
@ -384,7 +386,7 @@ export function OpenClawGatewayConfigFields({
</Field>
)}
<Field label="Wait timeout (ms)">
<Field configSection="runPolicy" label="Wait timeout (ms)">
<DraftInput
value={
isCreate
@ -453,5 +455,5 @@ export function OpenClawGatewayConfigFields({
</div>
</Field>
</>
);
));
}

View File

@ -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 && (
<Field label="Agent instructions file" hint={instructionsFileHint}>
@ -68,5 +70,5 @@ export function OpenCodeLocalConfigFields({
}
/>
</>
);
));
}

View File

@ -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, (
<Field label="Agent instructions file" hint={instructionsFileHint}>
<div className="flex items-center gap-2">
<DraftInput
@ -45,5 +47,5 @@ export function PiLocalConfigFields({
<ChoosePathButton />
</div>
</Field>
);
));
}

View File

@ -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, (
<>
<Field label="Command" hint={help.command}>
<Field configSection="advanced" label="Command" hint={help.command}>
<DraftInput
value={
isCreate
@ -51,7 +53,7 @@ export function ProcessConfigFields({
placeholder="e.g. node, python"
/>
</Field>
<Field label="Args (comma-separated)" hint={help.args}>
<Field configSection="advanced" label="Args (comma-separated)" hint={help.args}>
<DraftInput
value={
isCreate
@ -73,5 +75,5 @@ export function ProcessConfigFields({
/>
</Field>
</>
);
));
}

View File

@ -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<AdapterConfigSchema | null>(
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<string, unknown> = {};
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) {

View File

@ -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) => <TooltipProvider><SchemaConfigFields {...props} adapterType={adapterType} /></TooltipProvider>;
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) => <TooltipProvider><SchemaConfigFields {...props} adapterType={adapterType} /></TooltipProvider>;
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" } });
});

View File

@ -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;

View File

@ -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<AdapterModel[]>(

View File

@ -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");

View File

@ -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");

View File

@ -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<string, EnvBinding> = {};
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" && <ClaudeLocalAdvancedFields {...adapterFieldProps} section={section} />}
<uiAdapter.ConfigFields {...adapterFieldProps} section={section} hideModel={isLocal} />
</>
);
// 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 = (
<div data-config-field="environment-variables">
<Field label="Environment variables" hint={help.envVars}>
{environmentVariablesEditor}
</Field>
</div>
);
if (!isCreate && props.content === "secrets") {
return (
@ -1329,21 +1336,6 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
chooseLabel="Choose manager…"
/>
</Field>
<Field label="Capabilities" hint={help.capabilities}>
<MarkdownEditor
value={eff("identity", "capabilities", props.agent.capabilities ?? "") ?? ""}
onChange={(v) => 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;
}}
/>
</Field>
{isLocal && !props.hidePromptTemplate && (
<>
<Field label="Prompt Template" hint={help.promptTemplate}>
@ -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) {
</Field>
)}
{!isLocal && <uiAdapter.ConfigFields {...adapterFieldProps} />}
{/* Local adapter-specific fields are rendered inside Permissions & Configuration */}
</div>
</div>
{/* ---- Permissions & Configuration ---- */}
{isLocal && (
<div data-config-section="permissions" className={cn(!cards && "border-b border-border")}>
{cards
? <h3 className="text-sm font-medium mb-3">{props.sectionTitles?.["permissions"] ?? "Permissions & Configuration"}</h3>
: <div className="px-4 py-2 text-xs font-medium text-muted-foreground">Permissions &amp; Configuration</div>
}
<div className={cn(cards ? "border border-border rounded-lg p-4 space-y-3" : "px-4 pb-3 space-y-3")}>
{/*
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 && (
<div data-config-field="command">
<Field label="Command" hint={help.localCommand}>
<DraftInput
value={
isCreate
? val!.command
: eff(
"adapterConfig",
adapterCommandField,
String(
config.command ?? "",
),
)
}
onCommit={(v) =>
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<string, string>)[adapterType] ?? adapterType.replace(/_local$/, "")
}
/>
</Field>
</div>
)}
{renderAdapterFields("adapter")}
{isLocal && (<>
<ModelDropdown
models={models}
value={currentModelId}
@ -1664,13 +1591,13 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
open={modelOpen}
onOpenChange={setModelOpen}
defaultLabel={adapterType === "claude_local" ? `Default (${DEFAULT_CLAUDE_LOCAL_MODEL})` : undefined}
allowDefault={adapterType !== "opencode_local" && adapterType !== "pi_local"}
allowDefault={adapterType !== "opencode_local" && adapterType !== "pi_local" && adapterType !== "paperclip_runner"}
required={adapterType === "opencode_local" || adapterType === "pi_local"}
groupByProvider={adapterType === "opencode_local" || adapterType === "pi_local"}
creatable
detectedModel={detectedModel}
detectedModelCandidates={[]}
onDetectModel={adapterType === "opencode_local"
onDetectModel={adapterType === "opencode_local" || adapterType === "paperclip_runner"
? undefined
: async () => {
const result = await refetchDetectedModel();
@ -1723,6 +1650,19 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
)}
</>
)}
</>)}
</div>
</div>
{/* ---- Configuration ---- */}
{(
<div data-config-section="configuration" className={cn(!cards && "border-b border-border")}>
{cards
? <h3 className="text-sm font-medium mb-3">Configuration</h3>
: <div className="px-4 py-2 text-xs font-medium text-muted-foreground">Configuration</div>
}
<div className={cn(cards ? "border border-border rounded-lg p-4 space-y-3" : "px-4 pb-3 space-y-3")}>
{!isCreate && typeof config.bootstrapPromptTemplate === "string" && config.bootstrapPromptTemplate && (
<>
<Field label="Bootstrap prompt (legacy)" hint={help.bootstrapPrompt}>
@ -1749,10 +1689,60 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
</div>
</>
)}
{adapterType === "claude_local" && (
<ClaudeLocalAdvancedFields {...adapterFieldProps} />
{renderAdapterFields("configuration")}
{(isLocal || adapterType === "process" || configSchema?.fields.some((field) => schemaFieldSection(field.key) === "advanced")) && (
<CollapsibleSection
title="Advanced"
open={configurationAdvancedOpen}
onToggle={() => setConfigurationAdvancedOpen(!configurationAdvancedOpen)}
>
<div className="space-y-3">
{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 && (
<Field label="Command" hint={help.localCommand}>
<DraftInput
value={
isCreate
? val!.command
: eff(
"adapterConfig",
adapterCommandField,
String(
config.command ?? "",
),
)
}
onCommit={(v) =>
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<string, string>)[adapterType] ?? adapterType.replace(/_local$/, "")
}
/>
</Field>
)}
<uiAdapter.ConfigFields {...adapterFieldProps} />
<Field label="Extra args (comma-separated)" hint={help.extraArgs}>
<DraftInput
@ -1771,37 +1761,24 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
/>
</Field>
{props.environmentVariablesPlacement !== "secrets" && environmentVariablesField}
{/* Edit-only: timeout + grace period */}
{!isCreate && (
<>
<Field label="Timeout (sec)" hint={help.timeoutSec}>
<DraftNumberInput
value={eff(
"adapterConfig",
"timeoutSec",
Number(config.timeoutSec ?? 0),
)}
onCommit={(v) => mark("adapterConfig", "timeoutSec", v)}
immediate
className={inputClass}
/>
</Field>
<Field label="Interrupt grace period (sec)" hint={help.graceSec}>
<DraftNumberInput
value={eff(
"adapterConfig",
"graceSec",
Number(config.graceSec ?? 15),
)}
onCommit={(v) => mark("adapterConfig", "graceSec", v)}
immediate
className={inputClass}
/>
</Field>
</>
</>)}
{renderAdapterFields("advanced")}
</div>
</CollapsibleSection>
)}
</div>
</div>
)}
{props.environmentVariablesPlacement !== "secrets" && (isLocal || configSchema?.fields.some((field) => schemaFieldSection(field.key) === "environment")) && (
<div data-config-section="environment-variables" className={cn(!cards && "border-b border-border")}>
{cards
? <h3 className="text-sm font-medium mb-3">Environment variables</h3>
: <div className="px-4 py-2 text-xs font-medium text-muted-foreground">Environment variables</div>
}
<div className={cn(cards ? "border border-border rounded-lg p-4 space-y-3" : "px-4 pb-3 space-y-3")}>
{isLocal ? environmentVariablesEditor : renderAdapterFields("environment")}
</div>
</div>
)}
@ -1826,6 +1803,9 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
numberHint={help.intervalSec}
showNumber={val!.heartbeatEnabled}
/>
<CollapsibleSection title="Advanced Run Policy" open={runPolicyAdvancedOpen} onToggle={() => setRunPolicyAdvancedOpen(!runPolicyAdvancedOpen)}>
<div className="space-y-3">{renderAdapterFields("runPolicy")}</div>
</CollapsibleSection>
</div>
</div>
) : !isCreate ? (
@ -1856,6 +1836,42 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
onToggle={() => setRunPolicyAdvancedOpen(!runPolicyAdvancedOpen)}
>
<div className="space-y-3">
{renderAdapterFields("runPolicy")}
{isLocal && (<>
{/* Edit-only: timeout + grace period */}
{!isCreate && (
<>
{!configSchema?.fields.some((field) => field.key === "timeoutSec") && (
<Field label="Timeout (sec)" hint={help.timeoutSec}>
<DraftNumberInput
value={eff(
"adapterConfig",
"timeoutSec",
Number(config.timeoutSec ?? 0),
)}
onCommit={(v) => mark("adapterConfig", "timeoutSec", v)}
immediate
className={inputClass}
/>
</Field>
)}
{!configSchema?.fields.some((field) => field.key === "graceSec") && (
<Field label="Interrupt grace period (sec)" hint={help.graceSec}>
<DraftNumberInput
value={eff(
"adapterConfig",
"graceSec",
Number(config.graceSec ?? 15),
)}
onCommit={(v) => mark("adapterConfig", "graceSec", v)}
immediate
className={inputClass}
/>
</Field>
)}
</>
)}
</>)}
<ToggleField
label="Wake on demand"
hint={help.wakeOnDemand}

View File

@ -1,3 +1,4 @@
import { normalizeLegacyRunnerProvider } from "@paperclipai/adapter-utils";
import { memo, useState, useEffect, useRef, useCallback, useMemo, type ChangeEvent, type CSSProperties, type DragEvent, type RefObject } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import type { AgentEnvConfig, EnvBinding, IssueWorkMode } from "@paperclipai/shared";
@ -603,12 +604,13 @@ export function NewIssueDialog() {
});
}, [agents, companyMembers?.users, orderedProjects]);
const catalogProvider = assigneeAdapterType === "paperclip_runner" ? String(normalizeLegacyRunnerProvider(selectedAssigneeAgent?.adapterConfig ?? {}).provider ?? "codex") : undefined;
const { data: assigneeAdapterModels } = useQuery({
queryKey:
effectiveCompanyId && assigneeAdapterType
? queryKeys.agents.adapterModels(effectiveCompanyId, assigneeAdapterType)
? queryKeys.agents.adapterModels(effectiveCompanyId, assigneeAdapterType, null, catalogProvider)
: ["agents", "none", "adapter-models", assigneeAdapterType ?? "none"],
queryFn: () => agentsApi.adapterModels(effectiveCompanyId!, assigneeAdapterType!),
queryFn: () => agentsApi.adapterModels(effectiveCompanyId!, assigneeAdapterType!, { provider: catalogProvider }),
enabled: Boolean(effectiveCompanyId) && newIssueOpen && supportsAssigneeOverrides,
});

View File

@ -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"

View File

@ -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 (
<div>
<div className="flex items-center gap-1.5 mb-1">
@ -200,6 +200,8 @@ export function CollapsibleSection({
return (
<div className={cn(bordered && "border-t border-border")}>
<button
type="button"
aria-expanded={open}
className="flex items-center gap-2 w-full px-4 py-2 text-xs font-medium text-muted-foreground hover:bg-accent/30 transition-colors"
onClick={onToggle}
>

View File

@ -1,3 +1,4 @@
import { normalizeLegacyRunnerProvider } from "@paperclipai/adapter-utils";
import { useCallback, useEffect, useMemo, useRef, useState, type ComponentType } from "react";
import { createPortal } from "react-dom";
import { PROPERTIES_PANE_HEADER_SLOT_ID } from "../PropertiesPanel";
@ -740,12 +741,13 @@ export function IssueProperties({
);
const assigneeOverrideChrome = assigneeAdapterType === "claude_local"
&& assigneeOverrideAdapterConfig.chrome === true;
const catalogProvider = assigneeAdapterType === "paperclip_runner" ? String(normalizeLegacyRunnerProvider(assigneePrimaryAdapterConfig).provider ?? "codex") : undefined;
const { data: assigneeAdapterModels } = useQuery({
queryKey:
companyId && assigneeAdapterType
? queryKeys.agents.adapterModels(companyId, assigneeAdapterType)
? queryKeys.agents.adapterModels(companyId, assigneeAdapterType, null, catalogProvider)
: ["agents", "none", "adapter-models", assigneeAdapterType ?? "none"],
queryFn: () => agentsApi.adapterModels(companyId!, assigneeAdapterType!),
queryFn: () => agentsApi.adapterModels(companyId!, assigneeAdapterType!, { provider: catalogProvider }),
enabled: Boolean(companyId) && showAssigneeAdapterOptions && supportsAssigneeOverrides,
});
const modelOverrideOptions = useMemo<InlineEntityOption[]>(() => {

View File

@ -398,7 +398,7 @@ describe("markdown work product review row", () => {
expect(container.querySelector('article[data-variant="compact"]')).not.toBeNull();
expect(container.querySelector(`img[src="${imagePath}"]`)).not.toBeNull();
expect(container.querySelector('a[aria-label="Open on GitHub: Artifact grouping PR"]')).not.toBeNull();
expect(container.querySelector('a[aria-label="Open gallery: Artifacts screenshot"]')).not.toBeNull();
expect(container.querySelector('button[aria-label="Open gallery: Artifacts screenshot"]')).not.toBeNull();
});
const typeSelect = container.querySelector('select[aria-label="Filter artifacts by type"]') as HTMLSelectElement;

View File

@ -1,7 +1,11 @@
import type { CSSProperties } from "react";
import { useContext, useState, type CSSProperties } from "react";
import { IssueGalleryContext } from "@/context/IssueGalleryContext";
import { ImageGalleryModal } from "@/components/ImageGalleryModal";
import { isImageContentType, isVideoLikeOutput } from "@/lib/issue-output";
import type { IssueWorkProduct } from "@paperclipai/shared";
import {
ExternalLink,
Maximize2,
File,
FileText,
Film,
@ -123,10 +127,12 @@ export interface RichWorkProductCardProps {
}
export function RichWorkProductCard({ workProduct, href, variant = "card" }: RichWorkProductCardProps) {
const openIssueGallery = useContext(IssueGalleryContext);
const [galleryOpen, setGalleryOpen] = useState(false);
const metadata = workProduct.metadata;
const contentType = stringMeta(metadata, "contentType") ?? "";
const isImage = contentType.startsWith("image/");
const isVideo = contentType.startsWith("video/");
const isImage = isImageContentType(contentType);
const isVideo = isVideoLikeOutput(contentType, stringMeta(metadata, "originalFilename"));
let Icon: LucideIcon = File;
let meta: Array<string | null> = [];
let action = "Open preview";
@ -211,6 +217,13 @@ export function RichWorkProductCard({ workProduct, href, variant = "card" }: Ric
? stringMeta(metadata, "openPath", "contentPath") ?? href
: null;
const mediaPath = workProduct.type === "artifact" && (isImage || isVideo)
? stringMeta(metadata, "contentPath", "openPath") ?? href
: null;
const openGallery = () => {
if (mediaPath && !openIssueGallery?.(mediaPath)) setGalleryOpen(true);
};
return (
<article
className={cn(
@ -237,12 +250,30 @@ export function RichWorkProductCard({ workProduct, href, variant = "card" }: Ric
</div>
<div className={cn("flex shrink-0 items-center", compact ? "gap-1.5" : "gap-2")}>
{chip ? <Chip chip={chip} /> : null}
{href ? (
{mediaPath ? (
<button type="button" onClick={openGallery} aria-label={`${action}: ${workProduct.title}`} className="inline-flex items-center gap-1 text-xs font-medium text-foreground hover:underline">
{compact ? null : <span className="hidden @sm:inline">{action}</span>}<Maximize2 aria-hidden className="h-3 w-3" />
</button>
) : href ? (
<a href={href} aria-label={`${action}: ${workProduct.title}`} className="inline-flex items-center gap-1 text-xs font-medium text-foreground hover:underline" target={href.startsWith("http") ? "_blank" : undefined} rel={href.startsWith("http") ? "noreferrer" : undefined}>
{compact ? null : <span className="hidden @sm:inline">{action}</span>}<ExternalLink aria-hidden className="h-3 w-3" />
</a>
) : null}
</div>
{galleryOpen && mediaPath ? (
<ImageGalleryModal
items={[{
id: workProduct.id,
contentPath: mediaPath,
downloadPath: stringMeta(metadata, "downloadPath") ?? undefined,
contentType,
originalFilename: stringMeta(metadata, "originalFilename") ?? workProduct.title,
}]}
initialIndex={0}
open
onOpenChange={setGalleryOpen}
/>
) : null}
</article>
);
}

View File

@ -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(
<ThemeProvider>
<IssueGalleryContext.Provider value={openGallery}>
<TaskChatBubble item={{ id: "m1", kind: "message", author: "agent", text: `![Proof](${contentPath})` }} />
</IssueGalleryContext.Provider>
</ThemeProvider>,
));
const image = container.querySelector<HTMLImageElement>(`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) ");

View File

@ -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<string | null>(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}
</MarkdownBody>
@ -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)}
>
<img
src={ref.openPath ?? ref.url}
@ -273,7 +277,7 @@ export function TaskChatBubble({
type="button"
className="aspect-video min-w-0 rounded-md bg-muted text-sm font-semibold text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label={`Open ${imageRefs.length - 3} more screenshots`}
onClick={() => setLightboxSrc(imageRefs[3].url)}
onClick={() => openImage(imageRefs[3].url)}
>
+{imageRefs.length - 3}
</button>

View File

@ -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> = {}): 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(
<IssueGalleryContext.Provider value={openGallery}>
<RichWorkProductCard
workProduct={workProduct({ type: "artifact", metadata: { contentType, contentPath } })}
href={contentPath}
variant="compact"
/>
</IssueGalleryContext.Provider>,
));
const button = container.querySelector<HTMLButtonElement>('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(
<RichWorkProductCard
workProduct={workProduct({ type: "artifact", title: "Screenshot", metadata: { contentType: "image/png", contentPath, originalFilename: "proof.png", downloadPath: `${contentPath}?download=1` } })}
href={contentPath}
/>,
));
await act(async () => container.querySelector<HTMLButtonElement>('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<HTMLButtonElement>('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();

View File

@ -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);

View File

@ -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,

View File

@ -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",
});

View File

@ -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}

View File

@ -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<void>;
stopRunLabel?: string;
@ -398,6 +400,9 @@ vi.mock("../components/TaskChatThread", () => ({
<div data-testid="task-chat-thread">
{props.threadHeader}
Task chat thread
{props.workProducts?.map((workProduct) => (
<RichWorkProductCard key={workProduct.id} workProduct={workProduct} href={workProduct.url} />
))}
{props.onStopRun ? (
<button
type="button"
@ -1392,6 +1397,40 @@ describe("IssueDetail", () => {
vi.restoreAllMocks();
});
it("opens artifact cards in the shared gallery at the selected image without duplicating attachments", async () => {
mockIssuesApi.get.mockResolvedValue(createIssue());
mockIssuesApi.listAttachments.mockResolvedValue([
createAttachment({ id: "chat-image", contentType: "image/png", originalFilename: "chat.png" }),
createAttachment({ id: "00000000-0000-4000-8000-000000000001", contentType: "image/png", originalFilename: "artifact.png" }),
]);
mockIssuesApi.listWorkProducts.mockResolvedValue([
createArtifactWorkProduct({ id: "artifact-1", attachmentId: "00000000-0000-4000-8000-000000000001", contentType: "image/png", originalFilename: "artifact.png" }),
createArtifactWorkProduct({ id: "artifact-2", attachmentId: "00000000-0000-4000-8000-000000000002", contentType: "image/png", originalFilename: "output.png" }),
]);
const windowOpen = vi.spyOn(window, "open").mockImplementation(() => null);
await act(async () => {
root.render(<QueryClientProvider client={queryClient}><IssueDetail /></QueryClientProvider>);
});
await waitForAssertion(() => {
expect(container.querySelector('button[aria-label="Open gallery: output.png"]')).not.toBeNull();
});
for (const [filename, index] of [["artifact.png", 1], ["output.png", 2]] as const) {
await act(async () => {
(container.querySelector(`button[aria-label="Open gallery: ${filename}"]`) as HTMLButtonElement).click();
});
expect(mockImageGalleryRender.mock.calls.at(-1)?.[0]).toMatchObject({
open: true,
initialIndex: index,
items: [
{ id: "chat-image" },
{ id: "00000000-0000-4000-8000-000000000001" },
{ id: "work-product-artifact-2", downloadPath: "/api/attachments/00000000-0000-4000-8000-000000000002/content?download=1" },
],
});
}
expect(windowOpen).not.toHaveBeenCalled();
});
it("loads from the pending state into issue detail without changing hook order", async () => {
const issueRequest = createDeferred<Issue>();
mockIssuesApi.get.mockReturnValueOnce(issueRequest.promise);
@ -1680,7 +1719,7 @@ describe("IssueDetail", () => {
await waitForAssertion(() => {
expect(mockSetPanelVisible).toHaveBeenCalledWith(true);
const panel = mockOpenPanel.mock.calls.at(-1)?.[0] as
const panel = mockOpenPanel.mock.calls.at(-1)?.[0]?.props.children as
{ props?: Record<string, unknown> } | undefined;
expect(panel?.props?.documentDeepLink).toMatchObject({
tab: "document",
@ -1713,7 +1752,7 @@ describe("IssueDetail", () => {
container.querySelector('[data-testid="issue-chat-thread"]'),
).not.toBeNull();
expect(mockSetPanelVisible).not.toHaveBeenCalled();
const panel = mockOpenPanel.mock.calls.at(-1)?.[0] as
const panel = mockOpenPanel.mock.calls.at(-1)?.[0]?.props.children as
{ props?: Record<string, unknown> } | undefined;
expect(panel?.props?.documentDeepLink).toBeNull();
});
@ -1731,7 +1770,7 @@ describe("IssueDetail", () => {
);
});
await waitForAssertion(() => {
const panel = mockOpenPanel.mock.calls.at(-1)?.[0] as
const panel = mockOpenPanel.mock.calls.at(-1)?.[0]?.props.children as
{ props?: Record<string, unknown> } | undefined;
expect(panel?.props?.documentDeepLink).toMatchObject({
documentKey: "qa-evidence",
@ -1748,7 +1787,7 @@ describe("IssueDetail", () => {
});
await waitForAssertion(() => {
const panel = mockOpenPanel.mock.calls.at(-1)?.[0] as
const panel = mockOpenPanel.mock.calls.at(-1)?.[0]?.props.children as
{ props?: Record<string, unknown> } | undefined;
expect(panel?.props?.documentDeepLink).toBeNull();
});
@ -1767,7 +1806,7 @@ describe("IssueDetail", () => {
);
});
await waitForAssertion(() => {
const panel = mockOpenPanel.mock.calls.at(-1)?.[0] as
const panel = mockOpenPanel.mock.calls.at(-1)?.[0]?.props.children as
{ props?: Record<string, unknown> } | undefined;
expect(panel?.props?.documentDeepLink).toMatchObject({
tab: "plans",
@ -1786,7 +1825,7 @@ describe("IssueDetail", () => {
});
expect(mockSetPanelVisible).not.toHaveBeenCalled();
await waitForAssertion(() => {
const panel = mockOpenPanel.mock.calls.at(-1)?.[0] as
const panel = mockOpenPanel.mock.calls.at(-1)?.[0]?.props.children as
{ props?: Record<string, unknown> } | undefined;
expect(panel?.props?.documentDeepLink).toBeNull();
});
@ -1803,7 +1842,7 @@ describe("IssueDetail", () => {
);
});
await waitForAssertion(() => {
const panel = mockOpenPanel.mock.calls.at(-1)?.[0] as
const panel = mockOpenPanel.mock.calls.at(-1)?.[0]?.props.children as
{ props?: Record<string, unknown> } | undefined;
expect(
(panel?.props?.documentDeepLink as { requestId?: number } | null)
@ -1818,7 +1857,7 @@ describe("IssueDetail", () => {
await act(async () => link.click());
await waitForAssertion(() => {
const panel = mockOpenPanel.mock.calls.at(-1)?.[0] as
const panel = mockOpenPanel.mock.calls.at(-1)?.[0]?.props.children as
{ props?: Record<string, unknown> } | undefined;
expect(
(panel?.props?.documentDeepLink as { requestId?: number } | null)
@ -1988,7 +2027,7 @@ describe("IssueDetail", () => {
await flushReact();
await flushReact();
const panel = mockOpenPanel.mock.calls.at(-1)?.[0] as
const panel = mockOpenPanel.mock.calls.at(-1)?.[0]?.props.children as
{ props?: Record<string, unknown> } | undefined;
expect(panel?.props?.childIssues).toEqual([
expect.objectContaining({ id: "child-1", identifier: "PAP-2" }),
@ -2500,7 +2539,7 @@ describe("IssueDetail", () => {
await flushReact();
await flushReact();
const panel = mockOpenPanel.mock.calls.at(-1)?.[0] as
const panel = mockOpenPanel.mock.calls.at(-1)?.[0]?.props.children as
{ props?: Record<string, unknown> } | undefined;
expect(panel?.props?.issueLinkState).toEqual(
expect.objectContaining({

View File

@ -205,6 +205,7 @@ import { SidePanelToggleButton } from "../components/side-panel";
import { PauseAffectsSummaryView } from "../components/interrupt-handoff/InterruptHandoffViews";
import { computePauseAffectsSummary } from "../lib/interrupt-handoff";
import { useIssueExternalObjects } from "../hooks/useIssueExternalObjects";
import { IssueGalleryContext } from "../context/IssueGalleryContext";
import { useIssuePlanDocument } from "../hooks/useIssuePlanDocument";
import { IssueRunLedger } from "../components/IssueRunLedger";
import { IssueWorkspaceCard } from "../components/IssueWorkspaceCard";
@ -5337,6 +5338,94 @@ export function IssueDetail() {
markIssueRead.mutate(issue.id);
}, [issue?.id]); // eslint-disable-line react-hooks/exhaustive-deps
const mediaGalleryItems = useMemo<GalleryMediaItem[]>(() => {
const items: GalleryMediaItem[] = [];
const seen = new Set<string>();
const mark = (
attachmentId: string | null | undefined,
contentPath: string,
) => {
if (attachmentId) seen.add(`attachment:${attachmentId}`);
seen.add(`content:${contentPath}`);
};
const hasSeen = (
attachmentId: string | null | undefined,
contentPath: string,
) =>
Boolean(attachmentId && seen.has(`attachment:${attachmentId}`)) ||
seen.has(`content:${contentPath}`);
for (const attachment of attachments ?? []) {
if (!isImageAttachment(attachment) && !isVideoAttachment(attachment))
continue;
items.push(attachment);
mark(attachment.id, attachment.contentPath);
}
for (const item of getIssueOutputs(workProducts).items) {
const meta = item.metadata;
if (!meta) continue;
const isMedia =
isImageContentType(meta.contentType) ||
isVideoLikeOutput(meta.contentType, meta.originalFilename);
if (!isMedia || hasSeen(meta.attachmentId, meta.contentPath)) continue;
items.push({
id: `work-product-${item.id}`,
contentPath: meta.contentPath,
openPath: meta.openPath,
downloadPath: meta.downloadPath,
contentType: meta.contentType,
originalFilename: meta.originalFilename ?? item.title,
});
mark(meta.attachmentId, meta.contentPath);
}
return items;
}, [attachments, workProducts]);
const openIssueGallery = useCallback(
(src: string) => {
// Match content and preview URLs in either relative or absolute form.
const absoluteUrl = (path: string) => {
try {
return new URL(path, window.location.origin).href;
} catch {
return path;
}
};
const requestedUrl = absoluteUrl(src);
let idx = mediaGalleryItems.findIndex(
(a) => absoluteUrl(a.contentPath) === requestedUrl ||
(a.openPath && absoluteUrl(a.openPath) === requestedUrl),
);
if (idx < 0) {
// Try matching by asset ID extracted from /api/assets/{assetId}/content URLs
const assetMatch = src.match(/\/api\/assets\/([^/]+)\/content/);
if (assetMatch) {
idx = mediaGalleryItems.findIndex(
(a) => "assetId" in a && a.assetId === assetMatch[1],
);
}
}
if (idx >= 0) {
setGalleryIndex(idx);
setGalleryOpen(true);
return true;
}
return false;
},
[mediaGalleryItems],
);
const handleChatImageClick = useCallback(
(src: string) => {
if (!openIssueGallery(src)) window.open(src, "_blank");
},
[openIssueGallery],
);
useEffect(() => {
if (!panelIssue || suppressPanelUntilPlan) {
closePanel();
@ -5370,22 +5459,29 @@ export function IssueDetail() {
};
if (taskChatShellEnabled) {
openPanel(
<TaskSidePanel
key={panelIssue.id}
{...sharedProps}
accountScope={currentUserId ?? "anonymous"}
fileTabsEnabled={fileViewerEnabled}
streamlinedTabs={streamlinedTaskDetailEnabled}
showSubtasksTab={streamlinedTaskDetailEnabled}
/>,
<IssueGalleryContext.Provider value={openIssueGallery}>
<TaskSidePanel
key={panelIssue.id}
{...sharedProps}
accountScope={currentUserId ?? "anonymous"}
fileTabsEnabled={fileViewerEnabled}
streamlinedTabs={streamlinedTaskDetailEnabled}
showSubtasksTab={streamlinedTaskDetailEnabled}
/>
</IssueGalleryContext.Provider>,
{ contentMode: "full-bleed" },
);
} else {
openPanel(<IssueProperties {...sharedProps} />);
openPanel(
<IssueGalleryContext.Provider value={openIssueGallery}>
<IssueProperties {...sharedProps} />
</IssueGalleryContext.Provider>,
);
}
return () => closePanel();
}, [
closePanel,
openIssueGallery,
handleIssuePropertiesUpdate,
issuePanelKey,
openNewSubIssue,
@ -5751,77 +5847,6 @@ export function IssueDetail() {
),
[attachments, promotedOutputAttachmentIds],
);
const mediaGalleryItems = useMemo<GalleryMediaItem[]>(() => {
const items: GalleryMediaItem[] = [];
const seen = new Set<string>();
const mark = (
attachmentId: string | null | undefined,
contentPath: string,
) => {
if (attachmentId) seen.add(`attachment:${attachmentId}`);
seen.add(`content:${contentPath}`);
};
const hasSeen = (
attachmentId: string | null | undefined,
contentPath: string,
) =>
Boolean(attachmentId && seen.has(`attachment:${attachmentId}`)) ||
seen.has(`content:${contentPath}`);
for (const attachment of attachments ?? []) {
if (!isImageAttachment(attachment) && !isVideoAttachment(attachment))
continue;
items.push(attachment);
mark(attachment.id, attachment.contentPath);
}
for (const item of getIssueOutputs(workProducts).items) {
const meta = item.metadata;
if (!meta) continue;
const isMedia =
isImageContentType(meta.contentType) ||
isVideoLikeOutput(meta.contentType, meta.originalFilename);
if (!isMedia || hasSeen(meta.attachmentId, meta.contentPath)) continue;
items.push({
id: `work-product-${item.id}`,
contentPath: meta.contentPath,
openPath: meta.openPath,
downloadPath: meta.downloadPath,
contentType: meta.contentType,
originalFilename: meta.originalFilename ?? item.title,
});
mark(meta.attachmentId, meta.contentPath);
}
return items;
}, [attachments, workProducts]);
const handleChatImageClick = useCallback(
(src: string) => {
// Try exact contentPath match first
let idx = mediaGalleryItems.findIndex((a) => a.contentPath === src);
if (idx < 0) {
// Try matching by asset ID extracted from /api/assets/{assetId}/content URLs
const assetMatch = src.match(/\/api\/assets\/([^/]+)\/content/);
if (assetMatch) {
idx = mediaGalleryItems.findIndex(
(a) => "assetId" in a && a.assetId === assetMatch[1],
);
}
}
if (idx >= 0) {
setGalleryIndex(idx);
setGalleryOpen(true);
} else {
// Image not in attachment list — open in new tab
window.open(src, "_blank");
}
},
[mediaGalleryItems],
);
const copyIssueToClipboard = async () => {
if (!issue) return;
const decodeEntities = (text: string) => {
@ -7244,6 +7269,7 @@ export function IssueDetail() {
return (
<FileViewerProvider issueId={issue.id} enabled={fileViewerEnabled}>
<IssueGalleryContext.Provider value={openIssueGallery}>
<div
data-task-chat-shell={taskChatShellEnabled ? "" : undefined}
className={
@ -8262,6 +8288,7 @@ export function IssueDetail() {
) : null}
<ScrollToBottom />
</div>
</IssueGalleryContext.Provider>
</FileViewerProvider>
);
}