feat: default new Apple Silicon Claude agents to Paperclip Runner (#15993)

## Thinking Path

> - Paperclip lets users choose an agent harness and account.
> - The server resolves the runner for new agents.
> - Claude still needs the same ordinary native-default journey as
Codex.
> - Existing agents must retain their runner, model and account.
> - This PR defaults new Apple Silicon Claude agents to Paperclip Runner
with Opus 5.5.
> - Users can connect and work without a runner override.

## Linked Issues or Issue Description

Refs #15683. This PR depends on #15994 and precedes #15997. Related
scope: #15422, #13978 and #15224.

## What Changed

- Resolve new Apple Silicon Claude agents to `paperclip_runner`,
`provider: acpx`, `acpxAgent: claude`. Keep explicit Legacy, models,
provider routing and saved execution.
- Use `claude-opus-5-5` for ordinary Claude defaults and retain explicit
overrides.
- Reuse existing API-key/subscription connections, credential storage
and checks. Generalize the shared Advanced runner control with Claude
branding.
- Carry configured effort into the shared v9 contract. Preserve
compatible permissions, instructions, skills, timeouts and session
behavior. Require Legacy for unsupported settings.
- Qualify a new immutable managed Claude target at explicit Connect
through the existing permission-gated probe. Use fresh scoped discovery
for test and hire. Discard stale results and require an explicit Connect
after settings change.
- Retain merged Codex onboarding recovery, auth preparation,
import/effort corrections and Grok Bot gates.
- Check the Claude first-task wizard's saved default directly. Update
configuration/runtime documentation and exact Opus 5.5 accounting.

- Align hire fixtures with the merged host-environment restriction.
Native agent-authenticated hires cannot inject host env settings; both
creation endpoints verify that no extra agent is saved.

## Verification

- Updated onto master `9179d8dcc3e908c217fde4f63c9d08ccf8132eeb` through
#15994. Native input v9 carries effort while preserving v7 home modes
and v8 Muse. Codex defaults remain unchanged.
- Focused local verification passed 285 server tests, 323 UI tests, 124
setup/registry tests, and two v9 permission-pin transport cases. Direct
server/UI TypeScript checks passed. An initial 30-second hook timeout
was resolved by serial verification with a longer hook budget: all 122
credential/permission cases and 232 snapshot/environment cases pass.
Direct server TypeScript checking also passes.
- Ordinary web creation with the experiment disabled saved native
Claude, Opus 5.5 and High effort without runner/model overrides. The
actual product task and follow-up both succeeded on integrated source
`b2c9fe43f9cd3bc5dd9d22552da0148845ba71a4` with the cloud-built daemon
from `04e0202ca81387b17cbe7fd094a8338648356fe9`. The final master
reconciliation also changes shared runtime source. Historical proof
retains its source identity; a final-source cloud qualification is
running before refreshing the preview daemon.
- Both actual browser downloads were checked: v1 has three sections/six
checkboxes (451 bytes); v2 preserves the original body and adds two
Rollback checks (612 bytes). The original attachment stayed
byte-identical. The follow-up records session resume and the same
provider session. Total estimated API usage: $0.518432800, with no
pending/unpriced usage in the successful fixture.
- Fixed the live-discovered setup incompatibility: native API-key
validation no longer depends on the global Legacy Claude CLI. The
existing provider endpoint verifies the key without a paid prompt;
subscription/custom routing probes remain intact.
- Fixed security review's native-hire credential redirection:
agent-supplied URLs, proxy/launch settings and other host environment
controls are rejected before inheritance. Native credential references
remain supported and partial edits use the saved harness. Added
create/hire/update regression coverage.
- Current-head cloud CI is green. All three stack PRs have fresh
Greptile 5/5 reviews and zero unresolved review threads. Required
typecheck, test and build gates ran in cloud CI; Docker work stayed
remote. Earlier failed checks remain in their original runs.
- Docker and native release builds ran in cloud infrastructure. An
inadvertent local Rust prerequisite was stopped; no local Docker build
was run.

- The final master merge preserves task-loading, permission,
skill-retention and OpenRouter changes. Pi uses a fresh combined profile
27, preserving both historical profile-24 declarations; Copilot remains
profile 22. Focused protocol checks passed 155 cases (one skipped), the
updated Pi attestation check passed 18 (one skipped), and 91
evaluation/catalog checks passed.

- Latest-application manual continuation passed in 37.942 seconds on
`695e5c005a85bddc8711d3fcddcaf6b7e1fb64d0`, with High effort, native
Opus 5.5, Anthropic API attribution, the same resumed Claude session, an
independently checked 635-byte file and persisted Done status. It uses
the earlier verified Mac daemon; the preceding-source package
qualification passed at `dca62c7aeb4b45f0dd91a8e18d386a16a2658ef8`. That
package run predates the small TypeScript startup fix and is not
relabeled as qualification for it.
- Upgrade limitation: changed compiled sidecar identity rejected old
durable launch state and recovery hit session-open bounds before
eventually resuming. That attempt was operator-cancelled and retained;
the successful manual continuation does not qualify seamless upgrades.

- Local latency profile, Opus 5.5/High unchanged: three alternating
real-ACP loopback samples per variant reduce median provider-ready time
from 5.081s to 4.034s. The matched real API task startup improves from
5.533s to 4.348s, but total reply time changes from 17.933s to 20.221s
because agent work varies. This establishes a startup saving of about
one second, not a consistent total-reply speedup. A real candidate
follow-up passed in 20.146s and resumed the same saved session. The
loopback fixture makes no external model requests; failed initial probes
remain retained.
- The startup change passes 107 focused runtime/effort/lease tests, 53
profile checks (one skipped), TypeScript checking and the
TypeScript-only runtime build. Native hire fixture checks pass 70 tests;
their first local run retained a socket-hangup failure and stale
OpenCode expectation, both accounted for before the passing rerun.
Broader required checks run in cloud CI.

## Risks

- Missing packages, authentication rejection, unsupported settings and
unavailable models must fail explicitly. Existing agents retain saved
execution; explicit runner changes use existing revision/session
invalidation.
- Subscription follow-up remains unverified; remaining live acceptance
uses API keys per human steering. Historical accounting holds and the
separate $50 ceiling remain intact.
- The subscription acceptance limit remains explicit for review. No
Claude database migration is introduced.

## Model Used

OpenAI Codex, GPT-6 family, with reasoning, repository tools, code
execution and browser testing. The exact serving model ID and
context-window size are not exposed in this session.

## Checklist

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

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip authored and GitHub committed 2026-10-19 13:58:52 -05:00
1 parent 9e8a9f28d5
commit 45b67f1d93
93 files changed
+3718 -587

No files matched your search

+5 -2
View File
@@ -173,16 +173,19 @@ jobs:
- name: Verify repository on hosted Linux
if: inputs.verify_source
# Leave time for artifact retention within the job deadline.
timeout-minutes: 38
timeout-minutes: 80
run: |
set -uo pipefail
pnpm install --frozen-lockfile --ignore-scripts
git fetch --no-tags --depth=1 origin 2a8a99e4a5f69aa803b3f10b982f583e75a87042 16ef1a5744b23bb043a8b286e6288921da1de98f
status=0
for check in 'pnpm -r typecheck' 'pnpm test:run' 'pnpm check:token-gates' 'pnpm test:e2e:runner:typecheck' 'pnpm test:e2e:runner:unit' 'pnpm build' 'if [ -f scripts/verify-grok-npm-install.mjs ]; then node scripts/verify-grok-npm-install.mjs; fi'; do
label="$(echo "$check" | tr -cs 'a-zA-Z0-9' '-')"
echo "Starting $check"
check_status=0
timeout --signal=TERM --kill-after=15s 15m bash -c "$check" > "remote-verification/$label.log" 2>&1 || check_status=$?
limit=15m
if [ "$check" = 'pnpm test:run' ]; then limit=60m; fi
timeout --signal=TERM --kill-after=15s "$limit" bash -c "$check" > "remote-verification/$label.log" 2>&1 || check_status=$?
printf '%s\t%s\n' "$check_status" "$check" >> remote-verification/check-status.tsv
if [ "$check_status" -ne 0 ]; then status=1; fi
echo "Finished $check (exit $check_status)"
+17 -3
View File
@@ -1790,14 +1790,18 @@ agent conversion are outside this playbook.
## Paperclip Runner Adapter Conversion
The experimental Paperclip Runner offers native Codex, OpenCode, and **ACPX
Claude**. Converting an existing Claude, Codex, or OpenCode agent selects its
Paperclip Runner offers native Codex, OpenCode, and **ACPX Claude**. New
Claude Code agents default to ACPX Claude on Apple Silicon; other Claude targets
retain Legacy until platform qualification is complete. The runner override is
in Advanced. Ordinary Claude models default to `claude-opus-5-5`; explicit models
and provider routing remain authoritative. Existing agents keep their saved runner. 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.
experimental setting applies to other native providers. Native Codex and ACPX
Claude do not require it. 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,
@@ -1825,6 +1829,16 @@ retries stopped. Reinstall or rebuild Paperclip with its provider dependencies,
restart the server, and run the agent's runtime test before retrying the task.
Changing the model or signing in again cannot repair a missing runtime package.
ACPX Claude uses the existing API-key and subscription connection flows,
including built-in login and credential checks. Connection setup and a successful
native task are separate checks. Thinking effort (`low`, `medium`, `high`,
`xhigh`, `max`, where supported by the selected model) travels in native input v9.
The runner requires the provider to acknowledge it before prompting and on resume.
An ignored setting or unavailable model fails admission; no runner fallback occurs.
Chrome integration, custom commands/state paths, warm-process controls,
confinement settings and turn limits require an explicit Legacy choice. Native
forms omit Legacy-only defaults. Native permissions keep their saved policy.
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
+3 -2
View File
@@ -411,8 +411,9 @@ Remaining native chat boundaries are in the explicit-only
`agent-chat-qualification` suite: active task reassignment, user Retry after
verified worker process loss, and multi-turn answers grounded in actual task
records. See the [workflow and qualification limits](../tests/runner-e2e/README.md#remaining-native-agent-chat-qualification).
The 26 native `first-task` cells exercise onboarding before native selection
becomes the UI default. Live results and semantic answer reviews must accompany
The 26 native `first-task` cells inspect the production wizard's saved native
default on qualified targets, without an API runner switch. The 26 Legacy cells
select Legacy explicitly in Advanced. Live results and semantic answer reviews must accompany
any qualification claim; catalog presence alone is not a pass.
The explicit-only [native question/resume qualification](../tests/runner-e2e/README.md#task-continuation)
File diff suppressed because it is too large. Load diff
+6
View File
@@ -29,6 +29,12 @@ API: `present` — representative operation IDs:
Agent access: `partial` — Native hire_agent; REST / CLI agent; runtime inheritance for
native hires.
Creation requests can use `runner: auto | paperclip | legacy`; the server resolves
and persists execution configuration. Claude Auto selects the native ACPX profile
on qualified Apple Silicon; saved runner/model/provider overrides survive edits
and approval activation. This selection does not grant connection access or
bypass hiring authorization. Source: [runner resolver](../../server/src/services/agent-runner-selection.ts).
Policy: Agent creation/configuration uses action grants, resource policies and change
consent; some controls require a board actor.
+6 -1
View File
@@ -44,6 +44,11 @@ credentials/run context; inspect its result and applicable persistence/attributi
Assert the relevant denied actor, owner, company or mode from Policy. Review the
remaining operations before qualifying the whole capability.
Runner discovery exposes `supportedRunners` and `defaultRunner`; it describes
qualified selection, not successful authentication or task execution. Native
Claude retains existing account grants and company/responsible-user boundaries.
Source: [adapter discovery](../../server/src/adapters/registry.ts).
Gaps: Raw instance adapter listing requires board, while company model discovery exists
for eligible agents.
@@ -52,7 +57,7 @@ reports supported runners/effective default for the selected company/environment
[selection policy](../../server/src/services/agent-runner-selection.ts) reads
server-owned immutable sandbox facts without provisioning. New Codex defaults
and explicit overrides follow the [shared runner contract](../../packages/shared/src/agent-runner.ts);
other harness defaults remain unchanged. The board inventory does not grant a
Claude follows its qualified rollout described above. The board inventory does not grant a
native agent an equivalent discovery binding. See
[probe restrictions](./execution-environments.md#probe) for acquiring qualification.
An absent forced-Kubernetes environment is an unqualified sandbox target, never
+9 -1
View File
@@ -52,7 +52,7 @@ Manual: Hire an agent with a distinct name and role. Complete any required appro
For a new Codex hire, choose the Codex harness. Automatic selection uses Paperclip
Runner on a qualified selected target; Advanced offers an explicit Legacy runner
override. Other harness defaults are unchanged. Record the persisted runner and
override. Record the persisted runner and
underlying harness, rather than treating the displayed Codex name as execution
proof. See [runtime selection and validation](./runs-adapters.md#adapter-setup)
and [target qualification](./execution-environments.md#environment-settings).
@@ -69,6 +69,14 @@ preserves its execution. For a native Codex import, edit a model or account bind
and verify unchanged runtime settings survive; an explicit clear must stay cleared.
Use the [portability recipe](./companies.md) for package preparation and boundaries.
For Claude Code on Apple Silicon, submit without a runner override and inspect
saved configuration: `paperclip_runner`, `provider: acpx`, `acpxAgent: claude`,
and ordinary model `claude-opus-5-5`. The Advanced runner selector can explicitly
choose Legacy. Keep explicit model/account choices and saved runners on edits.
Verify useful output and a follow-up after a controller restart; setup success
alone does not establish session continuity. Additional target defaults remain
subject to their separate qualification gates.
The current hiring wizard assigns the first hire CEO and subsequent hires
General reporting to the CEO. Change manager/role afterward through supported
configuration. Process/HTTP agents can be created through API/CLI but are excluded
+11 -1
View File
@@ -54,7 +54,7 @@ company's onboarding route, and finish; drafts save automatically. Follow the
created first task, verify the selected agent and company, and inspect its output
or explicit authentication gate. Reload both company and task.
For Codex, choose the harness and leave runner selection automatic to prove the
For Codex or Claude, choose the harness and leave runner selection automatic to prove the
first-agent default. On a qualified target it saves Paperclip Runner; Advanced
also permits Legacy. Do not switch runners after onboarding when claiming proof
of that default. Test the selected runtime and account, then complete a bounded
@@ -68,6 +68,16 @@ Use [runner validation](./runs-adapters.md#adapter-setup) and
[environment qualification](./execution-environments.md#environment-settings)
for the prerequisites; a successful login alone is insufficient.
For a new Claude agent on an explicit immutable sandbox image, use Connect to
qualify the selected environment through the existing permission-gated probe.
Do not preprobe outside onboarding when claiming this first-use path. Fresh
scoped discovery determines the runner for both the account test and hire;
the measured Legacy result remains valid. An instance-admin denial, incomplete
cleanup, missing lease or changed configuration stops setup before the adapter
test or hire. Named snapshots and mutable image tags do not gain qualification
through this flow. [Helper controls](../../ui/src/lib/qualify-claude-setup.test.ts)
and mounted wizard/new-agent tests use mocks; live managed acceptance remains a
separate gate.
For a new Codex agent with Auto on a saved immutable OCI sandbox, Connect first
uses the existing saved environment probe when authoritative qualification is
absent. The board actor needs the existing instance-environment administration
+7 -3
View File
@@ -45,15 +45,19 @@ Preconditions: follow the [baseline](./README.md#before-driving-a-journey). Reco
Automated: [adapter validation routes](../../server/src/__tests__/agent-adapter-validation-routes.test.ts) and [model refresh](../../server/src/__tests__/adapter-model-refresh-routes.test.ts) cover server behavior.
Claude native setup uses the existing Anthropic API-key or subscription connection
flow. Record the selected account separately from successful execution; unavailable
models/authentication and unsupported Legacy-only options must fail visibly.
Manual: Select the intended adapter and account, refresh model choices, and run its environment test. Save, reload, then execute a small task. Repeat with a deliberately unavailable account and verify the failure remains visible.
Choose Codex as the harness; the UI displays its name/icon for both runners.
Advanced identifies the automatic default and offers Paperclip Runner or Legacy.
Only new Codex agents default to the native runner on supported macOS or GNU/Linux
New Codex agents default to the native runner on supported macOS or GNU/Linux
targets; the [shared selection contract](../../packages/shared/src/agent-runner.ts)
and [runtime prerequisites](../../packages/paperclip-runner/README.md) define the
platform/libc boundary. Windows and Alpine/musl remain Legacy; other harness
defaults are unchanged. Disabled adapters, external overrides, and selected-target
platform/libc boundary. Windows and Alpine/musl remain Legacy. Claude follows
its qualified rollout described above. Disabled adapters, external overrides, and selected-target
qualification also affect availability.
On a fresh forced-Kubernetes instance without an environment row, inspect discovery
and create with automatic selection. Both must retain Legacy without provisioning;
@@ -12,7 +12,7 @@ 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",
acpx: "claude-opus-5-5",
opencode: "openrouter/deepseek/deepseek-v4-flash-0731",
} as const;
+1
View File
@@ -724,6 +724,7 @@ export interface CLIAdapterModule {
export interface CreateConfigValues {
runner?: "auto" | "paperclip" | "legacy";
resolvedRunner?: "paperclip" | "legacy";
adapterType: string;
codexPermissionMode?: "never" | "on-request" | "untrusted";
codexLegacySandboxBypass?: boolean;
@@ -27,9 +27,9 @@ describe("Claude model defaults", () => {
expect(claudeLocalReasoningEffortsForModel("custom-model")).toEqual(["low", "medium", "high"]);
});
it.each([undefined, null, "", " "])("uses Opus 5 for an unset model (%j)", (model) => {
expect(DEFAULT_CLAUDE_LOCAL_MODEL).toBe("claude-opus-5");
expect(resolveClaudeModel(model)).toBe("claude-opus-5");
it.each([undefined, null, "", " "])("uses Opus 5.5 for an unset model (%j)", (model) => {
expect(DEFAULT_CLAUDE_LOCAL_MODEL).toBe("claude-opus-5-5");
expect(resolveClaudeModel(model)).toBe("claude-opus-5-5");
});
it("keeps explicit model IDs ahead of environment overrides", () => {
+1 -1
View File
@@ -1,4 +1,4 @@
export const DEFAULT_CLAUDE_LOCAL_MODEL = "claude-opus-5";
export const DEFAULT_CLAUDE_LOCAL_MODEL = "claude-opus-5-5";
/** Resolve Paperclip's default without replacing an explicit provider model. */
export function resolveClaudeModel(
@@ -278,7 +278,7 @@ describe("claude_local ACP lane", () => {
onMeta: async (payload) => { meta.push(payload); },
}));
expect(result.exitCode).toBe(0);
expect(meta[0]?.env?.ANTHROPIC_MODEL).toBe("claude-opus-5");
expect(meta[0]?.env?.ANTHROPIC_MODEL).toBe("claude-opus-5-5");
});
it("keeps ACP model precedence consistent with CLI and provider overrides", () => {
@@ -78,7 +78,7 @@ function buildContext(config: Record<string, unknown> = {}) {
sessionDisplayId: null,
taskKey: null,
},
config,
config: { model: "claude-opus-5", ...config },
context: {},
onLog: vi.fn(async () => {}),
};
@@ -53,6 +53,14 @@ function buildProcResult(resultEvent: Record<string, unknown>, extraStdout = "")
};
}
function mockExecutionResult(result: ReturnType<typeof buildProcResult>) {
runAdapterExecutionTargetProcess.mockImplementation(async (_runId: string, _target: unknown, _command: string, args: string[]) =>
args.includes("--version")
? { ...result, exitCode: 0, stdout: "2.1.284 (Claude Code)" }
: result,
);
}
function buildContext() {
return {
runId: "run-1",
@@ -76,7 +84,7 @@ describe("claude_local failure classification precedence", () => {
});
it("keeps max_turns_exhausted when stdout mentions model_not_found", async () => {
runAdapterExecutionTargetProcess.mockResolvedValue(
mockExecutionResult(
buildProcResult({ is_error: true, subtype: "error_max_turns" }, POISONED_TOOL_OUTPUT),
);
@@ -86,7 +94,7 @@ describe("claude_local failure classification precedence", () => {
});
it("still reports model_not_found when the provider returns it", async () => {
runAdapterExecutionTargetProcess.mockResolvedValue(
mockExecutionResult(
buildProcResult({ is_error: true, subtype: "error", result: "API Error: 404 model not found" }),
);
@@ -189,11 +189,11 @@ describe("claude remote execution", () => {
remoteDir: `${managedRemoteWorkspace}/.paperclip-runtime/claude/mcp-config`,
followSymlinks: true,
}));
expect(runChildProcess).toHaveBeenCalledTimes(1);
const call = runChildProcess.mock.calls[0] as unknown as
expect(runChildProcess.mock.calls.filter(call => !call[2].includes("--version"))).toHaveLength(1);
const call = runChildProcess.mock.calls.find(call => !call[2].includes("--version")) as unknown as
| [string, string, string[], { env: Record<string, string>; remoteExecution?: { remoteCwd: string } | null }]
| undefined;
expect(call?.[2]).toEqual(expect.arrayContaining(["--model", "claude-opus-5"]));
expect(call?.[2]).toEqual(expect.arrayContaining(["--model", "claude-opus-5-5"]));
expect(call?.[2]).toContain("--dangerously-skip-permissions");
expect(call?.[2]).not.toContain("--allowedTools");
expect(call?.[2]).toContain("--append-system-prompt-file");
@@ -255,7 +255,7 @@ describe("claude remote execution", () => {
sessionDisplayId: "12345678-1234-4abc-9def-123456789012",
taskKey: null,
},
config: {
config: { model: "claude-opus-5",
engine: "cli",
command: "claude",
},
@@ -280,8 +280,8 @@ describe("claude remote execution", () => {
onLog: async () => {},
});
expect(runChildProcess).toHaveBeenCalledTimes(1);
const call = runChildProcess.mock.calls[0] as unknown as [string, string, string[]] | undefined;
expect(runChildProcess.mock.calls.filter(call => !call[2].includes("--version"))).toHaveLength(1);
const call = runChildProcess.mock.calls.find(call => !call[2].includes("--version")) as unknown as [string, string, string[]] | undefined;
expect(call?.[2]).not.toContain("--resume");
});
@@ -303,7 +303,7 @@ describe("claude remote execution", () => {
sessionDisplayId: null,
taskKey: null,
},
config: { engine: "cli", command: "claude", cwd: rootDir, paperclipRuntimeSkills: [] },
config: { model: "claude-opus-5", engine: "cli", command: "claude", cwd: rootDir, paperclipRuntimeSkills: [] },
context: {},
onLog,
});
@@ -345,7 +345,7 @@ describe("claude remote execution", () => {
sessionDisplayId: "12345678-1234-4abc-9def-123456789012",
taskKey: null,
},
config: {
config: { model: "claude-opus-5",
engine: "cli",
command: "claude",
},
@@ -370,8 +370,8 @@ describe("claude remote execution", () => {
onLog: async () => {},
});
expect(runChildProcess).toHaveBeenCalledTimes(1);
const call = runChildProcess.mock.calls[0] as unknown as [string, string, string[]] | undefined;
expect(runChildProcess.mock.calls.filter(call => !call[2].includes("--version"))).toHaveLength(1);
const call = runChildProcess.mock.calls.find(call => !call[2].includes("--version")) as unknown as [string, string, string[]] | undefined;
expect(call?.[2]).toContain("--resume");
expect(call?.[2]).toContain("12345678-1234-4abc-9def-123456789012");
});
@@ -413,7 +413,7 @@ describe("claude remote execution", () => {
sessionDisplayId: null,
taskKey: null,
},
config: {
config: { model: "claude-opus-5",
engine: "cli",
command: "claude",
},
@@ -463,7 +463,7 @@ describe("claude remote execution", () => {
sessionDisplayId: null,
taskKey: null,
},
config: {
config: { model: "claude-opus-5",
engine: "cli",
command: "claude",
...config,
@@ -619,7 +619,7 @@ describe("claude remote execution", () => {
sessionDisplayId: "12345678-1234-4abc-9def-123456789012",
taskKey: null,
},
config: {
config: { model: "claude-opus-5",
engine: "cli",
command: "claude",
env: { ANTHROPIC_API_KEY: "fixture-anthropic-key" },
@@ -633,7 +633,7 @@ describe("claude remote execution", () => {
expect(result.exitCode).toBe(0);
expect(runChildProcess).toHaveBeenCalledTimes(2);
const first = (runChildProcess.mock.calls[0] as unknown as [string, string, string[], { stdin?: string }])[3]?.stdin ?? "";
const first = (runChildProcess.mock.calls.find(call => !call[2].includes("--version")) as unknown as [string, string, string[], { stdin?: string }])[3]?.stdin ?? "";
const retry = (runChildProcess.mock.calls[1] as unknown as [string, string, string[], { stdin?: string }])[3]?.stdin ?? "";
expect(first).toContain("## Compact assignment");
expect(first).not.toContain("Explain the next step before starting work.");
@@ -139,7 +139,7 @@ describe("claude sandbox hello probe diagnostics", () => {
const result = await testEnvironment({
companyId: "company-1",
adapterType: "claude_local",
config: { engine: "cli", command: "claude" },
config: { model: "claude-opus-5", engine: "cli", command: "claude" },
executionTarget: sandboxTarget,
environmentName: "Daytona",
});
@@ -170,7 +170,7 @@ describe("claude sandbox hello probe diagnostics", () => {
const result = await testEnvironment({
companyId: "company-1",
adapterType: "claude_local",
config: { engine: "cli", command: "claude" },
config: { model: "claude-opus-5", engine: "cli", command: "claude" },
executionTarget: sandboxTarget,
environmentName: "Daytona",
});
@@ -202,7 +202,7 @@ describe("claude sandbox hello probe diagnostics", () => {
const result = await testEnvironment({
companyId: "company-1",
adapterType: "claude_local",
config: { engine: "cli", command: "claude" },
config: { model: "claude-opus-5", engine: "cli", command: "claude" },
executionTarget: sandboxTarget,
environmentName: "Daytona",
});
@@ -239,7 +239,7 @@ describe("claude sandbox hello probe diagnostics", () => {
const result = await testEnvironment({
companyId: "company-1",
adapterType: "claude_local",
config: { engine: "cli", command: "claude" },
config: { model: "claude-opus-5", engine: "cli", command: "claude" },
executionTarget: sandboxTarget,
environmentName: "Daytona",
});
@@ -269,7 +269,7 @@ describe("claude sandbox hello probe diagnostics", () => {
const result = await testEnvironment({
companyId: "company-1",
adapterType: "claude_local",
config: { engine: "cli", command: "claude" },
config: { model: "claude-opus-5", engine: "cli", command: "claude" },
executionTarget: sandboxTarget,
environmentName: "Daytona",
});
@@ -296,7 +296,7 @@ describe("claude sandbox hello probe diagnostics", () => {
const result = await testEnvironment({
companyId: "company-1",
adapterType: "claude_local",
config: { engine: "cli", command: "claude" },
config: { model: "claude-opus-5", engine: "cli", command: "claude" },
executionTarget: sandboxTarget,
environmentName: "Daytona",
});
@@ -323,7 +323,7 @@ describe("claude sandbox hello probe diagnostics", () => {
const result = await testEnvironment({
companyId: "company-1",
adapterType: "claude_local",
config: { engine: "cli", command: "claude" },
config: { model: "claude-opus-5", engine: "cli", command: "claude" },
executionTarget: sandboxTarget,
environmentName: "Daytona",
});
@@ -361,7 +361,7 @@ describe("claude sandbox hello probe diagnostics", () => {
const result = await testEnvironment({
companyId: "company-1",
adapterType: "claude_local",
config: { engine: "cli", command: "claude" },
config: { model: "claude-opus-5", engine: "cli", command: "claude" },
executionTarget: sandboxTarget,
environmentName: "Daytona",
});
@@ -401,7 +401,7 @@ describe("claude sandbox hello probe diagnostics", () => {
testEnvironment({
companyId: "company-1",
adapterType: "claude_local",
config: { engine: "cli", command: "claude" },
config: { model: "claude-opus-5", engine: "cli", command: "claude" },
executionTarget: sandboxTarget,
environmentName: "Daytona",
}),
@@ -428,7 +428,7 @@ describe("claude sandbox hello probe diagnostics", () => {
const result = await testEnvironment({
companyId: "company-1",
adapterType: "claude_local",
config: { engine: "cli", command: "claude" },
config: { model: "claude-opus-5", engine: "cli", command: "claude" },
executionTarget: sandboxTarget,
environmentName: "Daytona",
});
@@ -451,7 +451,7 @@ describe("claude sandbox hello probe diagnostics", () => {
const result = await testEnvironment({
companyId: "company-1",
adapterType: "claude_local",
config: { engine: "cli", command: "claude" },
config: { model: "claude-opus-5", engine: "cli", command: "claude" },
executionTarget: sandboxTarget,
environmentName: "Daytona",
});
@@ -471,7 +471,7 @@ describe("claude sandbox hello probe diagnostics", () => {
const result = await testEnvironment({
companyId: "company-1",
adapterType: "claude_local",
config: { engine: "cli", command: "claude" },
config: { model: "claude-opus-5", engine: "cli", command: "claude" },
executionTarget: sandboxTarget,
environmentName: "Daytona",
});
@@ -494,7 +494,7 @@ describe("claude auth mode hints", () => {
const result = await testEnvironment({
companyId: "company-1",
adapterType: "claude_local",
config: {
config: { model: "claude-opus-5",
engine: "cli",
command: "claude",
env: { CLAUDE_CODE_OAUTH_TOKEN: "oauth-test-token" },
@@ -515,7 +515,7 @@ describe("claude auth mode hints", () => {
it("reports an intentionally selected managed API account without a subscription warning", async () => {
probeResult.value = { exitCode: 0, stdout: successStdout, stderr: "" };
const result = await testEnvironment({ companyId: "company-1", adapterType: "claude_local",
config: { engine: "cli", command: "claude", managedAiConnection: { provider: "anthropic", method: "api_key" }, env: { ANTHROPIC_API_KEY: "api-test-key" } },
config: { model: "claude-opus-5", engine: "cli", command: "claude", managedAiConnection: { provider: "anthropic", method: "api_key" }, env: { ANTHROPIC_API_KEY: "api-test-key" } },
executionTarget: sandboxTarget, environmentName: "Daytona",
});
expect(result.checks.find(check => check.code === "claude_anthropic_api_key_overrides_subscription")).toMatchObject({ level: "info", message: "Using the selected Claude API connection." });
@@ -528,7 +528,7 @@ describe("claude auth mode hints", () => {
const result = await testEnvironment({
companyId: "company-1",
adapterType: "claude_local",
config: {
config: { model: "claude-opus-5",
engine: "cli",
command: "claude",
env: {
@@ -606,7 +606,7 @@ describe("claude CLI local hello probe hardening", () => {
await testEnvironment({
companyId: "company-1",
adapterType: "claude_local",
config: {
config: { model: "claude-opus-5",
engine: "cli",
command: "/tmp/evil/claude",
env: {
@@ -683,7 +683,7 @@ describe("claude CLI local hello probe hardening", () => {
const result = await testEnvironment({
companyId: "company-1",
adapterType: "claude_local",
config: { engine: "cli", command: "claude" },
config: { model: "claude-opus-5", engine: "cli", command: "claude" },
executionTarget: null,
environmentName: null,
});
@@ -109,7 +109,7 @@ describe("claude sandbox auth-missing check", () => {
const result = await testEnvironment({
companyId: "company-1",
adapterType: "claude_local",
config: { engine: "cli", command: "claude" },
config: { model: "claude-opus-5", engine: "cli", command: "claude" },
executionTarget: sandboxTarget,
environmentName: "Daytona",
});
@@ -130,7 +130,7 @@ describe("claude sandbox auth-missing check", () => {
const result = await testEnvironment({
companyId: "company-1",
adapterType: "claude_local",
config: { engine: "cli", command: "claude" },
config: { model: "claude-opus-5", engine: "cli", command: "claude" },
executionTarget: sshTarget,
environmentName: "Remote host",
});
@@ -37,6 +37,20 @@ function makeValues(overrides: Partial<CreateConfigValues> = {}): CreateConfigVa
}
describe("buildClaudeLocalConfig", () => {
it.each([undefined, "legacy"] as const)("retains the default turn cap when Auto has resolved to %s", resolvedRunner => {
expect(buildClaudeLocalConfig(makeValues({ runner: "auto", resolvedRunner })).maxTurnsPerRun).toBe(1000);
});
it.each(["auto", "paperclip"] as const)("omits the Legacy default only for native %s", runner => {
const config = buildClaudeLocalConfig(makeValues({ runner, resolvedRunner: "paperclip" }));
expect(config).not.toHaveProperty("maxTurnsPerRun");
expect(buildClaudeLocalConfig(makeValues({ runner, resolvedRunner: "paperclip", maxTurnsPerRun: 3 })).maxTurnsPerRun).toBe(3);
});
it("retains an explicit Legacy cap over a stale native form resolution", () => {
expect(buildClaudeLocalConfig(makeValues({ runner: "legacy", resolvedRunner: "paperclip" })).maxTurnsPerRun).toBe(1000);
});
it.each(["approve-all", "approve-reads", "deny-all"])("carries the selected native %s permission to creation", acpxPermissionMode => {
expect(buildClaudeLocalConfig(makeValues({ resolvedRunner: "paperclip", adapterSchemaValues: { acpxPermissionMode } })).acpxPermissionMode).toBe(acpxPermissionMode);
});
it("omits engine for the auto default so runtime fallback remains available", () => {
const config = buildClaudeLocalConfig(makeValues({ claudeEngine: "auto" }));
@@ -43,7 +43,12 @@ export function buildClaudeLocalConfig(v: CreateConfigValues): Record<string, un
ac.graceSec = 15;
const env = buildAdapterEnvConfig(v.envBindings, v.envVars);
if (Object.keys(env).length > 0) ac.env = env;
ac.maxTurnsPerRun = v.maxTurnsPerRun;
const nativeSelected = v.runner === "paperclip"
|| ((v.runner === undefined || v.runner === "auto") && v.resolvedRunner === "paperclip");
if (!nativeSelected || v.maxTurnsPerRun !== 1000) ac.maxTurnsPerRun = v.maxTurnsPerRun;
if (nativeSelected && v.adapterSchemaValues?.acpxPermissionMode !== undefined) {
ac.acpxPermissionMode = v.adapterSchemaValues.acpxPermissionMode;
}
ac.dangerouslySkipPermissions = v.dangerouslySkipPermissions;
if (v.workspaceStrategyType === "git_worktree") {
ac.workspaceStrategy = {
@@ -118,10 +118,15 @@ describe("buildCodexLocalConfig", () => {
});
describe("buildPaperclipRunnerConfig", () => {
it.each(["", "max"])("preserves or overrides saved native Codex reasoning effort (%s)", thinkingEffort => {
const values = makeValues({ thinkingEffort, adapterSchemaValues: { provider: "codex", modelReasoningEffort: "high" } });
expect(buildPaperclipRunnerConfig(values).modelReasoningEffort).toBe(thinkingEffort || "high");
expect(buildPaperclipRunnerConfig({ ...values, adapterSchemaValues: { provider: "acpx", acpxAgent: "claude", modelReasoningEffort: "high" } })).not.toHaveProperty("modelReasoningEffort");
it.each(["codex", "claude"])("preserves, overrides and explicitly clears saved %s reasoning effort", harness => {
const profile = harness === "codex" ? { provider: "codex" } : { provider: "acpx", acpxAgent: "claude" };
const values = makeValues({ adapterSchemaValues: { ...profile, modelReasoningEffort: "high", reasoningEffort: "low", effort: "low" } });
const unhydrated = { ...values };
Reflect.deleteProperty(unhydrated, "thinkingEffort");
expect(buildPaperclipRunnerConfig(unhydrated).modelReasoningEffort).toBe("high");
expect(buildPaperclipRunnerConfig({ ...values, thinkingEffort: "max" }).modelReasoningEffort).toBe("max");
const cleared = buildPaperclipRunnerConfig({ ...values, thinkingEffort: "" });
for (const key of ["modelReasoningEffort", "reasoningEffort", "effort"]) expect(cleared).not.toHaveProperty(key);
});
it.each([undefined, false, true])("preserves Dot attachment consent in create/import forms: %s", value => {
const config = buildPaperclipRunnerConfig(makeValues({ adapterType: "paperclip_runner", adapterSchemaValues: { provider: "openai_dot", dotAttachmentAccess: value } }));
@@ -225,6 +230,10 @@ describe("buildPaperclipRunnerConfig", () => {
});
});
it.each(["modelReasoningEffort", "reasoningEffort", "effort"])("keeps explicit Auto Claude effort cleared over saved %s", key => {
const config = buildPaperclipRunnerConfig(makeValues({ thinkingEffort: "", adapterSchemaValues: { provider: "acpx", acpxAgent: "claude", [key]: "high" } }));
for (const effortKey of ["modelReasoningEffort", "reasoningEffort", "effort"]) expect(config).not.toHaveProperty(effortKey);
});
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 });
@@ -76,7 +76,9 @@ export function buildCodexLocalConfig(v: CreateConfigValues): Record<string, unk
export function buildPaperclipRunnerConfig(v: CreateConfigValues): Record<string, unknown> {
const config = buildCodexLocalConfig(v);
const schemaValues = normalizeLegacyRunnerProvider({ ...(v.adapterSchemaValues ?? {}) });
const codexReasoningEffort = config.modelReasoningEffort ?? schemaValues.modelReasoningEffort ?? schemaValues.reasoningEffort;
const configuredEffort = typeof v.thinkingEffort === "string"
? v.thinkingEffort.trim()
: schemaValues.modelReasoningEffort || schemaValues.reasoningEffort || schemaValues.effort;
for (const unsupportedKey of [
"engine",
"agentCommand",
@@ -232,13 +234,19 @@ export function buildPaperclipRunnerConfig(v: CreateConfigValues): Record<string
]) {
delete schemaValues[normalizedKey];
}
if (provider === "codex" || (provider === "acpx" && acpxAgent === "claude")) {
delete config.reasoningEffort;
delete config.effort;
delete schemaValues.reasoningEffort;
delete schemaValues.effort;
}
return {
...config,
...schemaValues,
provider,
...(provider === "codex"
? { model: resolvePaperclipRunnerModel("codex", config.model),
...(codexReasoningEffort !== undefined ? { modelReasoningEffort: codexReasoningEffort } : {}) }
...(configuredEffort ? { modelReasoningEffort: configuredEffort } : {}) }
: {}),
codexPermissionMode: "never",
opencodePermissionMode: resolvePaperclipRunnerPermissionMode(
@@ -259,6 +267,7 @@ export function buildPaperclipRunnerConfig(v: CreateConfigValues): Record<string
...(provider === "acpx"
? {
acpxAgent,
...(acpxAgent === "claude" && configuredEffort ? { modelReasoningEffort: configuredEffort } : {}),
...(cursorMode === undefined ? {} : { acpxSessionMode: cursorMode }),
...(piThinkingLevel === undefined ? {} : { piThinkingLevel }),
model: configuredModel || schemaModel || (acpxAgent === "grok" ? "grok-4.7" : resolvePaperclipRunnerModel("acpx", undefined)),
+5 -2
View File
@@ -6,8 +6,10 @@ export interface AgentRunnerAvailability {
defaultRunner: AgentRunner;
}
export function paperclipRunnerProfileForHarness(harness: string): { provider: "codex" } | undefined {
return harness === "codex_local" ? { provider: "codex" } : undefined;
export function paperclipRunnerProfileForHarness(harness: string): { provider: "codex" } | { provider: "acpx"; acpxAgent: "claude" } | undefined {
if (harness === "codex_local") return { provider: "codex" };
if (harness === "claude_local") return { provider: "acpx", acpxAgent: "claude" };
return undefined;
}
export const PAPERCLIP_RUNNER_MINIMUM_GLIBC_VERSION = "2.39";
@@ -24,6 +26,7 @@ function supportsLinuxLibc(libc?: "glibc" | "musl" | "unknown", version?: string
}
export function paperclipRunnerSupportsPlatform(harness: string, platform: string, architecture: string, libc?: "glibc" | "musl" | "unknown", glibcVersion?: string): boolean {
if (harness === "claude_local") return platform === "darwin" && architecture === "arm64";
return !!paperclipRunnerProfileForHarness(harness)
&& ((platform === "linux" && supportsLinuxLibc(libc, glibcVersion) && (architecture === "x64" || architecture === "arm64"))
|| (platform === "darwin" && (architecture === "arm64" || architecture === "x64")));
@@ -712,7 +712,7 @@ describe("agent routes adapter validation", () => {
expect(mockAgentService.create).not.toHaveBeenCalled();
});
it("rechecks selection policy when a saved native adapter switches its underlying harness", async () => {
it("rechecks selection policy when a saved native adapter switches to an experimentally gated harness", async () => {
mockAgentService.getById.mockResolvedValue({ ...(await mockAgentService.getById()), adapterType: "paperclip_runner", adapterConfig: { provider: "codex" } });
mockInstanceSettingsService.getExperimental.mockResolvedValue({ enableNativeRunner: false });
const probe = vi.fn();
@@ -722,7 +722,7 @@ describe("agent routes adapter validation", () => {
const app = await createApp();
const res = await requestApp(app, baseUrl => request(baseUrl)
.post("/api/companies/company-1/adapters/paperclip_runner/test-environment")
.send({ agentId: "11111111-1111-4111-8111-111111111111", adapterConfig: { provider: "acpx", acpxAgent: "claude" } }));
.send({ agentId: "11111111-1111-4111-8111-111111111111", adapterConfig: { provider: "acpx", acpxAgent: "pi" } }));
expect(res.status, JSON.stringify(res.body)).toBe(422);
expect(res.body.details).toMatchObject({ code: "paperclip_runner_rollout_disabled" });
expect(probe).not.toHaveBeenCalled();
@@ -762,9 +762,9 @@ describe("agent routes adapter validation", () => {
expect(mockAgentService.create).not.toHaveBeenCalled();
});
it("keeps non-Codex native provider selection behind its existing flag", async () => {
it("keeps other native provider selection behind its existing flag", async () => {
const app = await createApp();
const response = await requestApp(app, baseUrl => request(baseUrl).post("/api/companies/company-1/agents").send({ name: "Native Claude", adapterType: "paperclip_runner", adapterConfig: { provider: "acpx", acpxAgent: "claude", model: "claude-sonnet-5" } }));
const response = await requestApp(app, baseUrl => request(baseUrl).post("/api/companies/company-1/agents").send({ name: "Native OpenCode", adapterType: "paperclip_runner", adapterConfig: { provider: "opencode", model: "openrouter/deepseek/deepseek-v4-flash-0731" } }));
expect(response.status).toBe(422);
expect(response.body.details).toMatchObject({ code: "paperclip_runner_rollout_disabled" });
});
@@ -134,7 +134,7 @@ describe("agent-created hires use managed AI connections", () => {
const f = await fixture("openai");
const p = await poolFixture(f);
await db.update(agents).set({ runtimeConfig: { aiConnection: p.binding } }).where(eq(agents.id, f.agentId));
const response = await request(f.app).post(`/api/companies/${f.companyId}/agent-hires`).send({ name: "Incompatible teammate", role: "engineer", adapterType: "claude_local" });
const response = await request(f.app).post(`/api/companies/${f.companyId}/agent-hires`).send({ name: "Incompatible teammate", role: "engineer", adapterType: "claude_local", runner: "legacy" });
expect(response.status, JSON.stringify(response.body)).toBe(422);
// Fixed bindings contain identity only; compatibility is decided from
// authoritative connection metadata when selecting each pool member.
@@ -155,7 +155,7 @@ describe("agent-created hires use managed AI connections", () => {
["opencode_local", {}, "PAPERCLIP_OPENCODE_PROVIDERS"],
["opencode_local", { model: "anthropic/claude-sonnet-5" }, "OPENCODE_AUTH_JSON"],
["paperclip_runner", { provider: "opencode", model: "anthropic/claude-sonnet-5" }, "OPENCODE_CONFIG_CONTENT"],
] as const)("%s hires with %j keep explicit %s auth outside the managed OpenRouter model catalog", async (adapterType, config, key) => {
] as const)("%s hires with %j apply the host boundary to explicit %s auth", async (adapterType, config, key) => {
const f = await fixture("openai");
const p = await poolFixture(f);
await db.update(agents).set({ runtimeConfig: { aiConnection: p.binding } }).where(eq(agents.id, f.agentId));
@@ -163,9 +163,17 @@ describe("agent-created hires use managed AI connections", () => {
const value = JSON.stringify(key === "PAPERCLIP_OPENCODE_PROVIDERS" ? { anthropic: provider }
: key === "OPENCODE_CONFIG_CONTENT" ? { provider: { anthropic: provider } }
: { anthropic: { type: "api", key: "fixture-explicit-key" } });
const agent = hired(await request(f.app).post(`/api/companies/${f.companyId}/agent-hires`).send({
const response = await request(f.app).post(`/api/companies/${f.companyId}/agent-hires`).send({
name: "Independent OpenCode teammate", role: "engineer", adapterType, adapterConfig: { ...config, env: { [key]: value } },
}));
});
if (adapterType === "paperclip_runner") {
expect(response.status).toBe(403);
expect(response.body.error).toContain("host-executed local adapter settings");
const savedAgents = await db.select({ id: agents.id }).from(agents).where(eq(agents.companyId, f.companyId));
expect(savedAgents.map(agent => agent.id)).toEqual([f.agentId]);
return;
}
const agent = hired(response);
expect(agent.runtimeConfig.aiConnection).toBeUndefined();
expect(agent.adapterConfig.env[key]).toEqual({ type: "plain", value });
expect(await db.select().from(toolConnectionInstalls).where(eq(toolConnectionInstalls.targetId, agent.id))).toEqual([]);
@@ -385,19 +393,16 @@ describe("agent-created hires use managed AI connections", () => {
["openai", "claude_local", {}, "OPENAI_API_KEY"],
["anthropic", "paperclip_runner", { provider: "codex" }, "ANTHROPIC_API_KEY"],
["openai", "paperclip_runner", { provider: "acpx", acpxAgent: "claude" }, "OPENAI_API_KEY"],
] as const)(`${endpoint}: ignores the %s auth key for a different provider in %s`, async (provider, adapterType, config, key) => {
] as const)(`${endpoint}: rejects host env settings for a %s caller hiring %s`, async (provider, adapterType, config, key) => {
const f = await fixture(provider);
const response = await request(f.app).post(`/api/companies/${f.companyId}/${endpoint}`).send({
name: "Cross-provider config", role: "engineer", adapterType,
adapterConfig: { ...config, env: { [key]: "leftover-parent-setting" } },
});
if (adapterType.endsWith("_local")) {
expect(response.status).toBe(403);
expect(response.body.error).toContain("host-executed local adapter settings");
return;
}
const agent = hired(response);
expect(agent.runtimeConfig.aiConnection).toMatchObject({ provider: provider === "anthropic" ? "openai" : "anthropic", mode: "responsible_user" });
expect(response.status).toBe(403);
expect(response.body.error).toContain("host-executed local adapter settings");
const savedAgents = await db.select({ id: agents.id }).from(agents).where(eq(agents.companyId, f.companyId));
expect(savedAgents.map(agent => agent.id)).toEqual([f.agentId]);
});
}
@@ -265,6 +265,34 @@ describeEmbeddedPostgres("hired agent provider credential inheritance", () => {
expect(childEnvOf(res).XAI_API_KEY).toMatchObject({ type: "secret_ref", secretId: secret.id });
});
it.each(["create", "hire"] as const)("blocks native Claude credential redirection before %s inheritance", async mode => {
const companyId = await seedCompany();
const secret = await createCompanySecret(companyId, "fixture-parent-credential");
const parent = await seedParentAgent(companyId, "paperclip_runner", { ANTHROPIC_API_KEY: secretRef(secret.id) });
await db.update(agents).set({ adapterConfig: { provider: "acpx", acpxAgent: "claude", env: { ANTHROPIC_API_KEY: secretRef(secret.id) } } }).where(eq(agents.id, parent.id));
const app = createApp(db, agentActor(companyId, parent.id));
const res = await request(app).post(`/api/companies/${companyId}/${mode === "hire" ? "agent-hires" : "agents"}`).send({
name: "Redirected child", role: "engineer", adapterType: "paperclip_runner",
adapterConfig: { provider: "acpx", acpxAgent: "claude", env: { ANTHROPIC_BASE_URL: "https://untrusted.example.test" } },
});
expect(res.status, JSON.stringify(res.body)).toBe(403);
expect(res.body.error).toContain("host-executed local adapter settings");
expect(await db.select().from(agents).where(eq(agents.companyId, companyId))).toHaveLength(1);
});
it("still inherits Claude credential references for an ordinary native hire", async () => {
const companyId = await seedCompany();
const secret = await createCompanySecret(companyId, "fixture-parent-credential");
const parent = await seedParentAgent(companyId, "claude_local", { ANTHROPIC_API_KEY: secretRef(secret.id) });
const res = await hire(agentActor(companyId, parent.id), companyId, {
name: "Native child", role: "engineer", adapterType: "paperclip_runner",
adapterConfig: { provider: "acpx", acpxAgent: "claude" },
});
expect(res.status, JSON.stringify(res.body)).toBe(201);
expect(res.body.agent.adapterType).toBe("paperclip_runner");
expect(childEnvOf(res).ANTHROPIC_API_KEY).toMatchObject({ type: "secret_ref", secretId: secret.id });
});
it("inherits a claude_local parent's ANTHROPIC_API_KEY reference", async () => {
const companyId = await seedCompany();
const secret = await createCompanySecret(companyId, "ant-parent-key");
@@ -499,13 +527,13 @@ describeEmbeddedPostgres("hired agent provider credential inheritance", () => {
const parent = await seedParentAgent(companyId, "paperclip_runner", {});
await db.update(agents).set({ adapterConfig: {
provider: "acpx", acpxAgent: "claude", model: "claude-sonnet-5", acpxPermissionMode: "approve-all",
env: { ANTHROPIC_API_KEY: secretRef("parent-secret") }, cwd: "/private/parent",
env: { PRIVATE_PARENT_TOKEN: secretRef("parent-secret") }, cwd: "/private/parent",
invocationLimits: { maxIterations: 3, credentials: "never-copy" },
} }).where(eq(agents.id, parent.id));
const res = await hire(agentActor(companyId, parent.id), companyId, {
name: "Approval Snapshot Child", role: "engineer", adapterType: "paperclip_runner", inheritRuntimeFrom: "caller",
});
expect(res.status).toBe(201);
expect(res.status, JSON.stringify(res.body)).toBe(201);
expect(res.body.agent.status).toBe("pending_approval");
expect(res.body.approval.payload.inheritRuntimeFrom).toBeUndefined();
expect(res.body.approval.payload.adapterConfig).toMatchObject({ provider: "acpx", acpxAgent: "claude", acpxPermissionMode: "approve-all" });
@@ -1027,6 +1027,26 @@ describe("agent permission routes", () => {
expect(mockAgentService.update).toHaveBeenCalledOnce();
});
it.each(["ANTHROPIC_BASE_URL", "HTTP_PROXY", "NODE_OPTIONS"])("blocks agent-supplied %s on native Claude updates", async key => {
mockAgentService.getById.mockResolvedValue({ ...baseAgent, adapterType: "paperclip_runner",
adapterConfig: { provider: "acpx", acpxAgent: "claude" } });
const app = await createApp({ type: "agent", agentId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", companyId, source: "agent_key", runId: "run-1" });
const res = await requestApp(app, baseUrl => request(baseUrl).patch(`/api/agents/${agentId}`)
.send({ adapterConfig: { env: { [key]: "untrusted-setting" } } }));
expect(res.status).toBe(403);
expect(mockAgentService.update).not.toHaveBeenCalled();
});
it("allows native Claude credential references in partial updates using the saved harness", async () => {
mockAgentService.getById.mockResolvedValue({ ...baseAgent, adapterType: "paperclip_runner",
adapterConfig: { provider: "acpx", acpxAgent: "claude" } });
const app = await createApp({ type: "agent", agentId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", companyId, source: "agent_key", runId: "run-1" });
const res = await requestApp(app, baseUrl => request(baseUrl).patch(`/api/agents/${agentId}`)
.send({ adapterConfig: { env: { ANTHROPIC_API_KEY: { type: "secret_ref", secretId: "33333333-3333-4333-8333-333333333333" } } } }));
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(mockAgentService.update).toHaveBeenCalledOnce();
});
it("blocks an agent from activating inherited host settings by switching onto a local adapter", async () => {
mockAgentService.getById.mockResolvedValue({
...baseAgent,
@@ -1853,8 +1873,8 @@ describe("agent permission routes", () => {
}),
{ runnerResolved: true, aiConnectionInstall: undefined, createdByUserId: "board-user", responsibleUserId: "board-user", claudeLogin: { storedSessionId: null, ownerUserId: "board-user", applyExistingWithoutClaim: false } },
);
if (adapterCase.adapterType === "codex_local") {
expect(mockAgentService.create.mock.calls[0][1].adapterConfig).toMatchObject({ provider: "codex" });
if (["codex_local", "claude_local"].includes(adapterCase.adapterType)) {
if (adapterCase.adapterType === "codex_local") expect(mockAgentService.create.mock.calls[0][1].adapterConfig).toMatchObject({ provider: "codex" });
const { AGENT_RUNNER_SSH_PLATFORM_COMMAND } = await import("../services/agent-runner-selection.js");
expect(mockRunSshCommand).toHaveBeenCalledWith(expect.objectContaining({ host: "qa.example.invalid" }), AGENT_RUNNER_SSH_PLATFORM_COMMAND, { timeoutMs: 10_000 });
} else {
@@ -909,6 +909,7 @@ describe("agent skill routes", () => {
name: "QA Agent",
role: "engineer",
adapterType: "claude_local",
runner: "legacy",
desiredSkills: ["paperclip"],
adapterConfig: {},
}));
@@ -942,6 +943,7 @@ describe("agent skill routes", () => {
name: "QA Agent",
role: "engineer",
adapterType: "claude_local",
runner: "legacy",
desiredSkills: [{
key: "paperclipai/paperclip/paperclip",
versionId: "22222222-2222-4222-8222-222222222222",
@@ -961,6 +963,7 @@ describe("agent skill routes", () => {
name: "Security Engineer",
role: "security",
adapterType: "claude_local",
runner: "legacy",
adapterConfig: {},
}));
@@ -992,6 +995,7 @@ describe("agent skill routes", () => {
name: "QA Agent",
role: "engineer",
adapterType: "claude_local",
runner: "legacy",
adapterConfig: {},
instructionsBundle: {
files: {
@@ -1028,6 +1032,7 @@ describe("agent skill routes", () => {
name: "QA Agent",
role: "engineer",
adapterType: "claude_local",
runner: "legacy",
adapterConfig: {
instructionsFilePath: "/tmp/existing/AGENTS.md",
promptTemplate: "You are QA.",
@@ -1054,6 +1059,7 @@ describe("agent skill routes", () => {
name: "CEO",
role: "ceo",
adapterType,
...(adapterType === "claude_local" ? { runner: "legacy" } : {}),
adapterConfig: adapterType === "paperclip_runner" ? { provider: "codex" } : {},
}));
@@ -1084,6 +1090,7 @@ describe("agent skill routes", () => {
name: "Research Lead",
role: "ceo",
adapterType,
...(adapterType === "claude_local" ? { runner: "legacy" } : {}),
adapterConfig: adapterType === "paperclip_runner" ? { provider: "codex" } : {},
instructionsBundle: { entryFile: "AGENTS.md", files: customFiles },
}));
@@ -1110,6 +1117,7 @@ describe("agent skill routes", () => {
name: "Engineer",
role: "engineer",
adapterType: "claude_local",
runner: "legacy",
adapterConfig: {},
}));
@@ -1137,6 +1145,7 @@ describe("agent skill routes", () => {
name: "Ada",
role: "general",
adapterType: "claude_local",
runner: "legacy",
adapterConfig: {},
onboardingFirstAgent: true,
}));
@@ -1168,7 +1177,12 @@ describe("agent skill routes", () => {
{ adapterType: "codex_local", runner: "legacy", platform: "darwin", expectedType: "codex_local" },
{ adapterType: "codex_local", runner: "auto", platform: "win32", expectedType: "codex_local" },
];
it.each([...codexRunnerCases, { adapterType: "claude_local", runner: "auto", platform: "linux", expectedType: "claude_local" }].flatMap((runnerCase) => [
const creationRunnerCases = [...codexRunnerCases,
{ adapterType: "claude_local", runner: "auto", platform: "linux", expectedType: "claude_local" },
{ adapterType: "claude_local", runner: "auto", platform: "darwin", expectedType: "paperclip_runner" },
{ adapterType: "claude_local", runner: "legacy", platform: "darwin", expectedType: "claude_local" },
];
it.each(creationRunnerCases.flatMap((runnerCase) => [
["agents", "paperclipai/paperclip/paperclip-create-agent"],
["agent-hires", "paperclipai/paperclip/paperclip-create-agent"],
["agents", "paperclip"],
@@ -1192,7 +1206,7 @@ describe("agent skill routes", () => {
expect(input.role).toBe("general");
expect(input.adapterType).toBe(expectedType);
const native = expectedType === "paperclip_runner";
if (native) expect(input.adapterConfig.provider).toBe("codex");
if (native) expect(input.adapterConfig).toMatchObject(adapterType === "claude_local" ? { provider: "acpx", acpxAgent: "claude", model: "claude-opus-5-5" } : { provider: "codex" });
const canonicalKey = skill === "paperclip" ? "paperclipai/paperclip/paperclip" : skill;
const expected = ["paperclip", "paperclip-board", "paperclip-converting-plans-to-tasks", "paperclip-create-agent", "para-memory-files", "first-task"]
.filter((name) => !native || name !== "paperclip")
@@ -1211,17 +1225,17 @@ describe("agent skill routes", () => {
expect(mockAgentService.create.mock.calls[0][1].adapterConfig.paperclipSkillSync).toBeUndefined();
});
it.each(codexRunnerCases.flatMap((runnerCase) => ["agents", "agent-hires"].map((route) => ({ ...runnerCase, route }))))("assigns core skills without first-task to ordinary CEOs via $route using $runner on $platform", async ({ route, runner, platform, expectedType }) => {
it.each(creationRunnerCases.flatMap((runnerCase) => ["agents", "agent-hires"].map((route) => ({ ...runnerCase, route }))))("assigns core skills without first-task to ordinary CEOs via $route using $runner on $platform", async ({ route, runner, platform, expectedType, adapterType }) => {
runnerTarget.platform = platform;
runnerTarget.architecture = platform === "darwin" ? "arm64" : "x64";
const res = await request(await createApp())
.post(`/api/companies/company-1/${route}`)
.send({ name: "CEO", role: "ceo", adapterType: "codex_local", ...(runner === "auto" ? {} : { runner }) });
.send({ name: "CEO", role: "ceo", adapterType, ...(runner === "auto" ? {} : { runner }) });
expect(res.status, JSON.stringify(res.body)).toBe(201);
const input = mockAgentService.create.mock.calls[0][1];
expect(input.adapterType).toBe(expectedType);
const native = expectedType === "paperclip_runner";
if (native) expect(input.adapterConfig.provider).toBe("codex");
if (native) expect(input.adapterConfig).toMatchObject(adapterType === "claude_local" ? { provider: "acpx", acpxAgent: "claude", model: "claude-opus-5-5" } : { provider: "codex" });
const desiredSkills = input.adapterConfig.paperclipSkillSync.desiredSkills;
const expected = ["paperclip", "paperclip-board", "paperclip-converting-plans-to-tasks", "paperclip-create-agent", "para-memory-files"]
.filter((name) => !native || name !== "paperclip")
@@ -1237,7 +1251,8 @@ describe("agent skill routes", () => {
type: "agent", agentId: "11111111-1111-4111-8111-111111111111", companyId: "company-1",
}))
.post("/api/companies/company-1/agent-hires")
.send({ name: "Biff", role: "general", adapterType: "claude_local", onboardingFirstAgent: true });
.send({ name: "Biff", role: "general", adapterType: "claude_local",
runner: "legacy", onboardingFirstAgent: true });
expect(res.status, JSON.stringify(res.body)).toBe(201);
expect(mockAgentService.create.mock.calls[0][1].adapterConfig.paperclipSkillSync).toBeUndefined();
await vi.waitFor(() => expect(mockAgentInstructionsService.materializeManagedBundle).toHaveBeenCalled());
@@ -1272,6 +1287,7 @@ describe("agent skill routes", () => {
name: "First Lead",
role: "ceo",
adapterType: "claude_local",
runner: "legacy",
adapterConfig: {},
});
@@ -1329,6 +1345,7 @@ describe("agent skill routes", () => {
name: "First Lead",
role: "ceo",
adapterType: "claude_local",
runner: "legacy",
desiredSkills: ["paperclip"],
adapterConfig: {},
});
@@ -1350,6 +1367,7 @@ describe("agent skill routes", () => {
name: "QA Agent",
role: "engineer",
adapterType: "claude_local",
runner: "legacy",
adapterConfig: {},
});
@@ -1367,6 +1385,7 @@ describe("agent skill routes", () => {
name: "QA Agent",
role: "engineer",
adapterType: "claude_local",
runner: "legacy",
desiredSkills: [{
key: "paperclipai/paperclip/paperclip",
versionId: "22222222-2222-4222-8222-222222222222",
@@ -1391,6 +1410,7 @@ describe("agent skill routes", () => {
role: "engineer",
icon: "crown",
adapterType: "claude_local",
runner: "legacy",
desiredSkills: ["paperclip"],
adapterConfig: {},
sourceIssueId,
@@ -1445,6 +1465,7 @@ describe("agent skill routes", () => {
name: "QA Agent",
role: "engineer",
adapterType: "claude_local",
runner: "legacy",
adapterConfig: {},
instructionsBundle: {
files: {
@@ -1481,6 +1502,7 @@ describe("agent skill routes", () => {
name: "QA Agent",
role: "engineer",
adapterType: "claude_local",
runner: "legacy",
adapterConfig: {
instructionsFilePath: "/tmp/existing/AGENTS.md",
promptTemplate: "You are QA.",
@@ -48,6 +48,16 @@ const withEnv = (env: Record<string, unknown>) => ({ env });
// --- The pure server-enforced binding invariant (no database) ----------------
describe("assertClaudeOAuthBindingInvariant", () => {
it.each([undefined, "claude"])("enforces the fixed binding for native Claude (agent: %s)", acpxAgent => {
const nextConfig = { provider: "acpx", ...(acpxAgent && { acpxAgent }), ...withEnv({ CLAUDE_CODE_OAUTH_TOKEN: FIXED_BINDING }) };
expect(assertClaudeOAuthBindingInvariant({ adapterType: "paperclip_runner", nextConfig }))
.toEqual({ introducesBinding: true, keepsBinding: false });
expect(() => assertClaudeOAuthBindingInvariant({ adapterType: "paperclip_runner", nextConfig: {
...nextConfig, env: { ...nextConfig.env, ANTHROPIC_API_KEY: "fixture-api-key" },
} })).toThrow(CLAUDE_OAUTH_CREDENTIAL_CONFLICT);
expect(assertClaudeOAuthBindingInvariant({ adapterType: "paperclip_runner", nextConfig: { ...nextConfig, acpxAgent: "codex" } }))
.toEqual({ introducesBinding: false, keepsBinding: false });
});
it("reports that a create introduces the fixed binding", () => {
const decision = assertClaudeOAuthBindingInvariant({
adapterType: "claude_local",
@@ -312,6 +322,7 @@ describeEmbeddedPostgres("agent service Claude OAuth binding claim", () => {
role: "engineer",
status: "idle" as const,
adapterType: "claude_local",
runner: "legacy" as const,
defaultEnvironmentId: scope.environmentId,
adapterConfig: { env: { CLAUDE_CODE_OAUTH_TOKEN: { ...FIXED_BINDING }, ...extraEnv } },
runtimeConfig: {},
@@ -359,11 +370,13 @@ describeEmbeddedPostgres("agent service Claude OAuth binding claim", () => {
return rows.length;
}
it("inserts the fixed binding and consumes a valid claim exactly once", async () => {
it.each([false, true])("inserts the fixed binding and consumes a valid claim exactly once (native: %s)", async native => {
const scope = await seedScope();
const sessionId = await seedStoredClaim(scope, Date.now() + 60_000);
const legacyInput = createInput(scope);
const input = native ? { ...legacyInput, runner: undefined, adapterType: "paperclip_runner", adapterConfig: { provider: "acpx", acpxAgent: "claude", ...legacyInput.adapterConfig } } : legacyInput;
const created = await createAgentLifecycle(db).requestHire(scope.companyId, createInput(scope), {
const created = await createAgentLifecycle(db).requestHire(scope.companyId, input, {
claudeLogin: { storedSessionId: sessionId, ownerUserId: scope.ownerUserId },
});
@@ -377,7 +390,7 @@ describeEmbeddedPostgres("agent service Claude OAuth binding claim", () => {
// A replay of the same claim inserts no second agent.
await expect(
createAgentLifecycle(db).requestHire(scope.companyId, createInput(scope), {
createAgentLifecycle(db).requestHire(scope.companyId, input, {
claudeLogin: { storedSessionId: sessionId, ownerUserId: scope.ownerUserId },
}),
).rejects.toMatchObject({ message: CLAUDE_OAUTH_CLAIM_REJECTED });
@@ -616,6 +629,7 @@ describeEmbeddedPostgres("agent service Claude OAuth binding claim", () => {
role: "engineer",
status: "idle",
adapterType: "claude_local",
runner: "legacy",
defaultEnvironmentId: scope.environmentId,
adapterConfig: { env: {} },
runtimeConfig: {},
@@ -680,6 +694,7 @@ describeEmbeddedPostgres("agent service Claude OAuth binding claim", () => {
role: "engineer",
status: "idle",
adapterType: "claude_local",
runner: "legacy",
defaultEnvironmentId: scope.environmentId,
adapterConfig: { env: {} },
runtimeConfig: {},
@@ -755,6 +770,7 @@ describeEmbeddedPostgres("agent service Claude OAuth binding claim", () => {
role: "engineer",
status: "idle",
adapterType: "claude_local",
runner: "legacy",
defaultEnvironmentId: scope.environmentId,
adapterConfig: { env: {} },
runtimeConfig: {},
@@ -202,6 +202,9 @@ describe("durable, authorized connection routing", () => {
expect(changed.binding).toEqual(first.binding); expect(changed.runtimeConfig).toMatchObject({ provider: "codex", model: "gpt-5.6-sol", modelReasoningEffort: "high" }); expect(changed.notes).toHaveLength(1);
const claude = poolMemberRuntimeConfig(members[1]!, "paperclip_runner", { model: "gpt-5.6-sol", modelReasoningEffort: "ultra" });
expect(claude.config).toMatchObject({ provider: "acpx", acpxAgent: "claude", model: "claude-sonnet-5" }); expect(claude.notes).toHaveLength(2);
const claudeEffort = poolMemberRuntimeConfig(members[1]!, "paperclip_runner", { modelReasoningEffort: "xhigh" });
expect(claudeEffort.config).toMatchObject({ provider: "acpx", acpxAgent: "claude", modelReasoningEffort: "xhigh" });
expect(claudeEffort.notes).toEqual([]);
const overrides = applyAiConnectionRouterTaskSettings({ model: "claude-sonnet-5", effort: "invalid", cwd: "/fixture" }, changed);
const native = resolvePaperclipRunnerNativeProviderInput({ backend: "codex_app_server", adapterConfig: projectPaperclipRunnerTaskConfig("codex_app_server", changed.runtimeConfig, overrides) });
expect(native).toMatchObject({ provider: "codex", model: "gpt-5.6-sol", codexReasoningEffort: "high" });
@@ -48,6 +48,7 @@ afterEach(() => {
async function writeHelpWithoutEffortClaudeCommand(commandPath: string): Promise<void> {
const script = `#!/usr/bin/env node
if (process.argv.includes("--version")) { console.log("2.1.286 (Claude Code)"); process.exit(0); }
const argv = process.argv.slice(2);
if (argv.includes("--help")) {
process.stdout.write("Usage: claude [options]\\n --print\\n --model <id>\\n");
@@ -306,6 +307,7 @@ describe("claude_local environment diagnostics", () => {
adapterType: "claude_local",
config: {
engine: "cli",
model: "claude-opus-5",
command: "claude",
},
executionTarget: {
@@ -379,6 +381,7 @@ describe("claude_local environment diagnostics", () => {
await fs.writeFile(path.join(sourceConfigDir, "credentials.json"), JSON.stringify({ token: "local" }), "utf8");
await fs.writeFile(path.join(remoteHome, ".claude", ".credentials.json"), JSON.stringify({ token: "remote" }), "utf8");
await fs.writeFile(commandPath, `#!/usr/bin/env node
if (process.argv.includes("--version")) { console.log("2.1.286 (Claude Code)"); process.exit(0); }
const fs = require("fs");
const path = require("path");
const configDir = process.env.CLAUDE_CONFIG_DIR || "";
@@ -20,6 +20,7 @@ async function writeFailingClaudeCommand(
const payload = JSON.stringify(options.resultEvent);
const exit = options.exitCode ?? 1;
const script = `#!/usr/bin/env node
if (process.argv.includes("--version")) { console.log("2.1.286 (Claude Code)"); process.exit(0); }
console.log(${JSON.stringify(payload)});
process.exit(${exit});
`;
@@ -33,6 +34,7 @@ async function writeTextFailingClaudeCommand(
): Promise<void> {
const exit = options.exitCode ?? 1;
const script = `#!/usr/bin/env node
if (process.argv.includes("--version")) { console.log("2.1.286 (Claude Code)"); process.exit(0); }
if (${JSON.stringify(options.stdout ?? "")}) {
process.stdout.write(${JSON.stringify(options.stdout ?? "")});
}
@@ -47,6 +49,7 @@ process.exit(${exit});
async function writeFakeClaudeCommand(commandPath: string): Promise<void> {
const script = `#!/usr/bin/env node
if (process.argv.includes("--version")) { console.log("2.1.286 (Claude Code)"); process.exit(0); }
const fs = require("node:fs");
const path = require("node:path");
@@ -88,6 +91,7 @@ console.log(JSON.stringify({ type: "result", session_id: "11111111-1111-4111-811
async function writeHelpWithoutEffortClaudeCommand(commandPath: string): Promise<void> {
const script = `#!/usr/bin/env node
if (process.argv.includes("--version")) { console.log("2.1.286 (Claude Code)"); process.exit(0); }
const fs = require("node:fs");
const path = require("node:path");
@@ -126,6 +130,7 @@ console.log(JSON.stringify({ type: "result", session_id: "33333333-3333-4333-833
async function writeHelpWithEffortClaudeCommand(commandPath: string): Promise<void> {
const script = `#!/usr/bin/env node
if (process.argv.includes("--version")) { console.log("2.1.286 (Claude Code)"); process.exit(0); }
const fs = require("node:fs");
const path = require("node:path");
@@ -185,6 +190,7 @@ afterEach(() => {
async function writePoisonedMessageIdClaudeCommand(commandPath: string): Promise<void> {
const script = `#!/usr/bin/env node
if (process.argv.includes("--version")) { console.log("2.1.286 (Claude Code)"); process.exit(0); }
const fs = require("node:fs");
const capturePath = process.env.PAPERCLIP_TEST_CAPTURE_PATH;
@@ -221,6 +227,7 @@ console.log(JSON.stringify({ type: "result", session_id: "bbbbbbbb-bbbb-4bbb-8bb
async function writeAlwaysPoisonedMessageIdClaudeCommand(commandPath: string): Promise<void> {
const script = `#!/usr/bin/env node
if (process.argv.includes("--version")) { console.log("2.1.286 (Claude Code)"); process.exit(0); }
const fs = require("node:fs");
const capturePath = process.env.PAPERCLIP_TEST_CAPTURE_PATH;
@@ -251,6 +258,7 @@ process.exit(1);
async function writeRetryThenSucceedClaudeCommand(commandPath: string): Promise<void> {
const script = `#!/usr/bin/env node
if (process.argv.includes("--version")) { console.log("2.1.286 (Claude Code)"); process.exit(0); }
const fs = require("node:fs");
const capturePath = process.env.PAPERCLIP_TEST_CAPTURE_PATH;
@@ -410,9 +418,9 @@ describe("claude execute", () => {
});
it.each([
[undefined, "claude-opus-5"],
["", "claude-opus-5"],
[" ", "claude-opus-5"],
[undefined, "claude-opus-5-5"],
["", "claude-opus-5-5"],
[" ", "claude-opus-5-5"],
["claude-sonnet-4-5", "claude-sonnet-4-5"],
])("passes the resolved model to the CLI for %j", async (model, expected) => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-claude-default-"));
@@ -6095,7 +6095,7 @@ describe("company portability", () => {
await expect(portability.importBundle(input, "user-1")).rejects.toMatchObject({ status: 422, details: { code: "paperclip_runner_dot_disabled" } });
expect(agentSvc.create).not.toHaveBeenCalled();
instanceSettingsSvc.getExperimental.mockResolvedValue({ enableNativeRunner: false, enableOpenAiDot: true });
await expect(portability.importBundle({ ...input, adapterOverrides: { claudecoder: { adapterType: "paperclip_runner", adapterConfig: { provider: "acpx", acpxAgent: "claude", model: "claude-sonnet-4-6" } } } }, "user-1"))
await expect(portability.importBundle({ ...input, adapterOverrides: { claudecoder: { adapterType: "paperclip_runner", adapterConfig: { provider: "acpx", acpxAgent: "grok", model: "grok-build-1.0.13" } } } }, "user-1"))
.rejects.toMatchObject({ status: 422, details: { code: "paperclip_runner_rollout_disabled" } });
expect(agentSvc.create).not.toHaveBeenCalled();
await portability.importBundle(input, "user-1");
@@ -6129,7 +6129,7 @@ describe("company portability", () => {
adapterOverrides: {
claudecoder: {
adapterType: "paperclip_runner",
adapterConfig: { provider: "acpx", acpxAgent: "claude", model: "claude-sonnet-4-6" },
adapterConfig: { provider: "opencode", model: "openrouter/deepseek/deepseek-v4-flash-0731" },
},
},
};
@@ -6161,7 +6161,7 @@ describe("company portability", () => {
await portability.importBundle(request, "user-1");
expect(agentSvc.create).toHaveBeenCalledWith("company-1", expect.objectContaining({
adapterType: "paperclip_runner",
adapterConfig: expect.objectContaining({ provider: "acpx", acpxAgent: "claude" }),
adapterConfig: expect.objectContaining({ provider: "opencode" }),
}), { createdByUserId: "user-1" });
await portability.importBundle({
+1
View File
@@ -311,6 +311,7 @@ describe("redaction", () => {
providerValue: input.nested.providerValue,
},
});
expect(sanitizeRecord({ schema: "paperclip.native-execution-input.v9" })).toEqual({ schema: "paperclip.native-execution-input.v9" });
expect(sanitizeRecord({ schema: "paperclip.native-execution-input.v7", runtimeSchema: "paperclip.native-execution-input.v8" })).toEqual({
schema: "paperclip.native-execution-input.v7",
runtimeSchema: "paperclip.native-execution-input.v8",
@@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto";
import { promises as fs } from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { eq } from "drizzle-orm";
import { agents, companies, createDb } from "@paperclipai/db";
import {
@@ -11,6 +11,12 @@ import {
} from "./helpers/embedded-postgres.js";
import { teamsCatalogService } from "../services/teams-catalog.js";
vi.mock("../services/agent-runner-selection.js", async importOriginal => {
const actual = await importOriginal<typeof import("../services/agent-runner-selection.js")>();
return { ...actual, resolveNewAgentRunnerForCompany: async (_db: unknown, _companyId: string, input: Parameters<typeof actual.resolveNewAgentRunnerForCompany>[2]) =>
actual.resolveNewAgentRunner({ ...input, target: { driver: "local", platform: "linux", architecture: "x64", libc: "glibc", glibcVersion: "2.39" } }) };
});
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
+30 -1
View File
@@ -4,11 +4,13 @@ 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, probeGrokInstallation, probePiInstallation } = vi.hoisted(() => ({
const { probeInstallation, probeGrokInstallation, probePiInstallation, verifyApiKey } = vi.hoisted(() => ({
verifyApiKey: vi.fn(),
probeInstallation: vi.fn(),
probeGrokInstallation: vi.fn(),
probePiInstallation: vi.fn(),
}));
vi.mock("../services/ai-api-key-test.js", () => ({ validateAiApiKey: verifyApiKey }));
vi.mock("../vendor/paperclip-runner/live/index.js", () => ({
probeAcpxClaudeInstallation: probeInstallation,
probeAcpxGrokInstallation: probeGrokInstallation,
@@ -96,6 +98,7 @@ describe("built-in runtime connection tool delivery", () => {
describe("native ACPX environment checks", () => {
beforeEach(() => {
verifyApiKey.mockReset().mockResolvedValue(undefined);
probeInstallation.mockReset().mockResolvedValue(undefined);
probeGrokInstallation.mockReset().mockResolvedValue(undefined);
probePiInstallation.mockReset().mockResolvedValue(undefined);
@@ -108,6 +111,32 @@ describe("native ACPX environment checks", () => {
config: { provider: "acpx", acpxAgent: "claude", model: "claude-sonnet-5" },
};
it("verifies a native Claude key without the global CLI or a model prompt", async () => {
const result = await requireServerAdapter("paperclip_runner").testEnvironment!({ ...context,
config: { ...context.config, env: { ANTHROPIC_API_KEY: "test-key" } },
});
expect(result.status).toBe("pass");
expect(verifyApiKey).toHaveBeenCalledExactlyOnceWith("anthropic", "test-key");
expect(result.checks.map(check => check.code)).toEqual(["acpx_runtime_ready", "ai_connection_api_key_reverified"]);
});
it.each(["ai_connection_api_key_rejected", "ai_connection_verification_failed"])("fails setup on %s", async code => {
verifyApiKey.mockRejectedValue({ details: { code } });
const result = await requireServerAdapter("paperclip_runner").testEnvironment!({ ...context,
config: { ...context.config, env: { ANTHROPIC_API_KEY: "test-key" } },
});
expect(result.status).toBe("fail");
expect(result.checks.at(-1)).toMatchObject({ code, level: "error" });
});
it.each(["ANTHROPIC_BASE_URL", "ANTHROPIC_AUTH_TOKEN", "CLAUDE_CODE_USE_BEDROCK", "CLAUDE_CODE_USE_VERTEX", "CLAUDE_CODE_USE_FOUNDRY"])("preserves the supplementary probe for custom routing via %s", async name => {
const result = await requireServerAdapter("paperclip_runner").testEnvironment!({ ...context,
config: { ...context.config, env: { ANTHROPIC_API_KEY: "test-key", [name]: "configured" } },
});
expect(verifyApiKey).not.toHaveBeenCalled();
expect(result.checks.some(check => check.code === "ai_connection_api_key_reverified")).toBe(false);
});
it("reports unsupported local platforms before a successful CLI login can mask them", async () => {
probeInstallation.mockRejectedValue(new Error("ACPX Claude requires a supported runtime platform"));
const result = await requireServerAdapter("paperclip_runner").testEnvironment!(context);
+36 -7
View File
@@ -1,5 +1,7 @@
import type { AdapterRuntimeCommandSpec, ServerAdapterModule } from "./types.js";
import { parseAdapterModelsEnv } from "../services/adapter-models-env.js";
import { validateAiApiKey } from "../services/ai-api-key-test.js";
import type { AdapterEnvironmentTestResult } from "@paperclipai/shared";
import { stampClaudeAgentIdHeader } from "./claude-agent-id-header.js";
import {
buildSandboxNpmInstallCommand,
@@ -143,6 +145,33 @@ import {
resolvePaperclipRunnerProviderProfile,
} from "../services/native-runtime/provider-profile.js";
/** Reuse the existing provider key check without requiring a global Claude CLI.
* Readiness and account access are separate checks; model access is proven by a task.
*/
async function verifyNativeClaudeApiKey(
config: Record<string, unknown>,
result: AdapterEnvironmentTestResult,
): Promise<AdapterEnvironmentTestResult> {
const env = config.env as Record<string, unknown> | undefined;
if (config.acpxAgent !== "claude" || !env || typeof env.ANTHROPIC_API_KEY !== "string"
|| !env.ANTHROPIC_API_KEY || config.managedAiRouting || config.managedAiConnection
|| ["ANTHROPIC_BASE_URL", "ANTHROPIC_AUTH_TOKEN", "CLAUDE_CODE_USE_BEDROCK", "CLAUDE_CODE_USE_VERTEX", "CLAUDE_CODE_USE_FOUNDRY"].some(key => env[key])) return result;
try {
await validateAiApiKey("anthropic", env.ANTHROPIC_API_KEY);
return { ...result, checks: [...result.checks, {
code: "ai_connection_api_key_reverified", level: "info",
message: "Anthropic verified this API key. Model access is checked when the agent runs.",
}] };
} catch (error) {
const details = error && typeof error === "object" && "details" in error
? error.details as { code?: string } | undefined : undefined;
return { ...result, status: "fail", checks: [...result.checks, {
code: details?.code === "ai_connection_api_key_rejected" ? details.code : "ai_connection_verification_failed",
level: "error", message: "Could not verify the Anthropic API key. Check the connection and try again.",
}] };
}
}
function readConfiguredCommand(config: Record<string, unknown>, fallback: string): string {
const value = typeof config.command === "string" ? config.command.trim() : "";
return value.length > 0 ? value : fallback;
@@ -436,18 +465,18 @@ const paperclipRunnerAdapter: ServerAdapterModule = {
if (!((os === "Linux" && arch === "x86_64") || (os === "Darwin" && (arch === "arm64" || (profile.acpxAgent !== "grok" && arch === "x86_64"))))) {
throw new Error(`ACPX ${profile.acpxAgent} requires a qualified Linux x64 or macOS architecture.`);
}
return {
return verifyNativeClaudeApiKey(context.config, {
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." }],
};
});
}
const { probeAcpxClaudeInstallation, probeAcpxGrokInstallation, probeAcpxCursorInstallation, probeAcpxPiInstallation } = await import("../vendor/paperclip-runner/live/index.js");
await (profile.acpxAgent === "pi" ? probeAcpxPiInstallation : profile.acpxAgent === "grok" ? probeAcpxGrokInstallation : profile.acpxAgent === "cursor" ? probeAcpxCursorInstallation : probeAcpxClaudeInstallation)(profile.model);
return {
return verifyNativeClaudeApiKey(context.config, {
adapterType: "paperclip_runner", status: "pass" as const, testedAt: new Date().toISOString(),
checks: [{ code: "acpx_runtime_ready", level: "info" as const, message: `ACPX ${profile.acpxAgent} runtime is installed and verified. Model access is checked when it runs.` }],
};
});
} catch (error) {
return {
adapterType: "paperclip_runner", status: "fail" as const, testedAt: new Date().toISOString(),
@@ -505,19 +534,19 @@ const paperclipRunnerAdapter: ServerAdapterModule = {
models: [
...codexModels,
{ id: DEFAULT_OPENCODE_RUNNER_MODEL, label: "OpenRouter · DeepSeek V4 Flash 0731" },
{ id: DEFAULT_ACPX_RUNNER_MODELS.claude, label: "Claude Sonnet 5" },
{ id: DEFAULT_ACPX_RUNNER_MODELS.claude, label: "Claude Opus 5.5" },
{ id: "global.anthropic.claude-sonnet-4-6", label: "Amazon Bedrock · Claude Sonnet 4.6 (global)" },
],
listModels: async () => [
...await listCodexModels(),
{ id: DEFAULT_OPENCODE_RUNNER_MODEL, label: "OpenRouter · DeepSeek V4 Flash 0731" },
{ id: DEFAULT_ACPX_RUNNER_MODELS.claude, label: "Claude Sonnet 5" },
{ id: DEFAULT_ACPX_RUNNER_MODELS.claude, label: "Claude Opus 5.5" },
{ id: "global.anthropic.claude-sonnet-4-6", label: "Amazon Bedrock · Claude Sonnet 4.6 (global)" },
],
refreshModels: async () => [
...await refreshCodexModels(),
{ id: DEFAULT_OPENCODE_RUNNER_MODEL, label: "OpenRouter · DeepSeek V4 Flash 0731" },
{ id: DEFAULT_ACPX_RUNNER_MODELS.claude, label: "Claude Sonnet 5" },
{ id: DEFAULT_ACPX_RUNNER_MODELS.claude, label: "Claude Opus 5.5" },
{ id: "global.anthropic.claude-sonnet-4-6", label: "Amazon Bedrock · Claude Sonnet 4.6 (global)" },
],
supportsLocalAgentJwt: false,
@@ -136,7 +136,7 @@ const support = await getEmbeddedPostgresTestSupport();
const probe = vi.spyOn(adapter, "testEnvironment").mockResolvedValue({ adapterType, status: "warn",
testedAt: new Date().toISOString(), checks: [{ code: `${adapterType.split("_")[0]}_hello_probe_auth_required`, level: "warn", message: "Login required" }] });
try {
const agent = await createAgentLifecycle(db).requestHire(companyId, { name: "Login test", adapterType });
const agent = await createAgentLifecycle(db).requestHire(companyId, { name: "Login test", adapterType, runner: "legacy" });
const work = worker(createLifecycleDriver(db, {} as never).runHost);
await work.process(agent.id);
expect(probe).toHaveBeenCalled();
+1
View File
@@ -92,6 +92,7 @@ export const PAPERCLIP_PUBLIC_SCHEMA_IDS = new Set([
"paperclip.native-execution-input.v5",
"paperclip.native-execution-input.v7",
"paperclip.native-execution-input.v8",
"paperclip.native-execution-input.v9",
"paperclip.native-finalization.v1",
"paperclip.native-finalization.v2",
"paperclip.native-harness-backup-stamp.v1",
+10 -6
View File
@@ -1849,7 +1849,7 @@ export function agentRoutes(
if (experimental.enableMuse !== true || experimental.enableNativeRunner !== true) throw unprocessable("Muse is experimental and disabled on this instance.", { code: "paperclip_runner_muse_disabled" });
} else if (asRecord(config)?.provider === "grok_bot" && (experimental.enableGrokBot !== true || experimental.enablePublicMcp !== true || experimental.enableNativeRunner !== true)) {
throw unprocessable("Enable Grok Bot, Assistant connections (MCP), and Paperclip Runner before connecting this agent.", { code: "paperclip_runner_grok_disabled" });
} else if (!([undefined, "codex"].includes(asRecord(config)?.provider as string | undefined)) && experimental.enableNativeRunner !== true) {
} else if (!([undefined, "codex"].includes(asRecord(config)?.provider as string | undefined)) && agentHarnessType("paperclip_runner", asRecord(config) ?? {}) !== "claude_local" && experimental.enableNativeRunner !== true) {
throw unprocessable(
"Paperclip Runner is experimental and disabled on this instance.",
{ code: "paperclip_runner_rollout_disabled" },
@@ -2255,12 +2255,12 @@ export function agentRoutes(
const noInheritance = { adapterConfig, inheritedFixedClaudeOAuthBinding: false };
if (asRecord(runtimeConfig)?.aiConnection) return noInheritance;
if (req.actor.type !== "agent" || !req.actor.agentId) return noInheritance;
const harness = agentHarnessType(adapterType ?? "", adapterConfig) === "codex_local" ? "codex_local" : adapterType ?? "";
const harness = agentHarnessType(adapterType ?? "", adapterConfig);
const credentialKeys = INHERITABLE_AGENT_CREDENTIAL_ENV_KEYS[harness];
if (!credentialKeys) return noInheritance;
const parent = await svc.getById(req.actor.agentId);
if (!parent || parent.companyId !== companyId || (harness === "codex_local" ? agentHarnessType(parent.adapterType, parent.adapterConfig) !== harness : parent.adapterType !== adapterType)) return noInheritance;
if (!parent || parent.companyId !== companyId || agentHarnessType(parent.adapterType, parent.adapterConfig) !== harness) return noInheritance;
// Managed parents must not pass stale legacy credential references to hires.
if (aiRuntimeConnectionBindingSchema.safeParse(parent.runtimeConfig.aiConnection).success) return noInheritance;
const parentEnv = asRecord(asRecord(parent.adapterConfig)?.env);
@@ -2269,7 +2269,7 @@ export function agentRoutes(
const existingEnv = asRecord(adapterConfig.env);
const claudeCredentialKeys = INHERITABLE_AGENT_CREDENTIAL_ENV_KEYS.claude_local;
const childHasClaudeCredential =
adapterType === "claude_local" &&
harness === "claude_local" &&
existingEnv !== null &&
claudeCredentialKeys.some((key) => existingEnv[key] !== undefined);
if (childHasClaudeCredential) return noInheritance;
@@ -2630,11 +2630,13 @@ export function agentRoutes(
adapterType: string,
adapterConfig: Record<string, unknown>,
allowPlainCredentials = false,
harnessConfig = adapterConfig,
) {
if (req.actor.type !== "agent" || !adapterType.endsWith("_local")) return;
if (req.actor.type !== "agent" || (!adapterType.endsWith("_local") && adapterType !== "paperclip_runner")) return;
const harness = agentHarnessType(adapterType, harnessConfig);
const changedKeys = LOCAL_ADAPTER_HOST_COMMAND_KEYS.filter((key) =>
adapterConfig[key] !== undefined &&
(key !== "env" || !containsOnlyLocalAdapterCredentialRefs(adapterType, adapterConfig.env, allowPlainCredentials)),
(key !== "env" || !containsOnlyLocalAdapterCredentialRefs(harness, adapterConfig.env, allowPlainCredentials)),
);
if (changedKeys.length === 0) return;
throw forbidden(
@@ -5329,6 +5331,8 @@ export function agentRoutes(
req,
requestedAdapterType,
changingAdapterType ? rawEffectiveAdapterConfig : (requestedAdapterConfig ?? {}),
false,
rawEffectiveAdapterConfig,
);
}
const existingRunnerProvider =
+2 -1
View File
@@ -1,5 +1,6 @@
import { eq } from "drizzle-orm";
import { agents, type Db } from "@paperclipai/db";
import { agentHarnessType } from "@paperclipai/shared";
import { collectSecretRefs, collectUserSecretRefs, syncAgentAdapterEnvBindings } from "./agent-secret-bindings.js";
import { logActivity } from "./activity-log.js";
import { claudeOAuthBindingsMatchExactly, claudeOAuthClaimRejectedError,
@@ -166,7 +167,7 @@ export function agentCredentialService(db: Db) {
if (
!parent ||
parent.companyId !== input.companyId ||
parent.adapterType !== CLAUDE_LOCAL_ADAPTER_TYPE ||
agentHarnessType(parent.adapterType, parent.adapterConfig as Record<string, unknown>) !== CLAUDE_LOCAL_ADAPTER_TYPE ||
!claudeOAuthBindingsMatchExactly(parentBinding, childBinding)
) {
throw claudeOAuthClaimRejectedError();
@@ -378,7 +378,8 @@ describe("server-owned Codex runner selection", () => {
describe("Codex creation runner contract", () => {
it("graduates only Codex and retains saved provider identity", () => {
expect(paperclipRunnerProfileForHarness("codex_local")).toEqual({ provider: "codex" });
for (const harness of ["claude_local", "opencode_local", "grok_local", "cursor", "pi_local", "process"]) expect(paperclipRunnerProfileForHarness(harness)).toBeUndefined();
expect(paperclipRunnerProfileForHarness("claude_local")).toEqual({ provider: "acpx", acpxAgent: "claude" });
for (const harness of ["opencode_local", "grok_local", "cursor", "pi_local", "process"]) expect(paperclipRunnerProfileForHarness(harness)).toBeUndefined();
expect(agentHarnessType("paperclip_runner", { provider: "codex" })).toBe("codex_local");
expect(agentHarnessType("paperclip_runner", { provider: "acpx", acpxAgent: "claude" })).toBe("claude_local");
expect(agentHarnessType("paperclip_runner", { provider: "unknown" })).toBe("unknown");
@@ -394,7 +395,7 @@ describe("Codex creation runner contract", () => {
expect(paperclipRunnerSupportsPlatform("codex_local", "linux", architecture, "unknown")).toBe(false);
}
for (const [platform, architecture] of [["darwin", "ia32"], ["linux", "arm"], ["win32", "x64"], ["win32", "arm64"], ["freebsd", "x64"]]) expect(paperclipRunnerSupportsPlatform("codex_local", platform, architecture)).toBe(false);
expect(paperclipRunnerSupportsPlatform("claude_local", "darwin", "arm64")).toBe(false);
expect(paperclipRunnerSupportsPlatform("claude_local", "darwin", "arm64")).toBe(true);
});
it("keeps auto out of saved update defaults and validates request intent", () => {
expect(createAgentSchema.parse({ name: "Codex", adapterType: "codex_local" }).runner).toBeUndefined();
@@ -403,3 +404,82 @@ describe("Codex creation runner contract", () => {
expect(createAgentSchema.safeParse({ name: "Codex", adapterType: "codex_local", runner: "other" }).success).toBe(false);
});
});
describe("Claude runner defaults", () => {
const mac = { driver: "local", platform: "darwin", architecture: "arm64" };
beforeEach(() => { state.disabled = []; state.overridden.clear(); });
it.each(["arm64"])("keeps Darwin SSH %s automatic selection Legacy while preserving explicit assets", architecture => {
const target = { driver: "ssh", platform: "darwin", architecture };
expect(agentRunnerAvailability("claude_local", target)).toEqual({ supportedRunners: ["paperclip", "legacy"], defaultRunner: "legacy" });
const config = { model: "custom-model", dangerouslySkipPermissions: false };
expect(resolveNewAgentRunner({ adapterType: "claude_local", target, adapterConfig: config })).toEqual({ adapterType: "claude_local", adapterConfig: config });
const assets = { runnerRemoteProviderPackPath: "/operator/mac-pack", runnerRemoteBinaryPath: "/operator/mac-daemon" };
expect(resolveNewAgentRunner({ adapterType: "claude_local", target, runner: "paperclip", adapterConfig: assets }))
.toMatchObject({ adapterType: "paperclip_runner", adapterConfig: { ...assets, provider: "acpx", acpxAgent: "claude" } });
const saved = { adapterType: "paperclip_runner", adapterConfig: { ...assets, provider: "acpx", acpxAgent: "claude" }, target };
expect(resolveNewAgentRunner(saved)).toEqual({ adapterType: saved.adapterType, adapterConfig: saved.adapterConfig });
});
it("saves native Claude with Opus 5.5, preserving credentials and shared settings", () => {
const config = { env: { ANTHROPIC_API_KEY: { type: "secret_ref", secretId: "key" } }, instructionsFilePath: "/workspace/AGENTS.md", timeoutSec: 180, dangerouslySkipPermissions: true };
const result = resolveNewAgentRunner({ adapterType: "claude_local", target: mac, adapterConfig: config });
expect(result).toEqual({ adapterType: "paperclip_runner", adapterConfig: { env: config.env, instructionsFilePath: config.instructionsFilePath, timeoutSec: 180, provider: "acpx", acpxAgent: "claude", model: "claude-opus-5-5", acpxPermissionMode: "approve-all", lifecycleMode: "per_turn" } });
expect(resolveNewAgentRunner(result)).toEqual(result);
expect(config.dangerouslySkipPermissions).toBe(true);
});
it("preserves explicit models and Legacy choices", () => {
const legacy = { adapterType: "claude_local", target: mac, runner: "legacy" as const, adapterConfig: { model: "custom", chrome: true } };
expect(resolveNewAgentRunner(legacy)).toEqual({ adapterType: legacy.adapterType, adapterConfig: legacy.adapterConfig });
expect(resolveNewAgentRunner({ adapterType: "claude_local", target: mac, adapterConfig: { model: "custom" } }).adapterConfig.model).toBe("custom");
});
it.each(["claude-sonnet-5-5", "custom-model"])("preserves the plain environment model binding %s", model => {
const env = { ANTHROPIC_MODEL: { type: "plain", value: model } };
const native = resolveNewAgentRunner({ adapterType: "claude_local", target: mac, adapterConfig: { env } });
expect(native.adapterConfig.model).toBe(model);
expect(native.adapterConfig.env).toEqual(env);
});
it.each(["ANTHROPIC_MODEL", "CLAUDE_CODE_USE_BEDROCK", "CLAUDE_CODE_USE_VERTEX", "ANTHROPIC_BEDROCK_BASE_URL"])("rejects unresolved %s before choosing a default", key => {
const env = { [key]: { type: "secret_ref", secretId: "override" } };
expect(() => resolveNewAgentRunner({ adapterType: "claude_local", target: mac, adapterConfig: { env } })).toThrow(`env.${key}`);
expect(resolveNewAgentRunner({ adapterType: "claude_local", target: mac, adapterConfig: { env, model: "custom-model" } }).adapterConfig.model).toBe("custom-model");
});
it("does not pin the ordinary model over a plain provider-routing binding", () => {
expect(() => resolveNewAgentRunner({ adapterType: "claude_local", target: mac,
adapterConfig: { env: { CLAUDE_CODE_USE_BEDROCK: { type: "plain", value: "1" } } } })).toThrow("Legacy runner");
});
it.each(["permissionMode", "acpPermissionMode"])("translates the legacy Claude %s without granting broader permissions", key => {
for (const mode of ["deny-all", "approve-reads", "approve-all"]) {
const native = resolveNewAgentRunner({ adapterType: "claude_local", target: mac, adapterConfig: { engine: "acp", [key]: mode } });
expect(native.adapterConfig.acpxPermissionMode).toBe(mode);
expect(native.adapterConfig[key]).toBeUndefined();
}
expect(() => resolveNewAgentRunner({ adapterType: "claude_local", target: mac, adapterConfig: { [key]: "deny-all", acpxPermissionMode: "approve-all" } })).toThrow("cannot honor");
expect(() => resolveNewAgentRunner({ adapterType: "claude_local", target: mac, adapterConfig: { [key]: "unrecognized" } })).toThrow("Legacy runner");
});
it("retains saved native Claude and allows an explicit compatible Legacy switch", () => {
const saved = { adapterType: "paperclip_runner", adapterConfig: { provider: "acpx", acpxAgent: "claude", model: "custom", acpxPermissionMode: "approve-all", lifecycleMode: "per_turn" } };
expect(resolveNewAgentRunner(saved)).toEqual(saved);
expect(resolveNewAgentRunner({ ...saved, runner: "legacy" })).toEqual({ adapterType: "claude_local", adapterConfig: { model: "custom", dangerouslySkipPermissions: true } });
expect(() => resolveNewAgentRunner({ ...saved, runner: "legacy", adapterConfig: { ...saved.adapterConfig, acpxPermissionMode: "deny-all" } })).toThrow("cannot honor");
});
it.each(["chrome", "agentCommand", "maxTurnsPerRun", "networkScope"])("rejects unsupported %s without fallback", key => {
expect(() => resolveNewAgentRunner({ adapterType: "claude_local", target: mac, adapterConfig: { [key]: "configured" } })).toThrow("Legacy runner");
});
it.each(["low", "medium", "high", "xhigh", "max"])("translates Claude effort %s and preserves it on an explicit Legacy switch", effort => {
const native = resolveNewAgentRunner({ adapterType: "claude_local", target: mac, adapterConfig: { effort } });
expect(native.adapterConfig.modelReasoningEffort).toBe(effort);
expect(native.adapterConfig.effort).toBeUndefined();
expect(resolveNewAgentRunner({ ...native, runner: "legacy" }).adapterConfig).toMatchObject({ effort });
});
it("rejects invalid effort and unknown platform defaults", () => {
expect(() => resolveNewAgentRunner({ adapterType: "claude_local", target: mac, adapterConfig: { effort: "ultra" } })).toThrow("Claude effort");
expect(agentRunnerAvailability("claude_local", { driver: "sandbox" }).defaultRunner).toBe("legacy");
});
it("keeps unqualified targets and overrides legacy", () => {
expect(agentRunnerAvailability("claude_local", target).defaultRunner).toBe("legacy");
state.overridden.add("claude_local");
expect(agentRunnerAvailability("claude_local", mac).defaultRunner).toBe("legacy");
state.overridden.clear(); state.disabled = ["claude_local"];
expect(() => resolveNewAgentRunner({ adapterType: "claude_local", target: mac })).toThrow("Claude is disabled");
});
});
+70 -10
View File
@@ -1,9 +1,10 @@
import type { Db } from "@paperclipai/db";
import { agentHarnessType, isEnvironmentDriverSupportedForAdapter, paperclipRunnerSupportsPlatform, type AgentRunnerChoice, type AgentRunnerAvailability } from "@paperclipai/shared";
import { normalizeLegacyRunnerProvider, PAPERCLIP_RUNNER_DEFAULT_MODELS } from "@paperclipai/adapter-utils";
import { resolveClaudeModel } from "@paperclipai/adapter-claude-local";
import { findActiveServerAdapter, hasActiveAdapterOverride, waitForExternalAdapters } from "../adapters/registry.js";
import { getDisabledAdapterTypes } from "./adapter-plugin-store.js";
import { PaperclipRunnerProviderProfileError, resolvePaperclipRunnerNativeProviderInput } from "./native-runtime/provider-profile.js";
import { claudeRunnerUnsupportedSettings, PaperclipRunnerProviderProfileError, resolvePaperclipRunnerNativeProviderInput } from "./native-runtime/provider-profile.js";
import { forbidden, unprocessable } from "../errors.js";
import { runSshCommand } from "@paperclipai/adapter-utils/ssh";
import { resolveEnvironmentDriverConfigForRuntime } from "./environment-config.js";
@@ -18,34 +19,84 @@ export interface AgentRunnerSelection {
target?: RunnerTarget;
}
export function agentRunnerAvailability(harness: string, target: RunnerTarget = localAgentRunnerTarget()): AgentRunnerAvailability {
function runnerAdaptersAvailable(harness: string, driver: string): boolean {
const disabled = new Set(getDisabledAdapterTypes());
const supported = harness === "codex_local"
return ["codex_local", "claude_local"].includes(harness)
&& !disabled.has(harness) && !disabled.has("paperclip_runner")
&& !!findActiveServerAdapter(harness) && !!findActiveServerAdapter("paperclip_runner")
&& !hasActiveAdapterOverride(harness) && !hasActiveAdapterOverride("paperclip_runner")
&& isEnvironmentDriverSupportedForAdapter("paperclip_runner", target.driver)
&& isEnvironmentDriverSupportedForAdapter("paperclip_runner", driver);
}
export function agentRunnerAvailability(harness: string, target: RunnerTarget = localAgentRunnerTarget()): AgentRunnerAvailability {
const supported = runnerAdaptersAvailable(harness, target.driver)
&& (target.driver !== "sandbox" || Boolean(target.platform && target.architecture
&& (target.platform !== "linux" || (target.libc === "glibc" && target.glibcVersion))))
&& (!target.platform || paperclipRunnerSupportsPlatform(harness, target.platform, target.architecture ?? "", target.libc, target.glibcVersion));
return { supportedRunners: supported ? ["paperclip", "legacy"] : ["legacy"], defaultRunner: supported ? "paperclip" : "legacy" };
&& (harness === "claude_local"
? !!target.platform && paperclipRunnerSupportsPlatform(harness, target.platform, target.architecture ?? "", target.libc, target.glibcVersion)
: !target.platform || paperclipRunnerSupportsPlatform(harness, target.platform, target.architecture ?? "", target.libc, target.glibcVersion));
const automatic = supported && !(harness === "claude_local" && target.driver === "ssh" && target.platform === "darwin");
return { supportedRunners: supported ? ["paperclip", "legacy"] : ["legacy"], defaultRunner: automatic ? "paperclip" : "legacy" };
}
function incompatible(fields: string[]): never {
throw unprocessable(`Paperclip Runner cannot honor these settings: ${fields.join(", ")}. Remove them or select Legacy runner in Advanced.`, { code: "agent_runner_config_incompatible", fields });
}
function resolveClaudeRunnerConfig(config: Record<string, unknown>): Record<string, unknown> {
const translated = { ...config };
const legacyPermission = [config.permissionMode, config.acpPermissionMode].find(value => typeof value === "string" && value.trim()) as string | undefined;
if (legacyPermission !== undefined) {
const mode = legacyPermission.trim();
if (!["approve-all", "approve-reads", "deny-all"].includes(mode)
|| (config.acpxPermissionMode !== undefined && config.acpxPermissionMode !== mode)) incompatible(["permissionMode", "acpPermissionMode"]);
translated.acpxPermissionMode = mode;
}
for (const key of ["permissionMode", "acpPermissionMode"]) {
if (config[key] !== undefined && typeof config[key] !== "string") incompatible([key]);
delete translated[key];
}
const unsupported = claudeRunnerUnsupportedSettings(translated);
const effort = config.modelReasoningEffort ?? config.reasoningEffort ?? config.effort;
if (config.model !== undefined && typeof config.model !== "string") unsupported.push("model");
if (unsupported.length) incompatible(unsupported);
for (const key of ["engine", "command", "mode", "acpMode", "nonInteractivePermissions", "acpNonInteractivePermissions", "dangerouslySkipPermissions", "chrome", "maxTurnsPerRun", "effort", "reasoningEffort"]) delete translated[key];
const env = config.env && typeof config.env === "object" ? config.env as Record<string, unknown> : {};
const modelEnv = { ...env };
if (!(typeof config.model === "string" && config.model.trim())) {
for (const key of ["ANTHROPIC_MODEL", "CLAUDE_CODE_USE_BEDROCK", "CLAUDE_CODE_USE_VERTEX", "ANTHROPIC_BEDROCK_BASE_URL"]) {
const binding = env[key];
if (binding === undefined || typeof binding === "string") continue;
if (binding && typeof binding === "object" && "type" in binding && binding.type === "plain"
&& "value" in binding && typeof binding.value === "string") modelEnv[key] = binding.value;
else incompatible([`env.${key}`]);
}
}
const model = resolveClaudeModel(config.model, modelEnv);
if (!model) incompatible(["model"]);
return { ...translated, ...(effort === undefined || effort === "" ? {} : { modelReasoningEffort: effort }), provider: "acpx", acpxAgent: "claude", model, acpxPermissionMode: translated.acpxPermissionMode ?? "approve-all", lifecycleMode: config.lifecycleMode ?? "per_turn" };
}
export function resolveNewAgentRunner(input: AgentRunnerSelection): { adapterType: string; adapterConfig: Record<string, unknown> } {
const adapterType = input.adapterType ?? "process";
let config = { ...input.adapterConfig };
const harness = agentHarnessType(adapterType, config);
const native = adapterType === "paperclip_runner";
if (adapterType === "codex_local" && getDisabledAdapterTypes().includes(adapterType)) throw unprocessable("Codex is disabled on this instance. Enable its adapter or choose another harness.", { code: "agent_runner_unavailable" });
if (["codex_local", "claude_local"].includes(adapterType) && getDisabledAdapterTypes().includes(adapterType)) throw unprocessable(`${adapterType === "claude_local" ? "Claude" : "Codex"} is disabled on this instance. Enable its adapter or choose another harness.`, { code: "agent_runner_unavailable" });
if (native && (input.runner === undefined || input.runner === "paperclip" || (harness !== "codex_local" && input.runner !== "legacy"))) return { adapterType, adapterConfig: normalizeLegacyRunnerProvider(config) };
const availability = agentRunnerAvailability(harness, input.target);
const choice = input.runner ?? "auto";
const useNative = choice === "paperclip" || (choice === "auto" && availability.defaultRunner === "paperclip");
if (!useNative) {
if (harness === "claude_local") {
if (!native) return { adapterType, adapterConfig: config };
if (config.acpxPermissionMode !== undefined && config.acpxPermissionMode !== "approve-all") incompatible(["acpxPermissionMode"]);
if (config.lifecycleMode !== undefined && config.lifecycleMode !== "per_turn") incompatible(["lifecycleMode"]);
const effort = config.modelReasoningEffort ?? config.reasoningEffort ?? config.effort;
for (const key of ["provider", "acpxAgent", "acpxPermissionMode", "lifecycleMode", "idleTimeoutMs", "modelReasoningEffort", "reasoningEffort"]) delete config[key];
if (effort !== undefined) config.effort = effort;
return { adapterType: "claude_local", adapterConfig: { ...config, dangerouslySkipPermissions: config.dangerouslySkipPermissions ?? true } };
}
if (!native && harness !== "codex_local") return { adapterType, adapterConfig: config };
if (!native && !["codexPermissionMode", "lifecycleMode", "idleTimeoutMs"].some(key => config[key] !== undefined)) return { adapterType, adapterConfig: config };
if (harness !== "codex_local") throw unprocessable("This native harness does not have a runner override in this release.", { code: "agent_runner_unavailable" });
@@ -55,10 +106,19 @@ export function resolveNewAgentRunner(input: AgentRunnerSelection): { adapterTyp
return { adapterType: "codex_local", adapterConfig: { ...config, dangerouslyBypassApprovalsAndSandbox: config.dangerouslyBypassApprovalsAndSandbox ?? true } };
}
if (native && harness !== "codex_local" && choice === "paperclip") return { adapterType, adapterConfig: normalizeLegacyRunnerProvider(config) };
if (availability.defaultRunner !== "paperclip") throw unprocessable(input.target?.driver === "sandbox"
if (!availability.supportedRunners.includes("paperclip")) throw unprocessable(input.target?.driver === "sandbox"
? "Paperclip Runner is unavailable for this sandbox. Choose an immutable OCI image digest and run its environment connection probe to qualify the OS, architecture and libc, or select Legacy runner in Advanced."
: "Paperclip Runner is unavailable for this harness and environment. Select Legacy runner in Advanced.", { code: "agent_runner_unavailable" });
if (native) return { adapterType, adapterConfig: normalizeLegacyRunnerProvider(config) };
if (harness === "claude_local") {
config = resolveClaudeRunnerConfig(config);
try { resolvePaperclipRunnerNativeProviderInput({ backend: "acpx_runtime", adapterConfig: config }); }
catch (error) {
if (error instanceof PaperclipRunnerProviderProfileError) throw unprocessable(error.message, { code: error.code });
throw error;
}
return { adapterType: "paperclip_runner", adapterConfig: config };
}
const aliasSources = new Map<string, string>();
for (const [canonical, alias] of [
["agentCommand", "acpAgentCommand"], ["stateDir", "acpStateDir"],
@@ -114,7 +174,7 @@ export function resolveNewAgentRunner(input: AgentRunnerSelection): { adapterTyp
}
export async function resolveNewAgentRunnerForCompany(db: Db, companyId: string, input: AgentRunnerSelection & { defaultEnvironmentId?: string | null }) {
if (agentHarnessType(input.adapterType ?? "process", input.adapterConfig) !== "codex_local"
if (!["codex_local", "claude_local"].includes(agentHarnessType(input.adapterType ?? "process", input.adapterConfig))
|| input.runner === "legacy" || (input.adapterType === "paperclip_runner" && (input.runner === undefined || input.runner === "paperclip"))) return resolveNewAgentRunner(input);
const target = await resolveAgentRunnerTargetForCompany(db, companyId, input.defaultEnvironmentId);
return resolveNewAgentRunner({ ...input, target });
@@ -151,7 +211,7 @@ export async function resolveAgentRunnerTargetForCompany(db: Db, companyId: stri
const qualified = await readSandboxPlatformQualification(db, environment, companyId);
if (qualified) target = { driver, ...qualified };
}
if (driver === "ssh" && environment && agentRunnerAvailability("codex_local", target).defaultRunner === "paperclip") {
if (driver === "ssh" && environment && ["codex_local", "claude_local"].some(harness => runnerAdaptersAvailable(harness, driver))) {
const parsed = await resolveEnvironmentDriverConfigForRuntime(db, companyId, environment);
if (parsed.driver !== "ssh") throw unprocessable("The selected SSH environment is unavailable.");
let output: string;
+1 -2
View File
@@ -55,8 +55,7 @@ export function poolMemberRuntimeConfig(member: AiConnectionPoolMember, adapterT
const efforts = provider === "openai" ? codexLocalReasoningEffortsForModel(model)
: provider === "anthropic" ? claudeLocalReasoningEffortsForModel(model)
: provider === "xai" ? grokLocalReasoningEffortsForModel(model) : [];
// ACPX currently has no qualified effort override; keep that host limitation explicit.
const effortKey = adapterType === "paperclip_runner" ? profile.provider === "codex" ? "modelReasoningEffort" : null
const effortKey = adapterType === "paperclip_runner" ? profile.provider === "codex" || (profile.provider === "acpx" && profile.acpxAgent === "claude") ? "modelReasoningEffort" : null
: provider === "anthropic" ? "effort" : provider === "xai" ? "reasoningEffort" : provider === "openai" ? "modelReasoningEffort" : "variant";
const requested = overrides.modelReasoningEffort ?? overrides.reasoningEffort ?? overrides.effort ?? overrides.variant;
const effort = typeof requested === "string" && efforts.includes(requested) && effortKey ? requested
@@ -6,6 +6,12 @@ const receipt: AdapterUsageCheckpoint = { complete: true, usageBasis: "per_run",
billingType: "metered_api", model: "claude-sonnet-5", costUsd: null, costStatus: "unpriced",
usage: { inputTokens: 22462, cachedInputTokens: 208218, cacheWriteTokens: 22450, outputTokens: 2450 } };
describe("complete Anthropic receipt pricing", () => {
it("prices Opus 5.5 cache writes at the one-hour upper rate", () => {
const priced = priceAnthropicReceipt({ ...receipt, model: "claude-opus-5-5" });
expect(priced.costUsdExact).toBe("0.270291600");
expect(priced.pricingProvenance).toMatchObject({ version: "anthropic-standard-2026-10-12", inputCentsPerMillion: "400", cachedInputCentsPerMillion: "20", cacheWriteCentsPerMillion: "800", outputCentsPerMillion: "2000" });
expect(priced.costStatus).toBe("estimated");
});
it("prices complete counters conservatively and labels their provenance as an estimate", () => {
const priced = priceAnthropicReceipt(receipt);
expect(priced.costUsdExact).toBe("0.155967600");
@@ -17,7 +23,7 @@ describe("complete Anthropic receipt pricing", () => {
});
it.each([
{ complete: false }, { billingType: "unknown" }, { billingType: "subscription_included" },
{ provider: "unknown" }, { biller: "aws_bedrock" }, { model: "claude-sonnet-latest" },
{ provider: "unknown" }, { biller: "aws_bedrock" }, { model: "claude-sonnet-latest" }, { model: "constructor" }, { model: "toString" }, { model: "__proto__" },
{ usageBasis: "session_cumulative" }, { costUsd: 0.12 }, { costUsdExact: "0.12" }, { cacheAdjustedCostUsd: 0.12 },
{ pricingContext: { serviceTier: "priority" } }, { usageByModel: [{ model: "other", usage: { inputTokens: 1, outputTokens: 1 }, costUsd: 1 }] },
{ usage: undefined }, { usage: { inputTokens: 1, outputTokens: 1 } },
+12 -8
View File
@@ -1,31 +1,35 @@
import { centsToUsd, unitsToCents, usdToUnits } from "@paperclipai/shared";
import type { AdapterUsageCheckpoint } from "@paperclipai/adapter-utils";
// Direct Anthropic API list prices, verified 2026-10-07:
// https://platform.claude.com/docs/en/about-claude/pricing
// Keep this bounded to the qualified model; aliases and other billers stay unknown.
const RATES = ["2", "0.2", "4", "10"] as const;
const RATE_CARDS: Record<string, { rates: readonly string[]; version: string }> = {
"claude-sonnet-5": { rates: ["2", "0.2", "4", "10"], version: "anthropic-standard-2026-10-07" },
"claude-opus-5-5": { rates: ["4", "0.2", "8", "20"], version: "anthropic-standard-2026-10-12" },
};
/** A complete token receipt can receive an explicit estimate, never an invoice
* claim. ACP aggregates cache writes without TTL; conservatively price them at
* the documented one-hour rate rather than silently assume five-minute writes. */
export function priceAnthropicReceipt(receipt: AdapterUsageCheckpoint): AdapterUsageCheckpoint {
const card = receipt.model && Object.hasOwn(RATE_CARDS, receipt.model) ? RATE_CARDS[receipt.model] : undefined;
if (!receipt.complete || !receipt.usage || receipt.usageBasis !== "per_run"
|| receipt.provider !== "anthropic" || receipt.biller !== "anthropic"
|| !["api", "metered_api"].includes(receipt.billingType ?? "")
|| receipt.model !== "claude-sonnet-5" || receipt.usageByModel?.length
|| !card || receipt.usageByModel?.length
|| receipt.costUsd != null || receipt.costUsdExact != null || receipt.cacheAdjustedCostUsd != null
|| (receipt.pricingContext?.serviceTier && !["standard", "default"].includes(receipt.pricingContext.serviceTier))) return receipt;
const usage = receipt.usage;
if (usage.cacheWriteTokens === undefined || usage.cachedInputTokens === undefined) return receipt;
const counts = [usage.inputTokens - usage.cacheWriteTokens, usage.cachedInputTokens, usage.cacheWriteTokens, usage.outputTokens];
if (counts.some(count => !Number.isSafeInteger(count) || count < 0)) return receipt;
const numerator = counts.reduce((total, count, index) => total + BigInt(count) * usdToUnits(RATES[index]), 0n);
const numerator = counts.reduce((total, count, index) => total + BigInt(count) * usdToUnits(card.rates[index]), 0n);
return { ...receipt, costUsdExact: centsToUsd(unitsToCents((numerator + 500_000n) / 1_000_000n)), costStatus: "estimated",
pricingProvenance: {
source: "rate_card", version: "anthropic-standard-2026-10-07",
source: "rate_card", version: card.version,
evidence: "https://platform.claude.com/docs/en/about-claude/pricing; standard global API pricing assumed; cache-write TTL unavailable, one-hour upper rate used; not an invoice",
inputCentsPerMillion: "200", cachedInputCentsPerMillion: "20", cacheWriteCentsPerMillion: "400", outputCentsPerMillion: "1000", serviceTier: "standard",
inputCentsPerMillion: (usdToUnits(card.rates[0]) / 10_000_000n).toString(),
cachedInputCentsPerMillion: (usdToUnits(card.rates[1]) / 10_000_000n).toString(),
cacheWriteCentsPerMillion: (usdToUnits(card.rates[2]) / 10_000_000n).toString(),
outputCentsPerMillion: (usdToUnits(card.rates[3]) / 10_000_000n).toString(), serviceTier: "standard",
},
};
}
+5 -4
View File
@@ -1761,11 +1761,12 @@ export function builtInAgentService(db: Db) {
const adapterType = resolvedInput.adapterType ?? existing.adapterType;
const adapterConfig = resolvedInput.adapterConfig ?? existing.adapterConfig;
assertAdapterAllowed(definition, adapterType, adapterConfig);
const sameCodexHarness = agentHarnessType(adapterType, adapterConfig) === "codex_local"
&& agentHarnessType(existing.adapterType, existing.adapterConfig) === "codex_local";
const runner = resolvedInput.runner ?? (sameCodexHarness && existing.metadata?.paperclipBuiltInSetupRequired !== true
const selectedHarness = agentHarnessType(adapterType, adapterConfig);
const sameQualifiedHarness = ["codex_local", "claude_local"].includes(selectedHarness)
&& agentHarnessType(existing.adapterType, existing.adapterConfig) === selectedHarness;
const runner = resolvedInput.runner ?? (sameQualifiedHarness && existing.metadata?.paperclipBuiltInSetupRequired !== true
? agentRunner(existing.adapterType) : undefined);
const retainedNative = sameCodexHarness && runner === "paperclip" && existing.adapterType === "paperclip_runner";
const retainedNative = sameQualifiedHarness && runner === "paperclip" && existing.adapterType === "paperclip_runner";
const resolvedAdapterConfig = { ...adapterConfig };
if (retainedNative) {
for (const field of ["provider", "acpxAgent", "acpxMode"]) {
+2 -2
View File
@@ -5307,7 +5307,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
"OpenAI Dot is experimental and disabled on this instance.",
{ code: "paperclip_runner_dot_disabled" },
);
} else if (config.provider !== "codex" && config.provider !== undefined && experimental.enableNativeRunner !== true) {
} else if (config.provider !== "codex" && config.provider !== undefined && agentHarnessType("paperclip_runner", config) !== "claude_local" && experimental.enableNativeRunner !== true) {
throw unprocessable(
"Paperclip Runner is experimental and disabled on this instance.",
{ code: "paperclip_runner_rollout_disabled" },
@@ -5651,7 +5651,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
runner = agentRunner(existingImportAgent.adapterType);
}
}
const retainedNative = runner === "paperclip" && selectedHarness === "codex_local"
const retainedNative = runner === "paperclip" && ["codex_local", "claude_local"].includes(selectedHarness)
? [manifestAgent, existingImportAgent].find((agent) => agent?.adapterType === "paperclip_runner"
&& agentHarnessType(agent.adapterType, agent.adapterConfig) === selectedHarness)
: undefined;
@@ -117,6 +117,16 @@ describe("Paperclip Runner native provider configuration", () => {
.toMatchObject({ provider: "codex", model: "gpt-6-astra", modelReasoningEffort: "high" });
});
it.each([true, false])("carries Claude task effort while retaining provider and permissions (explicit agent: %s)", explicitAgent => {
const base = { provider: "acpx", ...(explicitAgent ? { acpxAgent: "claude" } : {}), model: "claude-opus-5-5", modelReasoningEffort: "medium", acpxPermissionMode: "deny-all" };
const projected = projectPaperclipRunnerTaskConfig("acpx_runtime", base, { effort: "xhigh", provider: "codex", acpxAgent: "pi", acpxPermissionMode: "approve-all" });
expect(projected).toEqual({ ...base, modelReasoningEffort: "xhigh" });
expect(resolvePaperclipRunnerNativeProviderInput({ backend: "acpx_runtime", adapterConfig: projected })).toMatchObject({ provider: "acpx", acpxAgent: "claude", claudeEffort: "xhigh", acpxPermissionMode: "deny-all" });
const changedModel = projectPaperclipRunnerTaskConfig("acpx_runtime", base, { model: "claude-sonnet-5" });
expect(changedModel).not.toHaveProperty("modelReasoningEffort");
expect(() => resolvePaperclipRunnerNativeProviderInput({ backend: "acpx_runtime", adapterConfig: { ...base, chrome: true } })).toThrow("Legacy runner");
});
it("uses an OpenCode task model without changing the Runner provider", () => {
const projected = projectPaperclipRunnerTaskConfig(
"opencode_server",
@@ -21,7 +21,7 @@ import type { agentInstructionWorkingCopyService } from "../agent-instruction-wo
import { createHash, randomUUID } from "node:crypto";
import { and, eq, inArray, sql } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import { CHAT_PROVIDERS } from "@paperclipai/shared";
import { agentHarnessType, CHAT_PROVIDERS } from "@paperclipai/shared";
import {
type agents,
completionContracts,
@@ -716,6 +716,7 @@ export async function selectHeartbeatRuntime(db: Db, input: HeartbeatRuntimeSele
});
getNativeFreshSessionHandoff = nativeReviewRequest ? undefined : getFreshSessionHandoff;
const nativeProviderConfig = nativeRuntimeResolution.profile.backend === "codex_app_server"
|| (nativeRuntimeResolution.profile.backend === "acpx_runtime" && agentHarnessType(agent.adapterType, parseObject(agent.adapterConfig)) === "claude_local")
|| nativeRuntimeResolution.profile.backend === "opencode_server"
? projectPaperclipRunnerTaskConfig(
nativeRuntimeResolution.profile.backend,
@@ -72,7 +72,7 @@ describe("host ACPX qualification admission", () => {
}
});
it("preserves product defaults separately from qualification examples", () => {
for (const [acpxAgent, model] of [["claude", "claude-sonnet-5"], ["grok", "grok-4.7"]]) {
for (const [acpxAgent, model] of [["claude", "claude-opus-5-5"], ["grok", "grok-4.7"]]) {
expect(resolvePaperclipRunnerProviderProfile({ provider: "acpx", acpxAgent }))
.toMatchObject({ acpxAgent, model });
}
@@ -664,3 +664,27 @@ describe("Pi thinking native execution projection", () => {
expect(() => buildNativeExecutionInput({ ...fixture("low"), acpxAgent: "copilot" })).toThrow("only for Pi");
});
});
describe("Claude effort and master home-mode compatibility", () => {
it.each([
["isolated", undefined, "v5"], ["preconfigured", undefined, "v7"],
["isolated", "high", "v9"], ["preconfigured", "high", "v9"],
] as const)("retains %s home mode with effort %s in %s", (homeMode, claudeEffort, version) => {
const value = buildNativeExecutionInput({
companyId: "company", runId: "run", agentId: "agent",
issue: { id: "issue", identifier: "CLA-1", title: "Useful output", description: null, workMode: "standard" },
taskPrompt: "Create the requested file.",
workspace: { id: "workspace", cwd: "/workspace", repoUrl: null, repoRef: null, branchName: null },
normalizedSessionId: null, provider: "acpx", acpxAgent: "claude", model: "claude-opus-5-5",
acpxPermissionMode: "approve-all", homeMode, claudeEffort,
completionContract: { id: "contract", sha256: `sha256:${"a".repeat(64)}`, schemaVersion: "paperclip.run-result.v1",
contract: { revision: "1", objective: "Create a file", criteria: [{ id: "output", requirement: "Create the file" }] } },
runtimeContext: nativeRuntimeContextFixture(),
});
expect(value.schema).toBe(`paperclip.native-execution-input.${version}`);
if (version !== "v5") expect(value).toHaveProperty("homeMode", homeMode);
if (claudeEffort) expect(value.provider).toHaveProperty("claudeEffort", claudeEffort);
expect(JSON.stringify(buildNativeModelEnvelope(value))).not.toContain("claudeEffort");
});
});
@@ -9,6 +9,7 @@ import type {
NativeExecutionInputV5,
NativeExecutionInputV6,
NativeExecutionInputV8,
NativeExecutionInputV9,
MuseBindingSnapshot,
DotBindingSnapshot,
NativeExecutionInputV7,
@@ -85,6 +86,7 @@ export interface BuildNativeExecutionInput {
acpxPermissionMode?: NativeAcpxPermissionMode;
acpxSessionMode?: "agent" | "plan" | "ask";
piThinkingLevel?: "off" | "low" | "high" | "max";
claudeEffort?: "low" | "medium" | "high" | "xhigh" | "max";
model?: string | null;
managedProfile?: Extract<
NativeExecutionInputV5["provider"],
@@ -115,9 +117,9 @@ export interface BuildNativeExecutionInput {
}
export function buildNativeExecutionInput(input: BuildNativeExecutionInput & { provider: "muse"; museBinding: MuseBindingSnapshot }): NativeExecutionInputV8;
export function buildNativeExecutionInput(input: BuildNativeExecutionInput & { provider: "openai_dot" | "grok_bot"; dotBinding: DotBindingSnapshot }): NativeExecutionInputV6;
export function buildNativeExecutionInput(input: BuildNativeExecutionInput & { provider?: Exclude<BuildNativeExecutionInput["provider"], "openai_dot" | "grok_bot" | "muse"> }): NativeExecutionInputV5 | NativeExecutionInputV7;
export function buildNativeExecutionInput(input: BuildNativeExecutionInput): NativeExecutionInputV5 | NativeExecutionInputV6 | NativeExecutionInputV7 | NativeExecutionInputV8;
export function buildNativeExecutionInput(input: BuildNativeExecutionInput): NativeExecutionInputV5 | NativeExecutionInputV6 | NativeExecutionInputV7 | NativeExecutionInputV8 {
export function buildNativeExecutionInput(input: BuildNativeExecutionInput & { provider?: Exclude<BuildNativeExecutionInput["provider"], "openai_dot" | "grok_bot" | "muse"> }): NativeExecutionInputV5 | NativeExecutionInputV7 | NativeExecutionInputV9;
export function buildNativeExecutionInput(input: BuildNativeExecutionInput): NativeExecutionInputV5 | NativeExecutionInputV6 | NativeExecutionInputV7 | NativeExecutionInputV8 | NativeExecutionInputV9;
export function buildNativeExecutionInput(input: BuildNativeExecutionInput): NativeExecutionInputV5 | NativeExecutionInputV6 | NativeExecutionInputV7 | NativeExecutionInputV8 | NativeExecutionInputV9 {
if (input.issue.workMode !== "standard" && input.issue.workMode !== "planning" && input.issue.workMode !== "ask") {
throw new Error("native_execution_input_invalid: issue work mode must be standard, planning, or ask");
}
@@ -217,8 +219,9 @@ export function buildNativeExecutionInput(input: BuildNativeExecutionInput): Nat
? verifiedCompletionSources(input.turnContext, input.completionContract.sources ?? [])
: [];
const prepared = {
schema: input.homeMode === "preconfigured" ? "paperclip.native-execution-input.v7" : "paperclip.native-execution-input.v5",
...(input.homeMode === "preconfigured" ? { homeMode: input.homeMode } : {}),
schema: input.claudeEffort !== undefined ? "paperclip.native-execution-input.v9"
: input.homeMode === "preconfigured" ? "paperclip.native-execution-input.v7" : "paperclip.native-execution-input.v5",
...(input.claudeEffort !== undefined || input.homeMode === "preconfigured" ? { homeMode: input.homeMode ?? "isolated" } : {}),
...((input.initialCommunicationGuidance || input.freshSessionHandoff) ? {
initialCommunicationGuidance: [input.initialCommunicationGuidance, input.freshSessionHandoff].filter(Boolean).join("\n\n"),
} : {}),
@@ -297,6 +300,7 @@ export function buildNativeExecutionInput(input: BuildNativeExecutionInput): Nat
permissionMode: input.acpxPermissionMode ?? "approve-all",
...(mode === undefined ? {} : { mode }),
...(piThinkingLevel === undefined ? {} : { piThinkingLevel }),
...(input.claudeEffort === undefined ? {} : { claudeEffort: input.claudeEffort }),
profile: {
driverKind: acpxProfile!.driverKind,
protocolVersion: acpxProfile!.protocolVersion,
@@ -345,7 +349,7 @@ export function buildNativeExecutionInput(input: BuildNativeExecutionInput): Nat
schema: "paperclip.native-execution-input.v6", provider: { kind: input.provider, model: null, binding: input.dotBinding },
workspace: { access: "none", cwd: null, repoUrl: null, repoRef: null, branchName: null },
session: { ...prepared.session, driverKind: input.provider === "grok_bot" ? "grok_bot_mcp" : "openai_dot_mcp", lifecyclePolicy: { mode: "per_turn", idleTimeoutMs: null } }
} : prepared) as NativeExecutionInputV5 | NativeExecutionInputV6 | NativeExecutionInputV7 | NativeExecutionInputV8;
} : prepared) as NativeExecutionInputV5 | NativeExecutionInputV6 | NativeExecutionInputV7 | NativeExecutionInputV8 | NativeExecutionInputV9;
}
@@ -10235,6 +10235,20 @@ describe("runnerd provider runtime wiring", () => {
await expect(readFile(authPath, "utf8")).resolves.toBe(auth);
});
it.each(["isolated", "preconfigured"] as const)("pins Claude v9 permissions and effort at daemon startup in %s mode", async homeMode => {
const current = { ...execution, schema: "paperclip.native-execution-input.v9", homeMode,
provider: { kind: "acpx", agent: "claude", model: "claude-opus-5-5", permissionMode: "approve-all", claudeEffort: "high" },
session: { ...execution.session, driverKind: "acpx_runtime" },
} as unknown as NativeExecutionInput;
await createRunnerdBackend({ db: leaseDb(current), execution: current,
runnerInstanceId: `claude-v9-pinned-${homeMode}` });
state.createBackend.mock.calls.at(-1)![1].codexTransportFactory!();
expect(state.createTransport.mock.calls.at(-1)![0]).toMatchObject({
provider: "acpx", acpxAgent: "claude", acpxPermissionMode: "approve-all",
acpxPermissionModePinned: true, claudeEffort: "high", homeMode,
});
});
it("copies back and removes Grok credentials from the exact ACPX launch home", async () => {
const priorHome = process.env.PAPERCLIP_HOME;
const privateRoot = await realpath(isolatedStateDirectory);
@@ -5638,6 +5638,7 @@ export function providerSessionIdentityFromDurableProviderState(input: {
identity.permissionMode !== input.execution.provider.permissionMode ||
!acpxRecoveryModeMatches(input.execution.provider, descriptor.mode, identity.mode) ||
!acpxRecoveryPiThinkingMatches(input.execution.provider, descriptor.piThinkingLevel, identity.piThinkingLevel) ||
!acpxRecoveryClaudeEffortMatches(input.execution.provider, descriptor.claudeEffort, identity.claudeEffort) ||
!["approve-all", "approve-paperclip", "approve-reads", "deny-all"].includes(
String(identity.permissionMode),
) ||
@@ -5745,6 +5746,13 @@ function acpxRecoveryModeMatches(
&& observedModes.every(mode => mode === expected);
}
function acpxRecoveryClaudeEffortMatches(provider: unknown, projected: unknown, observed: unknown): boolean {
const config = record(provider);
const expected = config.claudeEffort;
if (config.kind !== "acpx" || config.agent !== "claude") return expected === undefined && projected === undefined && observed === undefined;
return expected === projected && expected === observed && (expected === undefined || ["low", "medium", "high", "xhigh", "max"].includes(String(expected)));
}
function acpxRecoveryPiThinkingMatches(provider: NativeExecutionInput["provider"], ...observed: unknown[]): boolean {
const expected = record(provider).piThinkingLevel;
if (provider.kind === "acpx" && provider.agent === "pi") return ["off", "low", "high", "max"].includes(String(expected)) && observed.every(value => value === expected);
@@ -5762,6 +5770,7 @@ export function providerSessionIdentityTransitionIsAllowed(input: {
record(record(input.current).providerSessionIdentity).mode,
)) return false;
if (!acpxRecoveryPiThinkingMatches(input.execution.provider, record(record(input.previous).providerSessionIdentity).piThinkingLevel, record(record(input.current).providerSessionIdentity).piThinkingLevel)) return false;
if (!acpxRecoveryClaudeEffortMatches(input.execution.provider, record(record(input.previous).providerSessionIdentity).claudeEffort, record(record(input.current).providerSessionIdentity).claudeEffort)) return false;
if (canonicalJson(input.previous) === canonicalJson(input.current)) {
return true;
}
@@ -13001,10 +13010,12 @@ async function createRunnerdBackendWithinSessionClaim(
acpxPermissionMode: input.execution.provider.permissionMode,
acpxMode: input.execution.provider.mode,
piThinkingLevel: input.execution.provider.piThinkingLevel,
claudeEffort: "claudeEffort" in input.execution.provider ? input.execution.provider.claudeEffort : undefined,
acpxPermissionModePinned:
input.execution.schema === "paperclip.native-execution-input.v4" ||
input.execution.schema === "paperclip.native-execution-input.v5" ||
input.execution.schema === "paperclip.native-execution-input.v7",
input.execution.schema === "paperclip.native-execution-input.v7" ||
input.execution.schema === "paperclip.native-execution-input.v9",
acpxRuntimeDirectory: remoteRunnerFilesystemRoot
? posix.join(remoteRunnerFilesystemRoot, "acpx")
: resolve(
@@ -1,4 +1,5 @@
import { resolveQualifiedAcpxProfile } from "../../vendor/paperclip-runner/index.js";
import { agentHarnessType } from "@paperclipai/shared";
import { resolveClaudeEffort, resolveQualifiedAcpxProfile } from "../../vendor/paperclip-runner/index.js";
import {
isPaperclipRunnerProvider,
PAPERCLIP_RUNNER_PERMISSION_CAPABILITIES,
@@ -26,7 +27,7 @@ export const CLAUDE_MANAGED_BETA_VERSION = "managed-agents-2026-04-01" as const;
export const DEFAULT_ACPX_RUNNER_MODELS = {
grok: "grok-4.7",
claude: "claude-sonnet-5",
claude: "claude-opus-5-5",
// These profiles require explicit configuration. Admission belongs to the runner.
codex: null,
cursor: null,
@@ -136,6 +137,7 @@ export type PaperclipRunnerNativeProviderInput =
acpxPermissionMode: "approve-all" | "approve-paperclip" | "approve-reads" | "deny-all";
acpxSessionMode?: "agent" | "plan" | "ask";
piThinkingLevel?: "off" | "low" | "high" | "max";
claudeEffort?: "low" | "medium" | "high" | "xhigh" | "max";
};
export class PaperclipRunnerProviderProfileError extends Error {
@@ -195,7 +197,7 @@ export function validatePaperclipRunnerGrokConfig(config: Record<string, unknown
/** A task may change a local Runner model (and Codex effort), but not provider identity. */
export function projectPaperclipRunnerTaskConfig(
backend: "codex_app_server" | "opencode_server",
backend: "codex_app_server" | "opencode_server" | "acpx_runtime",
agentConfig: unknown,
taskOverrides: unknown,
managedModel?: string,
@@ -206,10 +208,11 @@ export function projectPaperclipRunnerTaskConfig(
// The server resolves a connection's model namespace after task overrides.
// Carry that model into durable input without allowing routing to change the harness.
const model = optionalString(managedModel) ?? optionalString(task.model);
const effortKey = backend === "codex_app_server"
const supportsEffort = backend === "codex_app_server" || (backend === "acpx_runtime" && agentHarnessType("paperclip_runner", base) === "claude_local");
const effortKey = supportsEffort
? ["modelReasoningEffort", "reasoningEffort", "effort"].find((key) => key in task)
: undefined;
if (backend === "codex_app_server" && ((model !== null && model !== optionalString(base.model)) || effortKey !== undefined)) {
if (supportsEffort && ((model !== null && model !== optionalString(base.model)) || effortKey !== undefined)) {
delete config.modelReasoningEffort;
delete config.reasoningEffort;
delete config.effort;
@@ -380,6 +383,19 @@ export function assertAgentCoreProfileRecoveryBinding(input: {
}
}
export function claudeRunnerUnsupportedSettings(config: Record<string, unknown>): string[] {
const unsupported = ["chrome", "agentCommand", "acpAgentCommand", "stateDir", "acpStateDir", "warmHandleIdleMs", "acpWarmHandleIdleMs", "extraArgs", "args", "filesystemScope", "filesystemExtraPaths", "filesystemSandboxCommand", "networkScope", "networkAllowlist", "maxTurnsPerRun", "permissionMode", "acpPermissionMode"]
.filter(key => config[key] !== undefined && config[key] !== false && config[key] !== "" && config[key] !== 0 && !(Array.isArray(config[key]) && config[key].length === 0));
if (config.command && config.command !== "claude") unsupported.push("command");
if (config.mode && config.mode !== "persistent") unsupported.push("mode");
if (config.acpMode && config.acpMode !== "persistent") unsupported.push("acpMode");
if (config.engine && !["auto", "cli", "acp"].includes(String(config.engine))) unsupported.push("engine");
if (config.nonInteractivePermissions && config.nonInteractivePermissions !== "deny") unsupported.push("nonInteractivePermissions");
if (config.acpNonInteractivePermissions && config.acpNonInteractivePermissions !== "deny") unsupported.push("acpNonInteractivePermissions");
if (config.dangerouslySkipPermissions === false) unsupported.push("dangerouslySkipPermissions");
return unsupported;
}
/**
* Resolve the immutable provider identity used for a fresh Paperclip Runner
* selection. The persisted adapterConfig is the authority; runtimeConfig is
@@ -409,6 +425,11 @@ export function resolvePaperclipRunnerProviderProfile(
const bindingId = validatePaperclipRunnerDotConfig(config)!;
return { provider: "openai_dot", backend: "openai_dot_mcp", model: null, dotBindingId: bindingId };
}
if (candidate === "acpx" && agentHarnessType("paperclip_runner", config) === "claude_local") {
const unsupported = claudeRunnerUnsupportedSettings(config);
if (unsupported.length) throw new PaperclipRunnerProviderProfileError("agent_runner_config_incompatible",
`Paperclip Runner cannot honor these settings: ${unsupported.join(", ")}. Remove them or select Legacy runner in Advanced.`);
}
assertPermissionMode(candidate, config);
try {
resolvePaperclipRunnerCursorMode(candidate, config.acpxAgent, config.acpxSessionMode);
@@ -418,6 +439,9 @@ export function resolvePaperclipRunnerProviderProfile(
error instanceof Error ? error.message : "Invalid Cursor session mode",
);
}
try { if (candidate === "acpx" && agentHarnessType("paperclip_runner", config) === "claude_local") resolveClaudeEffort("claude", config.modelReasoningEffort ?? config.reasoningEffort ?? config.effort); } catch (error) {
throw new PaperclipRunnerProviderProfileError("paperclip_runner_claude_effort_invalid", error instanceof Error ? error.message : "Invalid Claude effort");
}
try { resolvePaperclipRunnerPiThinkingLevel(candidate, config.acpxAgent, config.piThinkingLevel); } catch (error) {
throw new PaperclipRunnerProviderProfileError("paperclip_runner_pi_thinking_invalid", error instanceof Error ? error.message : "Invalid Pi thinking level");
}
@@ -630,6 +654,7 @@ export function resolvePaperclipRunnerNativeProviderInput(input: {
provider: "acpx",
model: profile.model,
acpxAgent: profile.acpxAgent,
...(profile.acpxAgent === "claude" ? { claudeEffort: resolveClaudeEffort("claude", config.modelReasoningEffort ?? config.reasoningEffort ?? config.effort) } : {}),
...(profile.acpxAgent === "pi" ? { piThinkingLevel: resolvePaperclipRunnerPiThinkingLevel("acpx", "pi", config.piThinkingLevel) } : {}),
...(profile.acpxAgent === "cursor" ? {
acpxSessionMode: resolvePaperclipRunnerCursorMode("acpx", "cursor", config.acpxSessionMode),
@@ -501,6 +501,10 @@ describe("resolveHeartbeatRuntimeMode compatibility", () => {
adapterConfig: { provider: "opencode", model: "opencode/gpt-5" },
})).toThrow(NativeRunnerSelectionError);
});
it("admits native Claude without enabling other ACPX providers", () => {
expect(resolveNativeRuntimeMode({ ...eligible, enabled: false, adapterConfig: { provider: "acpx", acpxAgent: "claude" } })).toMatchObject({ kind: "native", profile: { backend: "acpx_runtime" } });
expect(() => resolveNativeRuntimeMode({ ...eligible, enabled: false, adapterConfig: { provider: "acpx", acpxAgent: "grok" } })).toThrow(expect.objectContaining({ code: "paperclip_runner_rollout_disabled" }));
});
});
@@ -138,7 +138,7 @@ export function resolveNativeRuntimeMode(input: {
if (input.dotEnabled !== true) throw ineligible("paperclip_runner_dot_disabled", "Enable OpenAI Dot and Assistant connections (MCP) in experimental settings before assigning new work.");
} else if (runnerProfile.provider === "grok_bot" && (input.grokEnabled !== true || !input.enabled)) {
throw ineligible("paperclip_runner_grok_disabled", "Enable Grok Bot, Paperclip Runner, and Assistant connections (MCP) before assigning new work.");
} else if (runnerProfile.provider !== "codex" && !input.enabled) {
} else if (runnerProfile.provider !== "codex" && !(runnerProfile.provider === "acpx" && runnerProfile.acpxAgent === "claude") && !input.enabled) {
throw ineligible(
"paperclip_runner_rollout_disabled",
"Paperclip Runner is experimental and disabled on this instance.",
+2 -1
View File
@@ -36,6 +36,7 @@ import type {
SecretVersionSelector,
} from "@paperclipai/shared";
import {
agentHarnessType,
CLASS3_STATIC_LEASE_ALLOWLIST,
createSecretProviderConfigSchema,
deriveProjectUrlKey,
@@ -279,7 +280,7 @@ export interface ClaudeOAuthBindingInvariantDecision {
export function assertClaudeOAuthBindingInvariant(
input: ClaudeOAuthBindingInvariantInput,
): ClaudeOAuthBindingInvariantDecision {
const isClaudeLocal = input.adapterType === CLAUDE_LOCAL_ADAPTER_TYPE;
const isClaudeLocal = agentHarnessType(input.adapterType ?? "", asRecord(input.nextConfig) ?? {}) === CLAUDE_LOCAL_ADAPTER_TYPE;
const nextIsFixed = hasFixedClaudeOAuthBinding(input.nextConfig);
const priorIsFixed = hasFixedClaudeOAuthBinding(input.priorConfig);
+2
View File
@@ -37,6 +37,7 @@ export type {
NativeExecutionInputV5,
NativeExecutionInputV6,
NativeExecutionInputV8,
NativeExecutionInputV9,
MuseBindingSnapshot,
DotBindingSnapshot,
ExternalProviderPort,
@@ -137,6 +138,7 @@ export const parsePaperclipQuestionSet = runner.parsePaperclipQuestionSet;
export const parsePaperclipQuestionResponse =
runner.parsePaperclipQuestionResponse;
export const resolveQualifiedAcpxProfile = runner.resolveQualifiedAcpxProfile;
export const resolveClaudeEffort = runner.resolveClaudeEffort;
export const QUALIFIED_ACPX_PROFILES = runner.QUALIFIED_ACPX_PROFILES;
export const QUALIFIED_ACPX_VERSION = runner.QUALIFIED_ACPX_VERSION;
export const CURSOR_DISTRIBUTION_PINS = runner.CURSOR_DISTRIBUTION_PINS;
+9 -4
View File
@@ -191,13 +191,18 @@ For each linked issue, either:
Choose the underlying harness in `adapterType`, such as `codex_local` or
`claude_local`. New Codex agents use Paperclip Runner automatically on supported
execution targets. Other harnesses retain their existing adapters. The saved
response may therefore have `adapterType: paperclip_runner` and `provider: codex`;
execution targets. New Claude Code agents use Paperclip Runner on Apple Silicon;
other Claude targets retain Legacy until qualified. The native Claude profile is
`provider: acpx`, `acpxAgent: claude`, with ordinary model `claude-opus-5-5`.
Explicit models and existing saved runners remain intact. The saved response
may therefore have `adapterType: paperclip_runner` and a resolved provider;
use that returned configuration for later reads and edits.
The optional creation field `runner` accepts `auto`, `paperclip`, or `legacy`.
Use `legacy` when the user needs custom Codex CLI settings the native runtime
cannot honor. Setup and authentication errors never silently change runners.
Use `legacy` when the user needs CLI settings the native runtime cannot honor.
For Claude this includes Chrome, custom commands/state paths, warm-process
controls, confinement and turn limits. Native Claude uses existing API-key and
subscription connections. Its configured effort requires provider acknowledgment. Setup and authentication errors never silently change runners.
Existing agents keep their saved execution after unrelated edits. An approved
hire uses the configuration reviewed by the board. `inheritRuntimeFrom: caller`
keeps its existing runtime inheritance and credential restrictions.
+1 -1
View File
@@ -18,7 +18,7 @@ for (const { unfinishedWrite, stopResponse } of [{ unfinishedWrite: false, stopR
try {
await json(await request.patch("/api/instance/settings/experimental", { data: { enableClassicTaskInterface: false } }));
const owner = await json(await request.post(`/api/companies/${company.id}/agents`, { data: {
name: "ACP Stop fixture", role: "engineer", adapterType: "claude_local",
name: "ACP Stop fixture", role: "engineer", adapterType: "claude_local", runner: "legacy",
adapterConfig: { engine: "acp", cwd: root, stateDir: path.join(root, "state"),
agentCommand: `${JSON.stringify(process.execPath)} ${JSON.stringify(path.resolve("scripts/mcp-fixtures/servers/acp-stop-agent.mjs"))}`,
// The ACP fixture has no provider login. Use a fixture key to keep setup offline.
@@ -24,7 +24,7 @@ for (const action of ["task_retry", "thread_retry", "inbox_retry", "message", "q
try {
await writeFile(path.join(root, "continued"), "ready");
const agent = await json(await request.post(`/api/companies/${company.id}/agents`, { data: {
name: "Recovery fixture", role: "engineer", adapterType: "claude_local",
name: "Recovery fixture", role: "engineer", adapterType: "claude_local", runner: "legacy",
adapterConfig: { engine: "acp", cwd: root, stateDir: path.join(root, "state"),
agentCommand: `${JSON.stringify(process.execPath)} ${JSON.stringify(path.resolve("scripts/mcp-fixtures/servers/acp-stop-agent.mjs"))}`,
// The ACP fixture has no provider login. Use a fixture key to keep setup offline.
+26 -10
View File
@@ -1086,13 +1086,12 @@ model. Configured and provider-observed model identities are reported separately
There are thirteen cases on `legacy-codex`, `legacy-claude`, `runner-codex`, and
`runner-acpx-claude`, local only (52 cells). `runner-codex` selects OpenAI in the
production wizard, omits the runner override, and verifies the saved native
configuration without changing runners afterward. `legacy-codex` selects
Legacy runner in Advanced before authentication and creation. Both are labeled
`production-wizard`. Claude's creation default is unchanged: `legacy-claude`
retains its wizard configuration, while `runner-acpx-claude` explicitly changes
runtime through the public API before its first task and is labeled
`post-onboarding-runtime-switch`. Persona, managed instructions, skills, seeded
question, and task invocation are preserved. The native path removes the legacy
configuration without changing runners afterward. `runner-acpx-claude` does the
same for Claude on its qualified default targets and inspects the saved ACPX
Claude profile directly. Both Legacy profiles select Legacy runner in Advanced
before authentication and creation. All four paths are labeled
`production-wizard`; there is no post-onboarding runner switch. Persona,
managed instructions, skills, seeded question, and task invocation are preserved. The native path removes the legacy
Paperclip operational skill because Runner supplies that contract through its
protocol; other assigned skills, including `/first-task`, are retained.
Each isolated onboarding company has a $5 task budget. Setup probes have a
@@ -1454,9 +1453,10 @@ prompts, onboarding defaults, or provider permissions are changed.
For native onboarding qualification, select `first-task` with profiles
`runner-codex,runner-acpx-claude` (26 cells). Codex verifies the production
creation default with the experimental flag disabled. Claude still uses the
existing public-API runtime switch before provider work; that case does not
qualify a native Claude creation default.
creation default with the experimental flag disabled. Claude also inspects its
wizard's saved native default with the experimental flag disabled; select that
profile on a qualified default target. Retained results from the former API-switch
definition do not establish that the current creation-default journey passed.
Current proof and remaining decisions are recorded in
[the 21 September qualification report](QUALIFICATION-2026-09-21.md). In particular,
@@ -2222,6 +2222,22 @@ always preserves recovery state regardless of this optional diagnostic flag.
The `file-edit-validate` fixture independently downloads the exact active artifact work product from the tested run. It requires the matching run-attributed attachment, filename, MIME type, recorded byte count and SHA-256, then compares the downloaded content to the expected bytes. A workspace file alone cannot satisfy this gate. Explicit failed-case diagnostic retention follows the final result after integrity, isolation and evidence checks, including incomplete publication.
## Claude default rollout acceptance
The Claude first-task onboarding case asserts the wizard's saved native profile
directly. It must not patch a Legacy agent into native execution after creation.
Ordinary native defaults are qualified by execution target: the first rollout
enables Apple Silicon only. API-key acceptance requires a useful downloadable
file, a follow-up revision, independent downloads of both versions, persisted
configuration and session continuity after reload/restart. Subscription setup
uses the existing connection tools and receives separate task/follow-up proof.
Keep exact source, runtime executable digests and reports for each candidate.
Effort or runtime changes require affected acceptance reruns. No automatic paid
retry campaign is permitted under the separate $50 Claude qualification ceiling.
Docker and expensive Rust builds run in cloud CI, never on the local Mac.
## Projectless task workspaces (explicit-only)
`task-workspaces` contains exactly three cells: legacy OpenCode locally and native
+1 -1
View File
@@ -1466,7 +1466,7 @@ export const runnerSuites: readonly RunnerSuiteFixture[] = [
groups: ["onboarding"],
profiles: runnerProfiles.filter(profile => ["legacy-codex", "legacy-claude", "runner-codex", "runner-acpx-claude"].includes(profile.id)),
environments: [localEnvironment], tasks: firstTaskTasks, expectedMatrixSize: 52,
definitionMetadata: { version: 5, runGrading: "evidenced-nonexecution-and-refusal", credentialPersistenceCheck: false, questionChoiceMinimum: 2, nativeSetup: "codex-production-default-claude-explicit-runtime-switch", productionInstructions: true, qualityGrading: "informational" },
definitionMetadata: { version: 7, runGrading: "evidenced-nonexecution-and-refusal", credentialPersistenceCheck: false, questionChoiceMinimum: 2, nativeSetup: "qualified-platform-wizard-saved-default", claudeDefaultTarget: "darwin-arm64", legacySetup: "explicit-advanced-runner-choice", productionInstructions: true, qualityGrading: "informational" },
},
{
id: "agent-chat", label: "Persistent Agent Chat",
+5 -30
View File
@@ -8,7 +8,6 @@ import { captureFirstTaskAttachments } from "./first-task-attachments.js";
import { waitForFirstTaskReply } from "./first-task-replies.js";
import { observeCompletionUpdate } from "./completion-update-flow.js";
import {
firstTaskNativeRuntimePatch,
provisionFirstTaskFixtures,
isManagedFirstTask,
startFirstTaskWithRuntimeBound,
@@ -329,7 +328,7 @@ export async function setupFirstTaskFixtures(input: {
exact: true,
});
const savedKey = page.getByRole("combobox", { name: "Saved API key" });
if (execution.profile.id === "legacy-codex") {
if (["legacy-codex", "legacy-claude"].includes(execution.profile.id)) {
await page.getByText("Advanced", { exact: true }).click();
await page.getByRole("button", { name: "Runner", exact: true }).click();
await page.getByRole("option", { name: "Legacy runner", exact: true }).click();
@@ -359,7 +358,7 @@ export async function setupFirstTaskFixtures(input: {
const wizardAdapter =
execution.profile.credential === "OPENAI_API_KEY"
? execution.profile.id === "runner-codex" ? "paperclip_runner" : "codex_local"
: "claude_local";
: execution.profile.id === "runner-acpx-claude" ? "paperclip_runner" : "claude_local";
expect(agents[0].adapterType).toBe(wizardAdapter);
if (execution.profile.id === "runner-codex") {
expect(agents[0].adapterConfig?.provider).toBe("codex");
@@ -371,33 +370,9 @@ export async function setupFirstTaskFixtures(input: {
originalModel: agents[0].adapterConfig?.model ?? null,
};
if (execution.profile.id === "runner-acpx-claude") {
const runtimePatch = firstTaskNativeRuntimePatch(
execution,
fixtures,
agents[0],
);
const migrated = await api.patch<Row>(
`/api/agents/${agents[0].id}`,
runtimePatch,
);
expect(migrated.adapterType).toBe("paperclip_runner");
expect(migrated.adapterConfig?.provider).toBe(execution.profile.provider);
expect(migrated.adapterConfig?.instructionsFilePath).toBe(
agents[0].adapterConfig?.instructionsFilePath,
);
expect(migrated.adapterConfig?.paperclipSkillSync?.desiredSkills).toEqual(
(
runtimePatch.adapterConfig.paperclipSkillSync as {
desiredSkills?: unknown[];
}
)?.desiredSkills,
);
fixtures.onboardingRuntime = {
mode: "post-onboarding-runtime-switch",
originalAdapterType: wizardAdapter,
testedAdapterType: migrated.adapterType,
originalModel: agents[0].adapterConfig?.model ?? null,
};
expect(agents[0].adapterConfig?.provider).toBe("acpx");
expect(agents[0].adapterConfig?.acpxAgent).toBe("claude");
expect(agents[0].adapterConfig?.model).toBe("claude-opus-5-5");
}
fixtures.agent = {
id: agents[0].id,
+11 -2
View File
@@ -3,13 +3,17 @@ import { createHash } from "node:crypto";
import { chmod, lstat, mkdtemp, mkdir, readFile, realpath, rm, symlink, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { afterEach, expect, it } from "vitest";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { runnerMatrix } from "./catalog.js";
import { assertInstalledCliSelection, installedCliKeys, probeInstalledCodexStartup, verifyInstalledCli } from "./installed-cli.js";
import { buildPaperclipServerEnvironment, buildRunnerE2EProcessEnvironment } from "./harness-env.js";
const cells = runnerMatrix.filter(e => e.profile.id === "runner-acpx-pi");
const roots: string[] = [];
afterEach(async () => { await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))); });
beforeEach(() => {
vi.stubEnv("NODE_PATH", undefined);
vi.stubEnv("NODE_OPTIONS", undefined);
});
afterEach(async () => { vi.unstubAllEnvs(); await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))); });
const hash = (value: string) => createHash("sha256").update(value).digest("hex");
async function fixture() {
const root = await realpath(await mkdtemp(join(tmpdir(), "e2e-installed-cli-"))); roots.push(root);
@@ -187,3 +191,8 @@ it.skipIf(process.platform === "win32")("installed startup rejects stale daemon/
await expect(probeInstalledCodexStartup({ consumerRoot: wrong.root, serverRoot: wrong.server, expectedRunnerSha256: wrong.expectedRunnerSha256 })).rejects.toThrow("matching native identity");
expect(await readFile(wrong.observations, "utf8").then(JSON.parse)).toMatchObject({ calls: ["open", "session-close"] });
});
it("installed startup rejects ambient Node injection before launching", async () => {
vi.stubEnv("NODE_PATH", "/untrusted/test-path");
await expect(probeInstalledCodexStartup({ consumerRoot: "/unused", serverRoot: "/unused", expectedRunnerSha256: "0".repeat(64) })).rejects.toThrow("forbids NODE_PATH");
});
+3
View File
@@ -563,6 +563,9 @@ for (const execution of executions) {
page,
request,
}, testInfo) => {
test.skip(execution.suite.id === "first-task" && execution.profile.id === "runner-acpx-claude"
&& (process.platform !== "darwin" || process.arch !== "arm64"),
"Ordinary native Claude onboarding is qualified on Apple Silicon only in this delivery.");
test.setTimeout(
execution.task.flow === "warm_three_turn"
? deadlineMs
+2 -1
View File
@@ -220,7 +220,8 @@ export function agentDisplayAdapterType(agent: { adapterType: string; adapterCon
if (agent.adapterConfig.provider === "openai_dot" || agent.adapterConfig.provider === "grok_bot" || agent.adapterConfig.provider === "muse") {
return agent.adapterConfig.provider;
}
return agentHarnessType(agent.adapterType, agent.adapterConfig) === "codex_local" ? "codex_local" : agent.adapterType;
const harness = agentHarnessType(agent.adapterType, agent.adapterConfig);
return ["codex_local", "claude_local"].includes(harness) ? harness : agent.adapterType;
}
export function isKnownAdapterType(type: string): boolean {
@@ -11,9 +11,9 @@ import { TooltipProvider } from "@/components/ui/tooltip";
import { CodexLocalConfigFields } from "./config-fields";
import { buildCodexRunnerConfig } from "./config-fields";
import { buildPaperclipRunnerConfig } from "@paperclipai/adapter-codex-local/ui";
import { defaultCreateValues } from "@/components/agent-config-defaults";
import { resolveCodexLocalCliPolicy } from "@paperclipai/adapter-codex-local";
import { buildPaperclipRunnerConfig } from "@paperclipai/adapter-codex-local/ui";
beforeAll(() => { Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); });
@@ -121,11 +121,6 @@ async function editFields(
}
describe("Paperclip Runner Codex configuration", () => {
it.each(["", "max"])("preserves or overrides saved native Codex reasoning effort (%s)", thinkingEffort => {
const values = { ...defaultCreateValues, thinkingEffort, adapterSchemaValues: { provider: "codex", modelReasoningEffort: "high" } };
expect(buildPaperclipRunnerConfig(values).modelReasoningEffort).toBe(thinkingEffort || "high");
expect(buildPaperclipRunnerConfig({ ...values, adapterSchemaValues: { provider: "acpx", acpxAgent: "claude", modelReasoningEffort: "high" } })).not.toHaveProperty("modelReasoningEffort");
});
it.each(["", "--sandbox,read-only"])("shows actual automatic Legacy policy and preserves the explicit toggle (%s)", async (extraArgs) => {
const container = document.createElement("div");
document.body.appendChild(container);
@@ -40,7 +40,7 @@ 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 defaultAcpxClaudeModel = "claude-sonnet-5";
const defaultAcpxClaudeModel = "claude-opus-5-5";
const defaultClaudeManagedModel = "claude-sonnet-5";
const defaultAwsAgentCoreModel = "global.anthropic.claude-sonnet-4-6";
const runnerHarnessOptions = [
@@ -18,6 +18,7 @@ import { ApiError } from "../api/client";
import { aiConnectionsApi } from "../api/ai-connections";
import { CodexLocalConfigFields } from "../adapters/codex-local/config-fields";
import type { AdapterConfigFieldsProps } from "../adapters/types";
import { buildClaudeLocalConfig } from "@paperclipai/adapter-claude-local/ui";
import { DEFAULT_CODEX_LOCAL_MODEL } from "@paperclipai/adapter-codex-local";
const mockAgentsApi = vi.hoisted(() => ({
@@ -79,9 +80,8 @@ vi.mock("../api/grokBot", () => ({
},
}));
vi.mock("../api/adapters", () => ({
adaptersApi: { list: async () => [{ type: "codex_local", supportedRunners: ["legacy", "paperclip"], defaultRunner: "legacy" }] },
}));
const mockAdaptersApi = vi.hoisted(() => ({ list: vi.fn(async () => [{ type: "codex_local", supportedRunners: ["legacy", "paperclip"], defaultRunner: "legacy" }]) }));
vi.mock("../api/adapters", () => ({ adaptersApi: mockAdaptersApi }));
vi.mock("../api/environments", () => ({
environmentsApi: mockEnvironmentsApi,
@@ -640,6 +640,37 @@ async function flushUntil(check: () => boolean, timeoutMs = 4000) {
describe("AgentConfigForm environment selector", () => {
let roots: Root[] = [];
it.each(["paperclip", "legacy"] as const)("uses the resolved %s Claude form to preserve the correct turn limit", async defaultRunner => {
mockAdaptersApi.list.mockResolvedValue([{ type: "claude_local", supportedRunners: defaultRunner === "paperclip" ? ["paperclip", "legacy"] : ["legacy"], defaultRunner }]);
try {
const values = { ...defaultCreateValues, adapterType: "claude_local", runner: "auto" as const };
const result = await renderCreateForm([], values);
roots.push(result.root);
const resolved = result.onChange.mock.calls.reduce((current, [patch]) => ({ ...current, ...patch }), values);
expect(resolved.resolvedRunner).toBe(defaultRunner);
const config = buildClaudeLocalConfig(resolved);
if (defaultRunner === "legacy") expect(config.maxTurnsPerRun).toBe(1000);
else expect(config).not.toHaveProperty("maxTurnsPerRun");
expect(config).not.toHaveProperty("resolvedRunner");
} finally { mockAdaptersApi.list.mockResolvedValue([{ type: "codex_local", supportedRunners: ["legacy", "paperclip"], defaultRunner: "legacy" }]); }
});
it("carries a selected native Claude permission through the ordinary creation builder", async () => {
mockAdaptersApi.list.mockResolvedValue([{ type: "claude_local", supportedRunners: ["paperclip", "legacy"], defaultRunner: "paperclip" }]);
try {
const values = { ...defaultCreateValues, adapterType: "claude_local", runner: "auto" as const };
const result = await renderCreateForm([], values);
roots.push(result.root);
await clickElement(result.container.querySelector('[aria-label="Permission mode"]'));
const deny = [...document.querySelectorAll<HTMLElement>('[role="option"]')].find(option => option.textContent === "Deny all");
expect(deny).toBeTruthy();
await clickElement(deny);
const resolved = result.onChange.mock.calls.reduce((current, [patch]) => ({ ...current, ...patch }), values);
expect(buildClaudeLocalConfig(resolved)).toMatchObject({ acpxPermissionMode: "deny-all" });
} finally { mockAdaptersApi.list.mockResolvedValue([{ type: "codex_local", supportedRunners: ["legacy", "paperclip"], defaultRunner: "legacy" }]); }
});
it("keeps an explicit Codex draft policy until a harness reset clears it", async () => {
mockEnvironmentsApi.list.mockResolvedValue([]);
const container = document.createElement("div");
@@ -686,6 +717,21 @@ describe("AgentConfigForm environment selector", () => {
expect(result.onSave).toHaveBeenCalledWith({ name: "Renamed Cody" });
});
it.each(["codex", "claude"].flatMap(harness => ["modelReasoningEffort", "reasoningEffort", "effort"].map(key => ({ harness, key }))))("hydrates saved effort and clears explicit Auto for $harness ($key)", async ({ harness, key }) => {
const profile = harness === "codex" ? { provider: "codex", model: "gpt-5.6-sol" } : { provider: "acpx", acpxAgent: "claude", model: "claude-opus-5-5" };
const result = await renderForm([], { adapterType: "paperclip_runner", adapterConfig: { ...profile, [key]: "high" } });
roots.push(result.root);
const effort = result.container.querySelector('[aria-label="Thinking effort"]');
expect(effort?.textContent).toContain("High");
await clickElement(effort);
await clickElement(document.querySelector('[role="option"][data-value=""]'));
await clickByText(result.container, "Save");
const saved = result.onSave.mock.calls[0][0];
expect(saved.replaceAdapterConfig).toBe(true);
expect(saved.adapterConfig).toMatchObject(profile);
for (const alias of ["modelReasoningEffort", "reasoningEffort", "effort"]) expect(saved.adapterConfig).not.toHaveProperty(alias);
});
it.each(["codex_local", "paperclip_runner"])("changes and clears the environment without changing a saved %s runner or account", async adapterType => {
const adapterConfig = {
model: "gpt-5.6-sol",
@@ -748,7 +794,23 @@ describe("AgentConfigForm environment selector", () => {
expect(result.onSave.mock.calls[0][0].runner).toBeUndefined();
});
it.each([{ provider: "opencode", model: "openrouter/anthropic/claude-sonnet-4.6" }, { provider: "acpx", acpxAgent: "claude" }, { provider: "unknown-provider" }])("preserves saved native $provider configuration with the experimental gate off", async adapterConfig => {
it("shows Claude's saved runner without legacy command controls when the experimental gate is off", async () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableNativeRunner: false });
const result = await renderForm([], { adapterType: "paperclip_runner", adapterConfig: { provider: "acpx", acpxAgent: "claude", model: "claude-opus-5-5", modelReasoningEffort: "high" } }, { showAdapterTypeField: true });
roots.push(result.root);
expect(result.container.querySelector('[aria-label="Harness"]')?.textContent).toContain("Claude");
await clickByText(result.container, "Advanced");
expect(result.container.querySelector('[aria-label="Runner"]')?.textContent).toContain("Paperclip Runner");
const labels = Array.from(result.container.querySelectorAll("label")).map(label => label.textContent?.trim());
expect(labels).not.toContain("Command");
expect(labels).not.toContain("Extra args (comma-separated)");
await act(async () => setInputValue(result.container.querySelector<HTMLInputElement>('[placeholder="Agent name"]')!, "Renamed Claude"));
await flushReact();
await clickByText(result.container, "Save");
expect(result.onSave).toHaveBeenCalledWith({ name: "Renamed Claude" });
});
it.each([{ provider: "opencode", model: "openrouter/anthropic/claude-sonnet-4.6" }, { provider: "unknown-provider" }])("preserves saved native $provider configuration with the experimental gate off", async adapterConfig => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableNativeRunner: false });
const result = await renderForm([], { adapterType: "paperclip_runner", adapterConfig }, { showAdapterTypeField: true });
roots.push(result.root);
@@ -915,7 +977,7 @@ describe("AgentConfigForm environment selector", () => {
it.each([
["Codex", "codex", undefined, DEFAULT_CODEX_LOCAL_MODEL],
["ACP agents", "acpx", "claude", "claude-sonnet-5"],
["ACP agents", "acpx", "claude", "claude-opus-5-5"],
["Claude Managed", "claude_managed", undefined, "claude-sonnet-5"],
])("saves the %s harness default without the previous OpenCode model prefix", async (label, provider, acpxAgent, model) => {
const accountList = vi.spyOn(aiConnectionsApi, "list").mockResolvedValue({
@@ -1058,14 +1120,61 @@ describe("AgentConfigForm environment selector", () => {
});
});
it.each(["modelReasoningEffort", "reasoningEffort", "effort"])("clears native Claude %s when the model does not support it", async key => {
mockAgentsApi.adapterModels.mockResolvedValue([
{ id: "claude-opus-5-5", label: "claude-opus-5-5" },
{ id: "claude-haiku-4-5", label: "claude-haiku-4-5" },
]);
const result = await renderForm(
[makeEnvironment({ id: "local-1", name: "Local", driver: "local" })],
{
adapterType: "paperclip_runner",
adapterConfig: {
provider: "acpx", acpxAgent: "claude",
model: "claude-opus-5-5",
[key]: "max",
},
},
);
roots.push(result.root);
const modelButton = Array.from(result.container.querySelectorAll("button"))
.find((button) => button.textContent?.trim() === "claude-opus-5-5");
expect(modelButton).not.toBeUndefined();
await act(async () => {
modelButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
const astraOption = Array.from(document.body.querySelectorAll("button"))
.find((button) => button.textContent?.trim() === "claude-haiku-4-5");
expect(astraOption).not.toBeUndefined();
await act(async () => {
astraOption!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
const saveButton = Array.from(result.container.querySelectorAll("button"))
.find((button) => button.textContent?.trim() === "Save");
expect(saveButton).not.toBeUndefined();
await act(async () => {
saveButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(result.onSave).toHaveBeenCalledWith({
adapterConfig: { provider: "acpx", acpxAgent: "claude", model: "claude-haiku-4-5" },
replaceAdapterConfig: true,
});
});
it("names the Claude default for new and existing agents without pinning it", async () => {
const environments = [makeEnvironment({ id: "local-1", name: "Local", driver: "local" })];
const existing = await renderForm(environments, { adapterType: "claude_local", adapterConfig: {} });
roots.push(existing.root);
const created = await renderCreateForm(environments, { adapterType: "claude_local", model: "" });
roots.push(created.root);
expect(existing.container.textContent).toContain("Default (claude-opus-5)");
expect(created.container.textContent).toContain("Default (claude-opus-5)");
expect(existing.container.textContent).toContain("Default (claude-opus-5-5)");
expect(created.container.textContent).toContain("Default (claude-opus-5-5)");
expect(existing.onSave).not.toHaveBeenCalled();
});
+40 -32
View File
@@ -1,7 +1,7 @@
import { useConnectionModels } from "./ai-connections/useConnectionModels";
import { agentHarnessType, agentRunner, type AgentRunnerChoice } from "@paperclipai/shared";
import { adaptersApi } from "../api/adapters";
import { CodexRunnerSelect } from "./CodexRunnerSelect";
import { AgentRunnerSelect } from "./AgentRunnerSelect";
import { aiRoutingHarness } from "@paperclipai/shared";
import { AiConnectionField } from "./ai-connections/AiConnectionField";
import { aiConnectionBindingSchema, aiRuntimeConnectionBindingSchema } from "@paperclipai/shared";
@@ -606,6 +606,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
const effectiveConfig = isCreate ? props.values.adapterSchemaValues ?? {} : { ...config, ...overlay.adapterConfig };
const harnessType = agentHarnessType(adapterType, effectiveConfig);
const isCodexHarness = harnessType === "codex_local";
const isRunnerHarness = isCodexHarness || harnessType === "claude_local";
const rawCurrentDefaultEnvironmentId = isCreate
? props.values.defaultEnvironmentId ?? ""
: eff("identity", "defaultEnvironmentId", props.agent.defaultEnvironmentId ?? "");
@@ -619,13 +620,19 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
queryFn: () => adaptersApi.list({ companyId: selectedCompanyId!, environmentId: rawCurrentDefaultEnvironmentId || null }),
enabled: Boolean(selectedCompanyId),
});
const codexRunnerAvailability = runnerDiscovery.data?.find(item => item.type === "codex_local");
const runnerAvailability = runnerDiscovery.data?.find(item => item.type === harnessType);
const runner: AgentRunnerChoice = isCreate ? props.values.runner ?? "auto" : overlay.runner ?? agentRunner(adapterType);
const fieldsAdapterType = isCodexHarness
? (runner === "paperclip" || (runner === "auto" && codexRunnerAvailability?.defaultRunner === "paperclip") ? "paperclip_runner" : "codex_local")
const fieldsAdapterType = isRunnerHarness
? (runner === "paperclip" || (runner === "auto" && runnerAvailability?.defaultRunner === "paperclip") ? "paperclip_runner" : harnessType)
: adapterType;
useEffect(() => {
if (props.mode !== "create" || harnessType !== "claude_local") return;
const resolvedRunner = runner === "auto" ? runnerAvailability?.defaultRunner : runner;
if (props.values.resolvedRunner !== resolvedRunner) props.onChange({ resolvedRunner });
}, [props.mode, harnessType, runner, runnerAvailability?.defaultRunner,
isCreate ? props.values.resolvedRunner : undefined, isCreate ? props.onChange : undefined]);
const getCapabilities = useAdapterCapabilities();
const adapterCaps = getCapabilities(isCodexHarness ? harnessType : adapterType);
const adapterCaps = getCapabilities(isRunnerHarness ? harnessType : adapterType);
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"))
@@ -921,7 +928,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
).data?.provider === "openrouter" ? "openrouter" : runnerProvider;
const connectionModels = useConnectionModels(isExternalRunner ? null : selectedCompanyId, isCreate ? undefined : aiConnectionBindingSchema.safeParse((overlay.runtime.runtimeConfig as Record<string, unknown> | undefined)?.aiConnection ?? runtimeConfig.aiConnection).data, aiRoutingHarness(adapterType, runnerProvider, eff("adapterConfig", "acpxAgent", config.acpxAgent)));
// Fetch adapter models for the effective provider, including unsaved changes.
const modelAdapterType = isCodexHarness ? harnessType : adapterType;
const modelAdapterType = isRunnerHarness ? harnessType : adapterType;
const modelQueryKey = selectedCompanyId
? queryKeys.agents.adapterModels(selectedCompanyId, modelAdapterType, currentDefaultEnvironmentId || null, modelProvider)
: ["agents", "none", "adapter-models", adapterType];
@@ -973,10 +980,10 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
mode,
isCreate,
adapterType: fieldsAdapterType,
hideRunnerHarness: isCodexHarness,
values: isCreate ? props.values : null,
hideRunnerHarness: isRunnerHarness,
values: isCreate ? { ...props.values, ...(harnessType === "claude_local" && fieldsAdapterType === "paperclip_runner" ? { adapterSchemaValues: { ...props.values.adapterSchemaValues, provider: "acpx", acpxAgent: "claude" } } : {}) } : null,
set: isCreate ? (patch: Partial<CreateConfigValues>) => props.onChange(patch) : null,
config,
config: harnessType === "claude_local" && fieldsAdapterType === "paperclip_runner" ? { ...config, provider: "acpx", acpxAgent: "claude" } : config,
eff: eff as <T>(group: "adapterConfig", field: string, original: T) => T,
// Harness transitions supply the new harness's default model. Resolve
// user-selected model IDs in ModelDropdown, not through the previous
@@ -1108,9 +1115,9 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
: adapterConfig.provider === "acpx" && adapterConfig.acpxAgent === "claude" ? "claude_local"
: adapterType
: adapterType;
return testAgentSetup({ companyId: selectedCompanyId, adapterType, providerAdapter, adapterConfig, agentId, aiConnection, environmentId, ...(isCodexHarness && (isCreate ? runner !== "auto" : overlay.runner !== undefined) ? { runner } : {}) });
return testAgentSetup({ companyId: selectedCompanyId, adapterType, providerAdapter, adapterConfig, agentId, aiConnection, environmentId, ...(isRunnerHarness && (isCreate ? runner !== "auto" : overlay.runner !== undefined) ? { runner } : {}) });
}
return agentsApi.testEnvironment(selectedCompanyId, adapterType, { adapterConfig, agentId, aiConnection, environmentId, ...(isCodexHarness && (isCreate ? runner !== "auto" : overlay.runner !== undefined) ? { runner } : {}) });
return agentsApi.testEnvironment(selectedCompanyId, adapterType, { adapterConfig, agentId, aiConnection, environmentId, ...(isRunnerHarness && (isCreate ? runner !== "auto" : overlay.runner !== undefined) ? { runner } : {}) });
},
});
const [testActionPending, setTestActionPending] = useState(false);
@@ -1157,7 +1164,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
resetTestEnvironmentRef.current();
setTestActionError(null);
clearClaudeLoginClaimRef.current();
}, [adapterType, runner, effectiveLoginEnvironmentId, runner === "auto" ? codexRunnerAvailability?.defaultRunner : undefined]);
}, [adapterType, runner, effectiveLoginEnvironmentId, runner === "auto" ? runnerAvailability?.defaultRunner : undefined]);
// Show the login affordance only for a current sandbox adapter that declares a
// login capability, and whose most recent Test result carries the canonical
@@ -1168,7 +1175,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
const adapterSupportsSandboxLogin = adapterCaps.login != null;
const testResult = testEnvironment.data;
const testResultSupportsSandboxLogin =
testResult != null && getCapabilities(isCodexHarness ? harnessType : testResult.adapterType).login != null;
testResult != null && getCapabilities(isRunnerHarness ? harnessType : testResult.adapterType).login != null;
const authMissingCheck =
testResult && testResultSupportsSandboxLogin
? testResult.checks.find((check) => check.code === ADAPTER_AUTH_MISSING_CHECK_CODE) ?? null
@@ -1263,7 +1270,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
// environment id, so both are present here.
login:
showAdapterLogin && selectedCompanyId && effectiveLoginEnvironmentId
? { companyId: selectedCompanyId, adapterType: isCodexHarness ? harnessType : adapterType, environmentId: effectiveLoginEnvironmentId }
? { companyId: selectedCompanyId, adapterType: isRunnerHarness ? harnessType : adapterType, environmentId: effectiveLoginEnvironmentId }
: null,
});
return () => {
@@ -1301,7 +1308,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
}
const thinkingEffortKey =
isCodexHarness
isCodexHarness || (adapterType === "paperclip_runner" && harnessType === "claude_local")
? "modelReasoningEffort"
: adapterType === "cursor"
? "mode"
@@ -1323,19 +1330,19 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
? kimiThinkingEffortOptions
: adapterType === "pi_local"
? [{ id: "", label: "Auto" }, ...["off", "minimal", "low", "medium", "high", "xhigh"].map(id => ({ id, label: id }))]
: adapterType === "claude_local" || adapterType === "grok_local"
? [{ id: "", label: "Auto" }, ...setupEfforts(adapterType, currentModelId).map((id) => ({
: harnessType === "claude_local" || adapterType === "grok_local"
? [{ id: "", label: "Auto" }, ...setupEfforts(harnessType, currentModelId).map((id) => ({
id,
label: id === "xhigh" ? "X-High" : id[0].toUpperCase() + id.slice(1),
}))]
: claudeThinkingEffortOptions;
const currentThinkingEffort = isCreate
? val!.thinkingEffort
: isCodexHarness
: isCodexHarness || (adapterType === "paperclip_runner" && harnessType === "claude_local")
? eff(
"adapterConfig",
thinkingEffortKey,
String(config[thinkingEffortKey] ?? config.reasoningEffort ?? config.modelReasoningEffort ?? ""),
String(config[thinkingEffortKey] ?? config.reasoningEffort ?? config.modelReasoningEffort ?? config.effort ?? ""),
)
: adapterType === "cursor"
? eff("adapterConfig", "mode", String(config.mode ?? ""))
@@ -1344,7 +1351,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
: eff("adapterConfig", thinkingEffortKey, String(config[thinkingEffortKey] ?? ""));
const showThinkingEffort = adapterType !== "gemini_local"
&& adapterType !== "cursor_cloud"
&& (adapterType !== "paperclip_runner" || isCodexHarness);
&& (adapterType !== "paperclip_runner" || isRunnerHarness);
const codexSearchEnabled = adapterType === "codex_local"
? (isCreate ? Boolean(val!.search) : eff("adapterConfig", "search", Boolean(config.search)))
: false;
@@ -1640,7 +1647,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
{showAdapterTypeField && !isGrokBotRunner && (
<Field label="Harness" hint={help.adapterType}>
<AdapterTypeDropdown
value={isDotRunner ? "openai_dot" : isMuseRunner ? "muse" : isCodexHarness ? harnessType : adapterType}
value={isDotRunner ? "openai_dot" : isMuseRunner ? "muse" : isRunnerHarness ? harnessType : adapterType}
disabledTypes={adapterPickerDisabledTypes}
openAiDotEnabled={experimentalSettings?.enableOpenAiDot === true}
nativeRunnerEnabled={experimentalSettings?.enableNativeRunner === true}
@@ -1679,7 +1686,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
setOverlay((prev) => ({
...prev,
adapterType: t,
runner: t === "codex_local" ? "auto" : undefined,
runner: ["codex_local", "claude_local"].includes(t) ? "auto" : undefined,
adapterConfig: {
model:
t === "gemini_local"
@@ -1735,7 +1742,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
<AdapterLoginPanel
key={`${adapterType}:${effectiveLoginEnvironmentId}`}
companyId={selectedCompanyId!}
adapterType={isCodexHarness ? harnessType : adapterType}
adapterType={isRunnerHarness ? harnessType : adapterType}
environmentId={effectiveLoginEnvironmentId!}
onStored={isCreate ? handleClaudeLoginStored : handleClaudeLoginStoredEdit}
onApplyStored={
@@ -1777,8 +1784,8 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
loadingModels={connectionModels?.isLoading ?? fetchingModels}
value={currentModelId}
onChange={(v) => {
const supportedEfforts = setupEfforts(isCodexHarness ? harnessType : adapterType, v);
const clearUnsupportedEffort = ["codex_local", "claude_local", "grok_local"].includes(isCodexHarness ? harnessType : adapterType)
const supportedEfforts = setupEfforts(isRunnerHarness ? harnessType : adapterType, v);
const clearUnsupportedEffort = ["codex_local", "claude_local", "grok_local"].includes(isRunnerHarness ? harnessType : adapterType)
&& Boolean(currentThinkingEffort)
&& !supportedEfforts.includes(String(currentThinkingEffort));
if (isCreate) {
@@ -1790,8 +1797,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
}
mark("adapterConfig", "model", connectionModels?.resolveModel(v) || v || undefined);
if (clearUnsupportedEffort) {
mark("adapterConfig", thinkingEffortKey, undefined);
mark("adapterConfig", "reasoningEffort", undefined);
for (const key of ["modelReasoningEffort", "reasoningEffort", "effort"]) mark("adapterConfig", key, undefined);
}
}}
open={modelOpen}
@@ -1846,7 +1852,9 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
? set!({ thinkingEffort: v })
: (() => {
mark("adapterConfig", thinkingEffortKey, v || undefined);
if (isCodexHarness) mark("adapterConfig", thinkingEffortKey === "reasoningEffort" ? "modelReasoningEffort" : "reasoningEffort", undefined);
if (isRunnerHarness) {
for (const key of ["modelReasoningEffort", "reasoningEffort", "effort"]) if (key !== thinkingEffortKey) mark("adapterConfig", key, undefined);
}
})()
}
open={thinkingEffortOpen}
@@ -1908,12 +1916,12 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
onToggle={() => setConfigurationAdvancedOpen(!configurationAdvancedOpen)}
>
<div className="space-y-3">
{isCodexHarness && <CodexRunnerSelect value={runner}
{isRunnerHarness && <AgentRunnerSelect value={runner}
pending={runnerDiscovery.isFetching} error={runnerDiscovery.error?.message} onRetry={() => { void runnerDiscovery.refetch(); }}
defaultRunner={codexRunnerAvailability?.defaultRunner}
supportedRunners={codexRunnerAvailability?.supportedRunners}
defaultRunner={runnerAvailability?.defaultRunner}
supportedRunners={runnerAvailability?.supportedRunners}
onChange={next => isCreate ? set!({ runner: next }) : setOverlay(prev => ({ ...prev, runner: next }))} />}
{isLocal && (<> {/*
{isLocal && (!isRunnerHarness || fieldsAdapterType !== "paperclip_runner") && (<> {/*
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
@@ -3,7 +3,7 @@ import { Field } from "./agent-config-primitives";
import { SelectPopover } from "./ui/select";
import { Button } from "./ui/button";
export function CodexRunnerSelect({
export function AgentRunnerSelect({
value,
onChange,
defaultRunner,
@@ -1,7 +1,7 @@
import { useMemo, useState } from "react";
import { agentHarnessType, agentRunner, type AgentRunnerChoice } from "@paperclipai/shared";
import { adaptersApi } from "@/api/adapters";
import { CodexRunnerSelect } from "./CodexRunnerSelect";
import { AgentRunnerSelect } from "./AgentRunnerSelect";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
@@ -68,12 +68,12 @@ export function ConfigureBuiltInAgentModal({
const { definition } = state;
const [adapterType, setAdapterType] = useState<string>(
() => state.agent && agentHarnessType(state.agent.adapterType, state.agent.adapterConfig) === "codex_local" ? "codex_local" : state.agent?.adapterType ?? defaultAdapterType(state),
() => state.agent && ["codex_local", "claude_local"].includes(agentHarnessType(state.agent.adapterType, state.agent.adapterConfig)) ? agentHarnessType(state.agent.adapterType, state.agent.adapterConfig) : state.agent?.adapterType ?? defaultAdapterType(state),
);
const [runner, setRunner] = useState<AgentRunnerChoice | undefined>();
const environmentId = state.agent?.defaultEnvironmentId ?? null;
const adapters = useQuery({ queryKey: queryKeys.adapters.availability(companyId, environmentId), queryFn: () => adaptersApi.list({ companyId, environmentId }), enabled: open });
const codexAvailability = adapters.data?.find(item => item.type === "codex_local");
const runnerAvailability = adapters.data?.find(item => item.type === adapterType);
const [model, setModel] = useState<string>(() => {
const config = state.agent?.adapterConfig;
const configuredModel = typeof config === "object" && config !== null
@@ -143,7 +143,7 @@ export function ConfigureBuiltInAgentModal({
: { adapterType, adapterConfig };
const result = await builtInAgentsApi.provision(companyId, definition.key, {
...adapterInput,
...(adapterType === "codex_local" && runner !== undefined ? { runner } : {}),
...(["codex_local", "claude_local"].includes(adapterType) && runner !== undefined ? { runner } : {}),
...(budgetMonthlyCents !== undefined ? { budgetMonthlyCents } : {}),
});
return result;
@@ -189,11 +189,11 @@ export function ConfigureBuiltInAgentModal({
/>
</Field>
{adapterType === "codex_local" && <details className="space-y-3">
{["codex_local", "claude_local"].includes(adapterType) && <details className="space-y-3">
<summary className="cursor-pointer text-sm text-muted-foreground">Advanced</summary>
<CodexRunnerSelect value={runner ?? (state.agent ? agentRunner(state.agent.adapterType) : "auto")}
<AgentRunnerSelect value={runner ?? (state.agent ? agentRunner(state.agent.adapterType) : "auto")}
pending={adapters.isFetching} error={adapters.error?.message} onRetry={() => { void adapters.refetch(); }}
defaultRunner={codexAvailability?.defaultRunner} supportedRunners={codexAvailability?.supportedRunners}
defaultRunner={runnerAvailability?.defaultRunner} supportedRunners={runnerAvailability?.supportedRunners}
onChange={setRunner} />
</details>}
+1 -1
View File
@@ -107,7 +107,7 @@ it("offers one Codex harness without a runner/provider tile", async () => {
expect(document.querySelectorAll('input[value="codex_local"]')).toHaveLength(1);
expect(document.querySelector("select")).toBeNull();
});
it.each(["claude", "grok", "opencode"])("preserves explicit experimental %s creation in Advanced", async provider => {
it.each(["grok", "opencode"])("preserves explicit experimental %s creation in Advanced", async provider => {
await name();
await act(async () => (document.querySelector("summary") as HTMLElement).click());
await act(async () => document.querySelector<HTMLButtonElement>('[aria-label="Experimental harness"]')!.click());
@@ -178,7 +178,9 @@ describe("OnboardingWizard — which step it lands on", () => {
dialogState.onboardingOpen = false;
dialogState.onboardingOptions = {};
dialogState.onboardingRouteDismissed = false;
mockAdaptersApi.list.mockResolvedValue([]);
mockAdaptersApi.list.mockResolvedValue(["claude_local", "codex_local"].map(type => ({
type, loaded: true, disabled: false, defaultRunner: "legacy", supportedRunners: ["legacy"],
})));
mockGoalsApi.list.mockResolvedValue([]);
mockAgentsApi.adapterModels.mockResolvedValue([]);
// The hire step lists the company's agents first so it can adopt one that
+309 -6
View File
@@ -1,6 +1,8 @@
// @vitest-environment jsdom
import { act, StrictMode } from "react";
import { buildClaudeLocalConfig } from "../../../packages/adapters/claude-local/src/ui/build-config";
import type { CreateConfigValues } from "@paperclipai/adapter-utils";
import type { LocalAiLoginStatus } from "@paperclipai/shared";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
@@ -15,6 +17,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const mockAdaptersApi = vi.hoisted(() => ({ list: vi.fn(async (_context?: { companyId: string; environmentId?: string | null }) => [] as import("../api/adapters").AdapterInfo[]) }));
vi.mock("../api/adapters", () => ({ adaptersApi: mockAdaptersApi }));
function setupAdapterInfo(input: Pick<import("../api/adapters").AdapterInfo, "type"> & Partial<import("../api/adapters").AdapterInfo>): import("../api/adapters").AdapterInfo {
return { label: input.type, source: "builtin", modelsCount: 0, loaded: true, disabled: false,
capabilities: { supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true,
requiresMaterializedRuntimeSkills: false, supportsAcp: true }, ...input };
}
const mockAuthApi = vi.hoisted(() => ({ getSession: vi.fn() }));
const localHealth = vi.hoisted(() => ({ get: vi.fn() }));
vi.mock("@/api/health", () => ({ healthApi: localHealth }));
@@ -127,7 +135,7 @@ const mockAgentsApi = vi.hoisted(() => ({
// can shape the built adapter config (e.g. a configured ANTHROPIC_API_KEY)
// without a real adapter package.
const mockAdapterBuild = vi.hoisted(() => ({
buildAdapterConfig: vi.fn(() => ({}) as Record<string, unknown>),
buildAdapterConfig: vi.fn((_values?: CreateConfigValues) => ({}) as Record<string, unknown>),
}));
// The Connect path loads environment settings before probing; without these
// the probe dies on "Could not load environment settings" and the hire never
@@ -368,6 +376,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
mockCompaniesApi.list.mockResolvedValue([]);
mockAdapterRegistry.list = [];
mockAdapterRegistry.disabled = new Set<string>();
mockAdaptersApi.list.mockReset().mockResolvedValue(["claude_local", "codex_local"].map(type => setupAdapterInfo({ type, loaded: true, disabled: false, supportedRunners: ["paperclip", "legacy"], defaultRunner: "legacy" })));
mockAdapterBuild.buildAdapterConfig.mockReset();
mockAdapterBuild.buildAdapterConfig.mockReturnValue({});
// Default: no stored Claude login for the owner. The route returns a
@@ -1911,6 +1920,8 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
beforeEach(() => {
mockEnvironmentsApi.list.mockReset();
mockEnvironmentsApi.probe.mockReset().mockResolvedValue({ ok: true, driver: "sandbox", summary: "Measured image",
details: { platformQualificationRecorded: true, leaseId: "owned-platform-lease" } });
mockEnvironmentsApi.list.mockResolvedValue([SANDBOX_ENVIRONMENT]);
mockEnvironmentsApi.capabilities.mockReset();
mockEnvironmentsApi.capabilities.mockResolvedValue(
@@ -1963,6 +1974,138 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
return { root, queryClient };
}
it.each(["paperclip", "legacy"] as const)("qualifies managed Claude during Connect and tests/hire with measured %s configuration", async measured => {
mockAdapterBuild.buildAdapterConfig.mockImplementation(values => buildClaudeLocalConfig(values!));
mockEnvironmentsApi.list.mockResolvedValue([{ ...SANDBOX_ENVIRONMENT, config: {
provider: "daytona", image: `registry.example.test/runtime@sha256:${"a".repeat(64)}`,
} }]);
const discovery = (runner: "paperclip" | "legacy") => [
setupAdapterInfo({ type: "claude_local", loaded: true, disabled: false, defaultRunner: runner, supportedRunners: [runner] }),
setupAdapterInfo({ type: "paperclip_runner", loaded: true, disabled: false }),
];
let qualified = false;
mockAdaptersApi.list.mockImplementation(async () => discovery(qualified ? measured : "legacy"));
const handles = await openStep4({ adapterType: "claude_local", runner: "auto" });
await act(async () => handles.queryClient.setQueryData(["ai-connections", "company-new"], {
currentUserId: "user-1", connections: [{ id: "saved", grantId: "grant", companyId: "company-new", provider: "anthropic", method: "subscription", name: "My subscription", ownership: "personal", ownerUserId: "user-1", status: "connected", isDefault: true }],
}));
const calls: string[] = [];
mockAdaptersApi.list.mockImplementation(async context => {
expect(context).toEqual({ companyId: "company-new", environmentId: "env-sandbox-1" });
calls.push(qualified ? "discovery-after" : "discovery-before");
return discovery(qualified ? measured : "legacy");
});
mockEnvironmentsApi.probe.mockImplementation(async () => {
calls.push("platform"); qualified = true;
return { ok: true, driver: "sandbox", summary: "Measured image", details: { platformQualificationRecorded: true, leaseId: "owned-platform-lease" } };
});
mockAgentsApi.testEnvironment.mockImplementation(async () => {
calls.push("adapter"); return { adapterType: measured === "paperclip" ? "paperclip_runner" : "claude_local", status: "pass", checks: [], testedAt: "2026-10-13T00:00:00Z" };
});
mockAgentsApi.hire.mockImplementation(async () => { calls.push("hire"); return { agent: { id: "agent-1" }, approval: null }; });
try {
expect(mockEnvironmentsApi.probe).not.toHaveBeenCalled();
await pickSource(/Claude/);
expect(calls).toEqual(["discovery-before", "platform", "discovery-after", "adapter", "hire"]);
expect(mockEnvironmentsApi.probe).toHaveBeenCalledExactlyOnceWith("env-sandbox-1", "company-new");
const tested = (mockAgentsApi.testEnvironment.mock.calls[0] as unknown[])[2] as { adapterConfig: Record<string, unknown> };
const saved = (mockAgentsApi.hire.mock.calls[0] as unknown[])[1] as { adapterConfig: Record<string, unknown> };
expect(saved).not.toHaveProperty("runner");
expect(saved.adapterConfig).toMatchObject(tested.adapterConfig);
if (measured === "paperclip") expect(tested.adapterConfig).not.toHaveProperty("maxTurnsPerRun");
else expect(tested.adapterConfig.maxTurnsPerRun).toBe(1000);
} finally { await act(async () => handles.root.unmount()); }
});
it("stores a new API key once before managed qualification and hires with the same native configuration", async () => {
const key = "example-scoped-api-key";
mockAdapterBuild.buildAdapterConfig.mockImplementation(values => buildClaudeLocalConfig(values!));
mockEnvironmentsApi.list.mockResolvedValue([{ ...SANDBOX_ENVIRONMENT, config: {
provider: "daytona", image: `registry.example.test/runtime@sha256:${"a".repeat(64)}`,
} }]);
let qualified = false;
mockAdaptersApi.list.mockImplementation(async () => [
setupAdapterInfo({ type: "claude_local", defaultRunner: qualified ? "paperclip" : "legacy", supportedRunners: qualified ? ["paperclip"] : ["legacy"] }),
setupAdapterInfo({ type: "paperclip_runner" }),
]);
mockEnvironmentsApi.probe.mockImplementation(async () => {
qualified = true;
return { ok: true, driver: "sandbox", summary: "Measured image", details: { platformQualificationRecorded: true, leaseId: "owned-platform-lease" } };
});
const handles = await openStep4({ adapterType: "claude_local", runner: "auto", credentialMode: "api" });
try {
await pickSource(/Claude.*API/);
const field = document.body.querySelector('input[type="password"]') as HTMLInputElement;
await act(async () => setControlledValue(field, key));
await flushReact();
await pressArcPrimary();
expect(managedApi.create).toHaveBeenCalledExactlyOnceWith("company-new", expect.objectContaining({ provider: "anthropic", method: "api_key", apiKey: key }));
expect(mockEnvironmentsApi.probe).toHaveBeenCalledExactlyOnceWith("env-sandbox-1", "company-new");
expect(mockAgentsApi.testEnvironment).toHaveBeenCalledTimes(1);
expect(mockAgentsApi.hire).toHaveBeenCalledTimes(1);
const tested = (mockAgentsApi.testEnvironment.mock.calls[0] as unknown[])[2] as { adapterConfig: object };
const saved = (mockAgentsApi.hire.mock.calls[0] as unknown[])[1] as { adapterConfig: object; runtimeConfig: object };
expect(saved.adapterConfig).toMatchObject(tested.adapterConfig);
expect(saved.adapterConfig).not.toHaveProperty("maxTurnsPerRun");
expect(saved.runtimeConfig).toMatchObject({ aiConnection: { provider: "anthropic", method: "api_key", mode: "responsible_user" } });
expect(JSON.stringify(saved)).not.toContain(key);
} finally { await act(async () => handles.root.unmount()); }
});
it.each(["back", "environment-config"])("abandons held managed Claude qualification after %s without another probe", async change => {
const image = `registry.example.test/runtime@sha256:${"a".repeat(64)}`;
mockEnvironmentsApi.list.mockResolvedValue([{ ...SANDBOX_ENVIRONMENT, config: { provider: "daytona", image } }]);
mockAdaptersApi.list.mockResolvedValue([
setupAdapterInfo({ type: "claude_local", defaultRunner: "legacy", supportedRunners: ["legacy"] }), setupAdapterInfo({ type: "paperclip_runner" }),
]);
const handles = await openStep4({ adapterType: "claude_local", runner: "auto" });
await act(async () => handles.queryClient.setQueryData(["ai-connections", "company-new"], {
currentUserId: "user-1", connections: [{ id: "saved", grantId: "grant", companyId: "company-new", provider: "anthropic", method: "subscription", name: "My subscription", ownership: "personal", ownerUserId: "user-1", status: "connected", isDefault: true }],
}));
let finish!: (value: import("@paperclipai/shared").EnvironmentProbeResult) => void;
mockEnvironmentsApi.probe.mockReturnValue(new Promise(resolve => { finish = resolve; }));
try {
await pickSource(/Claude/);
expect(mockEnvironmentsApi.probe).toHaveBeenCalledTimes(1);
await act(async () => document.querySelector('[data-testid="onboarding-wizard"]')!.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", metaKey: true, bubbles: true })));
await flushReact();
expect(mockEnvironmentsApi.probe).toHaveBeenCalledTimes(1);
if (change === "back") await act(async () => [...document.body.querySelectorAll("button")].find(button => button.textContent?.trim() === "Back")!.click());
else {
await act(async () => handles.queryClient.setQueryData(queryKeys.environments.list("company-new"), [{ ...SANDBOX_ENVIRONMENT, config: {
provider: "daytona", image: `registry.example.test/runtime@sha256:${"b".repeat(64)}`,
} }]));
await flushReact();
}
await act(async () => finish({ ok: true, driver: "sandbox", summary: "Measured image", details: { platformQualificationRecorded: true, leaseId: "owned-lease" } }));
for (let i = 0; i < 8; i++) await flushReact();
expect(mockEnvironmentsApi.probe).toHaveBeenCalledTimes(1);
expect(mockAgentsApi.testEnvironment).not.toHaveBeenCalled();
expect(mockAgentsApi.hire).not.toHaveBeenCalled();
} finally { await act(async () => handles.root.unmount()); }
});
it("stops managed Claude onboarding at the existing platform-probe permission boundary", async () => {
mockEnvironmentsApi.list.mockResolvedValue([{ ...SANDBOX_ENVIRONMENT, config: {
provider: "daytona", image: `registry.example.test/runtime@sha256:${"a".repeat(64)}`,
} }]);
mockAdaptersApi.list.mockResolvedValue([
setupAdapterInfo({ type: "claude_local", defaultRunner: "legacy", supportedRunners: ["legacy"] }), setupAdapterInfo({ type: "paperclip_runner" }),
]);
const handles = await openStep4({ adapterType: "claude_local", runner: "auto" });
await act(async () => handles.queryClient.setQueryData(["ai-connections", "company-new"], {
currentUserId: "user-1", connections: [{ id: "saved", grantId: "grant", companyId: "company-new", provider: "anthropic", method: "subscription", name: "My subscription", ownership: "personal", ownerUserId: "user-1", status: "connected", isDefault: true }],
}));
mockEnvironmentsApi.probe.mockRejectedValue(new ApiError("Instance admin access required", 403, null));
try {
await pickSource(/Claude/);
expect(document.body.textContent).toContain("Instance admin access required");
expect(mockEnvironmentsApi.probe).toHaveBeenCalledTimes(1);
expect(mockAgentsApi.testEnvironment).not.toHaveBeenCalled();
expect(mockAgentsApi.hire).not.toHaveBeenCalled();
} finally { await act(async () => handles.root.unmount()); }
});
it.each([
["claude_local", "anthropic", /Claude/, "claude-session-1", "claude-setup-token-status"],
["codex_local", "openai", /OpenAI/, "codex-session-1", "adapter-login-status"],
@@ -2017,7 +2160,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
// then never read, so the row went on rendering whatever the registry
// supplied: "Claude Code" and "Codex" in the app, and the bare type here,
// since this suite's registry mock returns `label: type`.
mockAdapterRegistry.list = [{ type: "claude_local" }, { type: "codex_local" }];
mockAdapterRegistry.list = [setupAdapterInfo({ type: "claude_local" }), { type: "codex_local" }];
const { root } = await openStep4({ adapterType: "claude_local" });
const labels = [...document.body.querySelectorAll("button[aria-checked]")].map(
@@ -2086,7 +2229,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
// in never shows it. Nothing is highlighted, the canvas is shut, and with
// the gate on `sourcePicked` the CTA was live: one press hires against an
// adapter the customer has not seen on this screen.
mockAdapterRegistry.list = [{ type: "claude_local" }, { type: "opencode_local" }];
mockAdapterRegistry.list = [setupAdapterInfo({ type: "claude_local" }), { type: "opencode_local" }];
const { root } = await openStep4({ adapterType: "opencode_local" });
const tiles = [...document.body.querySelectorAll("button[aria-checked]")];
@@ -2118,7 +2261,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
// tiles, neither chosen. A keystroke that gets through hires claude_local
// — a real, offerable adapter that nobody on this screen selected, which
// is what makes the bypass worth a test rather than a comment.
mockAdapterRegistry.list = [{ type: "claude_local" }, { type: "codex_local" }];
mockAdapterRegistry.list = [setupAdapterInfo({ type: "claude_local" }), { type: "codex_local" }];
const { root } = await openStep4({ adapterType: "some_retired_adapter" });
const tiles = [...document.body.querySelectorAll("button[aria-checked]")];
@@ -2996,7 +3139,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
});
it("reads the signal again after an adapter change", async () => {
mockAdapterRegistry.list = [{ type: "claude_local" }, { type: "codex_local" }];
mockAdapterRegistry.list = [setupAdapterInfo({ type: "claude_local" }), { type: "codex_local" }];
mockAgentsApi.getAdapterAuthSignal.mockResolvedValue({ status: "absent" });
const { root } = await openStep4({ adapterType: "claude_local" });
@@ -3231,6 +3374,166 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
expect(document.body.textContent).not.toContain("Start sign-in again");
}
it.each(["paperclip", "legacy"] as const)("waits for Claude runner discovery before saved subscription verification (%s)", async defaultRunner => {
let finish!: (value: import("../api/adapters").AdapterInfo[]) => void;
mockAdaptersApi.list.mockReturnValue(new Promise<import("../api/adapters").AdapterInfo[]>(resolve => { finish = resolve; }));
mockAdapterBuild.buildAdapterConfig.mockImplementation(values => buildClaudeLocalConfig(values!));
const { root, queryClient } = await openStep4({ adapterType: "claude_local" });
try {
await act(async () => queryClient.setQueryData(["ai-connections", "company-new"], {
currentUserId: "user-1", connections: [{ id: "saved", grantId: "grant", companyId: "company-new", provider: "anthropic", method: "subscription", ownership: "personal", ownerUserId: "user-1", status: "connected", isDefault: true }],
}));
await pickSource(/Claude/);
await settle();
expect(button("Connect").disabled).toBe(true);
await act(async () => button("Connect").dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", metaKey: true, bubbles: true })));
await settle();
expect(mockAgentsApi.testEnvironment).not.toHaveBeenCalled();
expect(mockAgentsApi.hire).not.toHaveBeenCalled();
await act(async () => finish([setupAdapterInfo({ type: "claude_local", defaultRunner, supportedRunners: ["paperclip", "legacy"] })]));
await settle();
expect(mockAgentsApi.testEnvironment).toHaveBeenCalledTimes(1);
expect(mockAgentsApi.hire).toHaveBeenCalledTimes(1);
const tested = mockAgentsApi.testEnvironment.mock.calls[0] as unknown as [string, string, { adapterConfig: Record<string, unknown> }];
const hired = mockAgentsApi.hire.mock.calls[0] as unknown as [string, { adapterConfig: Record<string, unknown> }];
expect(hired[1].adapterConfig).toEqual(tested[2].adapterConfig);
if (defaultRunner === "paperclip") expect(hired[1].adapterConfig).not.toHaveProperty("maxTurnsPerRun");
else expect(hired[1].adapterConfig.maxTurnsPerRun).toBe(1000);
expect(hired[1].adapterConfig).not.toHaveProperty("resolvedRunner");
} finally { await act(async () => root.unmount()); }
});
it.each(["environments", "settings", "policy"] as const)("does not use warm Claude discovery before %s resolves", async pending => {
let finish!: (value: any) => void;
const delayed = new Promise<any>(resolve => { finish = resolve; });
if (pending === "environments") mockEnvironmentsApi.list.mockReturnValue(delayed);
else if (pending === "settings") mockInstanceSettingsApi.get.mockReturnValue(delayed);
else mockInstanceSettingsApi.getExperimental.mockReturnValue(delayed);
mockAdapterBuild.buildAdapterConfig.mockImplementation(values => buildClaudeLocalConfig(values!));
const { root, queryClient } = await openStep4({ adapterType: "claude_local" });
try {
await act(async () => {
if (pending === "environments") queryClient.setQueryData(queryKeys.environments.list("company-new"), [LOCAL_ENVIRONMENT]);
else if (pending === "settings") queryClient.setQueryData(queryKeys.instance.settings, { defaultEnvironmentId: null });
else queryClient.setQueryData(queryKeys.instance.experimentalSettings, { enableManagedSandboxOnly: false });
for (const environmentId of [null, LOCAL_ENVIRONMENT.id]) queryClient.setQueryData(queryKeys.adapters.availability("company-new", environmentId), [{ type: "claude_local", defaultRunner: "paperclip" }]);
queryClient.setQueryData(["ai-connections", "company-new"], { currentUserId: "user-1", connections: [{ id: "saved", grantId: "grant", companyId: "company-new", provider: "anthropic", method: "subscription", ownership: "personal", ownerUserId: "user-1", status: "connected", isDefault: true }] });
});
await pickSource(/Claude/);
await settle();
expect(mockAdapterBuild.buildAdapterConfig).not.toHaveBeenCalled();
expect(mockAgentsApi.testEnvironment).not.toHaveBeenCalled();
expect(mockAgentsApi.hire).not.toHaveBeenCalled();
await act(async () => finish(pending === "environments" ? [LOCAL_ENVIRONMENT] : pending === "settings" ? { defaultEnvironmentId: null } : { enableManagedSandboxOnly: false }));
await settle();
expect(mockAgentsApi.hire).toHaveBeenCalledTimes(1);
const hired = mockAgentsApi.hire.mock.calls[0] as unknown as [string, { adapterConfig: Record<string, unknown> }];
expect(hired[1].adapterConfig.maxTurnsPerRun).toBe(1000);
} finally { await act(async () => root.unmount()); }
});
it.each(["environments", "settings", "policy"] as const)("reports failed Claude %s resolution and retries without early verification", async failed => {
if (failed === "environments") mockEnvironmentsApi.list.mockRejectedValue(new Error("Unavailable"));
else if (failed === "settings") mockInstanceSettingsApi.get.mockRejectedValue(new Error("Unavailable"));
else mockInstanceSettingsApi.getExperimental.mockRejectedValue(new Error("Unavailable"));
const { root, queryClient } = await openStep4({ adapterType: "claude_local" });
try {
await act(async () => queryClient.setQueryData(["ai-connections", "company-new"], { currentUserId: "user-1", connections: [{ id: "saved", grantId: "grant", companyId: "company-new", provider: "anthropic", method: "subscription", ownership: "personal", ownerUserId: "user-1", status: "connected", isDefault: true }] }));
await pickSource(/Claude/);
await settle();
expect(document.body.textContent).toContain("Could not load execution environment settings");
expect(mockAgentsApi.testEnvironment).not.toHaveBeenCalled();
expect(mockAgentsApi.hire).not.toHaveBeenCalled();
mockEnvironmentsApi.list.mockResolvedValue([LOCAL_ENVIRONMENT]);
mockInstanceSettingsApi.get.mockResolvedValue({ defaultEnvironmentId: null });
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableManagedSandboxOnly: false });
await click("Retry setup");
expect(mockAgentsApi.testEnvironment).toHaveBeenCalledTimes(1);
expect(mockAgentsApi.hire).toHaveBeenCalledTimes(1);
} finally { await act(async () => root.unmount()); }
});
it("does not hire from obsolete Claude discovery after the target changes during verification", async () => {
mockAdapterBuild.buildAdapterConfig.mockImplementation(values => buildClaudeLocalConfig(values!));
mockAdaptersApi.list.mockImplementation(async context => [setupAdapterInfo({ type: "claude_local", defaultRunner: context?.environmentId === "new-local-default" ? "paperclip" : "legacy" })]);
const finishes: Array<(value: typeof passed) => void> = [];
mockAgentsApi.testEnvironment.mockImplementation(() => new Promise(resolve => finishes.push(resolve)));
const { root, queryClient } = await openStep4({ adapterType: "claude_local" });
try {
await pickSource(/Claude/);
await detected();
expect(mockAgentsApi.testEnvironment).toHaveBeenCalledTimes(1);
await act(async () => queryClient.setQueryData(queryKeys.environments.list("company-new"), [{ ...LOCAL_ENVIRONMENT, id: "new-local-default" }]));
await settle();
expect(mockAgentsApi.testEnvironment).toHaveBeenCalledTimes(1);
await act(async () => finishes[0](passed));
await settle();
expect(mockAgentsApi.hire).not.toHaveBeenCalled();
expect(mockAgentsApi.testEnvironment).toHaveBeenCalledTimes(1);
expect(document.body.textContent).toContain("Configuration changed during setup");
await click("Connect");
expect(mockAgentsApi.testEnvironment).toHaveBeenCalledTimes(2);
await act(async () => finishes[1](passed));
await settle();
expect(mockAgentsApi.hire).toHaveBeenCalledTimes(1);
const tested = mockAgentsApi.testEnvironment.mock.calls as unknown as Array<[string, string, { environmentId: string; adapterConfig: Record<string, unknown> }]>;
expect(tested[0][2].adapterConfig.maxTurnsPerRun).toBe(1000);
expect(tested[1][2].environmentId).toBe("new-local-default");
expect(tested[1][2].adapterConfig).not.toHaveProperty("maxTurnsPerRun");
const hired = mockAgentsApi.hire.mock.calls[0] as unknown as [string, { adapterConfig: Record<string, unknown> }];
expect(hired[1].adapterConfig).toEqual(tested[1][2].adapterConfig);
} finally { await act(async () => root.unmount()); }
});
it("unlocks Claude setup when automatic runner availability changes during verification", async () => {
mockAdapterBuild.buildAdapterConfig.mockImplementation(values => buildClaudeLocalConfig(values!));
mockAdaptersApi.list.mockResolvedValue([setupAdapterInfo({ type: "claude_local", defaultRunner: "paperclip" })]);
const finishes: Array<(value: typeof passed) => void> = [];
mockAgentsApi.testEnvironment.mockImplementation(() => new Promise(resolve => finishes.push(resolve)));
const { root, queryClient } = await openStep4({ adapterType: "claude_local" });
try {
await pickSource(/Claude/);
await detected();
expect(mockAgentsApi.testEnvironment).toHaveBeenCalledTimes(1);
expectTesting();
mockAdaptersApi.list.mockResolvedValue([setupAdapterInfo({ type: "claude_local", defaultRunner: "legacy" })]);
await act(async () => queryClient.setQueryData(queryKeys.adapters.availability("company-new", LOCAL_ENVIRONMENT.id), [setupAdapterInfo({ type: "claude_local", defaultRunner: "legacy" })]));
await settle();
expect(document.body.textContent).toContain("Configuration changed during setup");
expect(button("Connect").disabled).toBe(false);
expect(mockAgentsApi.testEnvironment).toHaveBeenCalledTimes(1);
await act(async () => finishes[0](passed));
await settle();
expect(mockAgentsApi.hire).not.toHaveBeenCalled();
await click("Connect");
expect(mockAgentsApi.testEnvironment).toHaveBeenCalledTimes(2);
await act(async () => finishes[1](passed));
await settle();
expect(mockAgentsApi.hire).toHaveBeenCalledTimes(1);
const hired = mockAgentsApi.hire.mock.calls[0] as unknown as [string, { adapterConfig: Record<string, unknown> }];
expect(hired[1].adapterConfig.maxTurnsPerRun).toBe(1000);
} finally { await act(async () => root.unmount()); }
});
it("fails closed on Claude discovery rejection and retries availability before verification", async () => {
mockAdaptersApi.list.mockRejectedValue(new Error("Discovery unavailable"));
const { root, queryClient } = await openStep4({ adapterType: "claude_local" });
try {
await act(async () => queryClient.setQueryData(["ai-connections", "company-new"], {
currentUserId: "user-1", connections: [{ id: "saved", grantId: "grant", companyId: "company-new", provider: "anthropic", method: "subscription", ownership: "personal", ownerUserId: "user-1", status: "connected", isDefault: true }],
}));
await pickSource(/Claude/);
await settle();
expect(mockAgentsApi.testEnvironment).not.toHaveBeenCalled();
expect(mockAgentsApi.hire).not.toHaveBeenCalled();
expect(document.body.textContent).toContain("Could not load runner availability");
mockAdaptersApi.list.mockResolvedValue([setupAdapterInfo({ type: "claude_local", defaultRunner: "paperclip" })]);
await click("Retry setup");
expect(mockAgentsApi.testEnvironment).toHaveBeenCalledTimes(1);
expect(mockAgentsApi.hire).toHaveBeenCalledTimes(1);
} finally { await act(async () => root.unmount()); }
});
it.each(providers)("automatically verifies detected %s once through connection refreshes and advances", async (adapterType, provider, label) => {
let finish!: (result: typeof passed) => void;
mockAgentsApi.testEnvironment.mockReturnValue(new Promise(resolve => { finish = resolve; }));
@@ -3324,7 +3627,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
await detected();
expectTesting();
await act(async () => queryClient.setQueryData(queryKeys.environments.list("company-new"), [
{ ...LOCAL_ENVIRONMENT, id: "new-local-default" },
{ ...LOCAL_ENVIRONMENT, name: "Refreshed local environment" },
]));
await settle();
expectTesting();
+115 -33
View File
@@ -1,7 +1,8 @@
import type { AgentRunnerChoice } from "@paperclipai/shared";
import type { AgentRunner, AgentRunnerChoice } from "@paperclipai/shared";
import { needsClaudeSetupQualification, qualifyClaudeSetup } from "../lib/qualify-claude-setup";
import { agentHarnessType } from "@paperclipai/shared";
import { adaptersApi } from "../api/adapters";
import { CodexRunnerSelect } from "./CodexRunnerSelect";
import { adaptersApi, type AdapterInfo } from "../api/adapters";
import { AgentRunnerSelect } from "./AgentRunnerSelect";
import { healthApi } from "@/api/health";
import { LocalProviderLoginInstructions } from "./AdapterLoginChrome";
import { useLocalAiLogin } from "./ai-connections/useLocalAiLogin";
@@ -599,7 +600,7 @@ function OnboardingWizardInner({
restoreOnboardingAdapterType(saved?.adapterType, saved?.runnerProvider),
);
const [runner, setRunner] = useState<AgentRunnerChoice>(() => saved?.runner === "legacy" || saved?.runner === "paperclip"
? saved.runner : saved?.adapterType === "paperclip_runner" && (saved?.runnerProvider === undefined || saved?.runnerProvider === "codex") ? "paperclip" : "auto");
? saved.runner : saved?.adapterType === "paperclip_runner" && (saved?.runnerProvider === undefined || ["codex", "claude", "acpx"].includes(String(saved?.runnerProvider))) ? "paperclip" : "auto");
/**
* Whether a model source has been chosen, as opposed to which one
* `adapterType` happens to hold.
@@ -626,7 +627,7 @@ function OnboardingWizardInner({
const savedNativeRunnerDraft = saved?.adapterType === "paperclip_runner";
const [cwd, setCwd] = useState((saved?.cwd as string) ?? "");
const [model, setModel] = useState(
savedNativeRunnerDraft && saved?.runnerProvider !== undefined && saved.runnerProvider !== "codex" ? "" : (saved?.model as string) ?? "",
savedNativeRunnerDraft && saved?.runnerProvider !== undefined && !["codex", "claude", "acpx"].includes(String(saved.runnerProvider)) ? "" : (saved?.model as string) ?? "",
);
const [command, setCommand] = useState(
savedNativeRunnerDraft ? "" : (saved?.command as string) ?? "",
@@ -949,14 +950,14 @@ function OnboardingWizardInner({
// managed-sandbox-only redirect (see AgentConfigForm.tsx:618-640). A render
// must not throw, so a resolver error yields no login environment rather
// than an error boundary.
const { data: loginEnvironmentList = [] } = useQuery({
const { data: loginEnvironmentList = [], isSuccess: loginEnvironmentsReady, isFetching: loginEnvironmentsFetching, isError: loginEnvironmentsFailed, refetch: refetchLoginEnvironments } = useQuery({
queryKey: createdCompanyId
? queryKeys.environments.list(createdCompanyId)
: ["environments", "none"],
queryFn: () => environmentsApi.list(createdCompanyId!),
enabled: Boolean(createdCompanyId) && effectiveOnboardingOpen && step === 4,
});
const { data: instanceSettingsForLogin } = useQuery({
const { data: instanceSettingsForLogin, isSuccess: loginSettingsReady, isFetching: loginSettingsFetching, isError: loginSettingsFailed, refetch: refetchLoginSettings } = useQuery({
queryKey: queryKeys.instance.settings,
queryFn: () => instanceSettingsApi.get(),
enabled: effectiveOnboardingOpen && step === 4,
@@ -964,7 +965,7 @@ function OnboardingWizardInner({
// Wanted across the whole arc, not just the connect step. The progress strip
// reads it too — see `enteredFromCloud` — and a value fetched only on step 4
// would let the strip change length as the customer walked through it.
const { data: experimentalSettingsForLogin } = useQuery({
const { data: experimentalSettingsForLogin, isSuccess: loginPolicyReady, isFetching: loginPolicyFetching, isError: loginPolicyFailed, refetch: refetchLoginPolicy } = useQuery({
queryKey: queryKeys.instance.experimentalSettings,
queryFn: () => instanceSettingsApi.getExperimental(),
enabled: effectiveOnboardingOpen && step >= 3 && step <= 5,
@@ -987,17 +988,62 @@ function OnboardingWizardInner({
loginEnvironmentList,
experimentalSettingsForLogin?.enableManagedSandboxOnly,
]);
const loginEnvironmentSettingsReady = loginEnvironmentsReady && loginSettingsReady && loginPolicyReady
&& !loginEnvironmentsFetching && !loginSettingsFetching && !loginPolicyFetching;
const runnerDiscovery = useQuery({
queryKey: queryKeys.adapters.availability(createdCompanyId, resolvedLoginEnvironmentId),
queryFn: () => adaptersApi.list({ companyId: createdCompanyId!, environmentId: resolvedLoginEnvironmentId }),
enabled: Boolean(createdCompanyId) && effectiveOnboardingOpen && step === 4,
enabled: Boolean(createdCompanyId) && effectiveOnboardingOpen && step === 4
&& loginEnvironmentSettingsReady,
});
const needsRunnerDiscovery = ["codex_local", "claude_local"].includes(adapterType) && runner === "auto";
const resolvedRunner = runner === "auto"
? runnerDiscovery.data?.find(item => item.type === adapterType)?.defaultRunner
: runner;
const runnerDiscoveryReady = !needsRunnerDiscovery
|| (loginEnvironmentSettingsReady
&& runnerDiscovery.isSuccess && !runnerDiscovery.isFetching && resolvedRunner !== undefined);
const runnerDiscoveryError = needsRunnerDiscovery && (loginEnvironmentsFailed || loginSettingsFailed || loginPolicyFailed)
? "Could not load execution environment settings. Retry setup."
: needsRunnerDiscovery && runnerDiscovery.isError
? "Could not load runner availability. Retry setup."
: needsRunnerDiscovery && runnerDiscovery.isSuccess && resolvedRunner === undefined
? "Runner availability is missing for this harness. Retry setup."
: null;
async function retryRunnerAvailability() {
if (!loginEnvironmentSettingsReady) {
await Promise.all([refetchLoginEnvironments(), refetchLoginSettings(), refetchLoginPolicy()]);
} else {
await runnerDiscovery.refetch();
}
}
const visibleAdapterEnvError = adapterEnvError ?? runnerDiscoveryError;
const runnerSelectionKey = JSON.stringify([createdCompanyId, resolvedLoginEnvironmentId, adapterType, runner, adapterType === "codex_local" && runner === "auto" ? undefined : resolvedRunner]);
const runnerSelectionRef = useRef(runnerSelectionKey);
runnerSelectionRef.current = runnerSelectionKey;
const resolvedLoginEnvironment = useMemo(
() =>
loginEnvironmentList.find((environment) => environment.id === resolvedLoginEnvironmentId) ??
null,
[loginEnvironmentList, resolvedLoginEnvironmentId],
);
const claudeSetupSelectionKey = JSON.stringify([createdCompanyId, adapterType, runner, resolvedRunner, model, command,
args, url, credentialMode, apiKey, selectedSavedKey, selectedApiKey?.binding, savedSubscription?.binding,
resolvedLoginEnvironmentId, resolvedLoginEnvironment?.config]);
const claudeSetupSelectionRef = useRef(claudeSetupSelectionKey);
claudeSetupSelectionRef.current = claudeSetupSelectionKey;
const activeClaudeQualification = useRef<{ attempt: number; selection: string } | null>(null);
useEffect(() => {
const active = activeClaudeQualification.current;
if (!active || active.selection === claudeSetupSelectionKey) return;
activeClaudeQualification.current = null;
connectAttemptRef.current++;
hiringAgentRef.current = null;
autoConnectStartedRef.current = true;
setLoading(false);
setAdapterEnvLoading(false);
setError("Configuration changed during setup. Connect again to test the current settings.");
}, [claudeSetupSelectionKey]);
const adapterSetupSelectionRef = useRef("");
const adapterEnvErrorSelectionRef = useRef<string | null>(null);
adapterSetupSelectionRef.current = adapterType === "codex_local" && runner === "auto" && resolvedLoginEnvironment?.driver === "sandbox"
@@ -1039,9 +1085,6 @@ function OnboardingWizardInner({
}, effectiveOnboardingOpen && step === 4 && canUseLocalLogin && credentialMode !== "api" &&
Boolean(managedProvider) && !savedSubscription && !savedKeys.storedLogin.data && !managedBindingForStep(),
);
// A result from a previous selection must not hire or advance this wizard.
// Environment query updates are not user navigation: the test resolves its
// own environment, and those updates must not interrupt the pending attempt.
useEffect(() => {
autoConnectStartedRef.current = false;
hiringAgentRef.current = null;
@@ -1190,7 +1233,7 @@ function OnboardingWizardInner({
* Anything that gates this step belongs in here, so the next one is added
* once rather than twice.
*/
const connectStepReady = sourceSelected && !adapterEnvLoading && !savedKeys.loading;
const connectStepReady = sourceSelected && !adapterEnvLoading && !savedKeys.loading && runnerDiscoveryReady;
/**
* Whether this step has a sign-in to do before it can hire.
@@ -1333,7 +1376,7 @@ function OnboardingWizardInner({
// Not before the login is stored. "Connecting" starts at the paste now,
// ahead of the server confirming anything, so a hire from here would go
// out against a source with no credential to run on.
if (!connectCredentialStored) return;
if (!connectCredentialStored || !runnerDiscoveryReady) return;
// No success state: the step advances. The hold is so "Connecting" is
// legible as a state rather than a flicker on the way out — a step that
// left the instant a paste landed would read as the paste having gone
@@ -1372,7 +1415,7 @@ function OnboardingWizardInner({
return () => clearTimeout(t);
}
return;
}, [step, connectPhase, credentialMode, connectStepNeedsLogin, connectCredentialStored]);
}, [step, connectPhase, credentialMode, connectStepNeedsLogin, connectCredentialStored, runnerDiscoveryReady]);
/**
* The button's four faces, and which of them can be pressed.
@@ -1557,7 +1600,7 @@ function OnboardingWizardInner({
setAdapterEnvResult(null);
adapterEnvResultAppliedStoredLoginRef.current = false;
if (adapterEnvErrorSelectionRef.current !== adapterSetupSelectionRef.current) setAdapterEnvError(null);
}, [step, adapterType, runner, model, command, args, url, credentialMode, apiKey, selectedSavedKey, selectedApiKey?.id, savedSubscription?.id, createdCompanyId, resolvedLoginEnvironmentId, runner === "auto" ? runnerDiscovery.data?.find(item => item.type === "codex_local")?.defaultRunner : undefined]);
}, [step, adapterType, runner, model, command, args, url, credentialMode, apiKey, selectedSavedKey, selectedApiKey?.id, savedSubscription?.id, createdCompanyId, resolvedLoginEnvironmentId, runner === "auto" ? runnerDiscovery.data?.find(item => item.type === adapterType)?.defaultRunner : undefined]);
/**
* Leaving the step puts the row back to a question.
@@ -1833,12 +1876,13 @@ function OnboardingWizardInner({
}
}
function buildAdapterConfig(bindApiKey = false): Record<string, unknown> {
function buildAdapterConfig(bindApiKey = false, setupRunner?: AgentRunner): Record<string, unknown> {
const adapter = getUIAdapter(adapterType);
const config = adapter.buildAdapterConfig({
...defaultCreateValues,
adapterType,
runner,
resolvedRunner: setupRunner ?? resolvedRunner,
model:
adapterType === "gemini_local"
? model || DEFAULT_GEMINI_LOCAL_MODEL
@@ -1904,7 +1948,9 @@ function OnboardingWizardInner({
adapterConfigOverride?: Record<string, unknown>,
appliedStoredClaudeLoginBinding = false,
isCurrent = () => true,
onQualifiedConfig?: (config: Record<string, unknown>, adapters: AdapterInfo[], environmentId: string | null) => void,
): Promise<AdapterEnvironmentTestResult | null> {
if (!runnerDiscoveryReady) return null;
if (!createdCompanyId) {
setAdapterEnvError(
"Create or select an organization before testing adapter environment."
@@ -2005,12 +2051,29 @@ function OnboardingWizardInner({
}
}
}
if (!testIsCurrent()) return null;
const selection = { adapterType, runner, savedAgentId: createdAgentId,
environment: environmentList.find(environment => environment.id === environmentId) };
const qualified = await qualifyClaudeSetup({ ...selection,
readAdapters: () => adaptersApi.list({ companyId: createdCompanyId, environmentId }),
probeEnvironment: () => environmentsApi.probe(environmentId!, createdCompanyId),
isCurrent: testIsCurrent,
});
if (!testIsCurrent() || (needsClaudeSetupQualification(selection) && !qualified)) return null;
let testedConfig = adapterConfigOverride ?? buildAdapterConfig();
if (qualified) {
testedConfig = buildAdapterConfig(true, qualified.runner);
if (appliedStoredClaudeLoginBinding) testedConfig.env = {
...(isEnvRecord(testedConfig.env) ? testedConfig.env : {}), ...buildFixedClaudeOAuthBinding(),
};
onQualifiedConfig?.(testedConfig, qualified.adapters, environmentId);
}
const result = await agentsApi.testEnvironment(
createdCompanyId,
adapterType,
{
adapterConfig: adapterConfigOverride ?? buildAdapterConfig(),
...(adapterType === "codex_local" && runner !== "auto" ? { runner } : {}),
adapterConfig: testedConfig,
...(["codex_local", "claude_local"].includes(adapterType) && runner !== "auto" ? { runner } : {}),
...(managedBindingForStep() ? { aiConnection: managedBindingForStep() } : {}),
environmentId,
}
@@ -2088,13 +2151,19 @@ function OnboardingWizardInner({
setStep(5);
return;
}
if (hiringAgentRef.current !== null) return;
if (!runnerDiscoveryReady || hiringAgentRef.current !== null) return;
const attempt = ++connectAttemptRef.current;
const isCurrent = () => connectAttemptRef.current === attempt && stillTheSameCompany(createdCompanyId);
const guardClaudeSetup = adapterType === "claude_local" && runner === "auto";
const setupSelection = claudeSetupSelectionRef.current;
activeClaudeQualification.current = guardClaudeSetup ? { attempt, selection: setupSelection } : null;
const isCurrent = () => connectAttemptRef.current === attempt && stillTheSameCompany(createdCompanyId)
&& runnerSelectionRef.current === runnerSelectionKey
&& (!guardClaudeSetup || claudeSetupSelectionRef.current === setupSelection);
hiringAgentRef.current = attempt;
autoConnectStartedRef.current = true;
setLoading(true);
setError(null);
let qualifiedDiscovery: { adapters: AdapterInfo[]; environmentId: string | null } | undefined;
try {
if (adapterType === "opencode_local") {
const selectedModelId = model.trim();
@@ -2175,7 +2244,7 @@ function OnboardingWizardInner({
setClaudeOAuthStatus(storedClaudeLogin);
}
const shouldApplyStoredClaudeLogin = storedClaudeLogin !== null;
const hireAdapterConfig = shouldApplyStoredClaudeLogin
let hireAdapterConfig = shouldApplyStoredClaudeLogin
? {
...baseAdapterConfig,
env: {
@@ -2194,14 +2263,15 @@ function OnboardingWizardInner({
// stored login, would lock out a customer who has since fixed the
// problem or signed in.
const cachedUsable =
adapterEnvResult &&
!needsClaudeSetupQualification({ adapterType, runner, savedAgentId: createdAgentId, environment: resolvedLoginEnvironment }) && adapterEnvResult &&
adapterEnvResultAppliedStoredLoginRef.current === shouldApplyStoredClaudeLogin &&
!blocksAgentCreate(adapterEnvResult)
? adapterEnvResult
: null;
const result =
cachedUsable ??
(await runAdapterEnvironmentTest(hireAdapterConfig, shouldApplyStoredClaudeLogin, isCurrent));
(await runAdapterEnvironmentTest(hireAdapterConfig, shouldApplyStoredClaudeLogin, isCurrent,
(config, adapters, environmentId) => { hireAdapterConfig = config; qualifiedDiscovery = { adapters, environmentId }; }));
if (!result || !isCurrent()) return;
// Block the hire on a failed environment test. Also block it on a
// pass or a warn result that reports missing authentication — the
@@ -2261,7 +2331,7 @@ function OnboardingWizardInner({
role: agentRole,
adapterType,
adapterConfig: hireAdapterConfig,
...(adapterType === "codex_local" && runner !== "auto" ? { runner } : {}),
...(["codex_local", "claude_local"].includes(adapterType) && runner !== "auto" ? { runner } : {}),
...(shouldApplyStoredClaudeLogin ? { applyStoredClaudeLogin: true } : {}),
// The server owns what the first agent is told now: this marker seeds
// the chief-of-staff persona over the agent's entry instruction file.
@@ -2297,7 +2367,13 @@ function OnboardingWizardInner({
setError(err instanceof Error ? err.message : "Failed to create agent");
} finally {
if (hiringAgentRef.current === attempt) hiringAgentRef.current = null;
if (isCurrent()) setLoading(false);
if (activeClaudeQualification.current?.attempt === attempt) activeClaudeQualification.current = null;
if (isCurrent()) {
if (qualifiedDiscovery) queryClient.setQueryData(
queryKeys.adapters.availability(createdCompanyId, qualifiedDiscovery.environmentId), qualifiedDiscovery.adapters,
);
setLoading(false);
}
// Authentication is already saved. A failed probe or hire must offer a
// retry with that account, rather than keep the completed login busy.
if (connectCredentialStored && isCurrent()) {
@@ -2957,12 +3033,12 @@ function OnboardingWizardInner({
) : null}
</motion.div>
{sourceSelected && adapterType === "codex_local" && <details className="space-y-3">
{sourceSelected && ["codex_local", "claude_local"].includes(adapterType) && <details className="space-y-3">
<summary className="cursor-pointer text-sm text-muted-foreground">Advanced</summary>
<CodexRunnerSelect value={runner} disabled={loading || adapterEnvLoading}
<AgentRunnerSelect value={runner} disabled={loading || adapterEnvLoading}
pending={runnerDiscovery.isFetching} error={runnerDiscovery.error?.message} onRetry={() => { void runnerDiscovery.refetch(); }}
defaultRunner={runnerDiscovery.data?.find(item => item.type === "codex_local")?.defaultRunner}
supportedRunners={runnerDiscovery.data?.find(item => item.type === "codex_local")?.supportedRunners}
defaultRunner={runnerDiscovery.data?.find(item => item.type === adapterType)?.defaultRunner}
supportedRunners={runnerDiscovery.data?.find(item => item.type === adapterType)?.supportedRunners}
onChange={setRunner} />
</details>}
@@ -2987,14 +3063,20 @@ function OnboardingWizardInner({
the hire, because `handleGiveHeartbeat` clears it in its
`finally` after the early return — so a genuine block still
shows its checks, which is the whole reason this is here. */}
{isLocalAdapter && !loading && (adapterEnvError || (adapterEnvResult && adapterEnvResult.status !== "pass")) && (
{isLocalAdapter && !loading && (visibleAdapterEnvError || (adapterEnvResult && adapterEnvResult.status !== "pass")) && (
<div className="space-y-2 rounded-md border border-border p-3">
{adapterEnvError && (
{visibleAdapterEnvError && (
<div className="rounded-md border border-destructive/30 bg-destructive/10 px-2.5 py-2 text-(length:--text-micro) text-destructive">
{adapterEnvError}
{visibleAdapterEnvError}
</div>
)}
{runnerDiscoveryError && (
<Button type="button" variant="outline" disabled={runnerDiscovery.isFetching || loginEnvironmentsFetching || loginSettingsFetching || loginPolicyFetching} onClick={() => void retryRunnerAvailability()}>
Retry setup
</Button>
)}
{adapterEnvResult &&
adapterEnvResult.status === "pass" ? (
<div className="space-y-2 animate-in fade-in slide-in-from-bottom-1 duration-300">
@@ -225,8 +225,6 @@ export function AgentBasicsDialog({
placeholder="Choose a native harness…"
onValueChange={provider => { setRunnerProvider(provider); setAdapterType("paperclip_runner"); }}
options={[
{ value: "codex", label: "Codex (Paperclip Runner)" },
{ value: "claude", label: "Claude Code (Paperclip Runner)" },
{ value: "grok", label: "Grok Build (Paperclip Runner)" },
{ value: "opencode", label: "OpenCode (Paperclip Runner)" },
]} />
+74 -40
View File
@@ -4,8 +4,8 @@ import { AgentCharacter } from "../AgentCharacter";
import { useAgentAppearanceDraft } from "../../hooks/useAgentAppearanceDraft";
import { AiConnectionField, aiProviderForAdapter } from "../ai-connections/AiConnectionField";
import type { AiConnectionBinding } from "@paperclipai/shared";
import type { AgentRunnerChoice } from "@paperclipai/shared";
import { CodexRunnerSelect } from "../CodexRunnerSelect";
import type { AgentRunner, AgentRunnerChoice } from "@paperclipai/shared";
import { AgentRunnerSelect } from "../AgentRunnerSelect";
import { SelectPopover } from "../ui/select";
import { DEFAULT_CODEX_LOCAL_MODEL } from "@paperclipai/adapter-codex-local";
import {
@@ -15,6 +15,7 @@ import {
setupProviderKeys,
} from "@/lib/agent-setup-fields";
import { testAgentSetup } from "@/lib/test-agent-setup";
import { needsClaudeSetupQualification, qualifyClaudeSetup } from "@/lib/qualify-claude-setup";
import { useCloudInstance } from "@/hooks/useCloudInstance";
import { isNewAgentAdapterAllowed } from "@/lib/new-agent-adapters";
import { useEffect, useRef, useState } from "react";
@@ -93,9 +94,9 @@ export function NewAgentSetup() {
key={`${selectedCompanyId}:${params.get("name")}:${params.get("adapterType")}:${params.get("runnerProvider")}:${params.get("runner")}`}
companyId={selectedCompanyId}
name={params.get("name") ?? ""}
adapterType={params.get("adapterType") === "paperclip_runner" && (params.get("runnerProvider") ?? "codex") === "codex" ? "codex_local" : params.get("adapterType") ?? ""}
initialRunner={params.get("runner") ?? (params.get("adapterType") === "paperclip_runner" && (params.get("runnerProvider") ?? "codex") === "codex" ? "paperclip" : "auto")}
preserveNativeCodex={params.get("adapterType") === "paperclip_runner" && (params.get("runnerProvider") ?? "codex") === "codex"}
adapterType={params.get("adapterType") === "paperclip_runner" && ["codex", "claude"].includes(params.get("runnerProvider") ?? "codex") ? params.get("runnerProvider") === "claude" ? "claude_local" : "codex_local" : params.get("adapterType") ?? ""}
initialRunner={params.get("runner") ?? (params.get("adapterType") === "paperclip_runner" && ["codex", "claude"].includes(params.get("runnerProvider") ?? "codex") ? "paperclip" : "auto")}
preserveNativeHarness={params.get("adapterType") === "paperclip_runner" && ["codex", "claude"].includes(params.get("runnerProvider") ?? "codex")}
runnerProvider={params.get("runnerProvider") ?? "codex"}
createdAgentId={params.get("createdAgentId")}
/>
@@ -108,7 +109,7 @@ function Setup({
adapterType,
runnerProvider,
initialRunner,
preserveNativeCodex,
preserveNativeHarness,
createdAgentId,
}: {
companyId: string;
@@ -116,7 +117,7 @@ function Setup({
adapterType: string;
runnerProvider: string;
initialRunner: string;
preserveNativeCodex: boolean;
preserveNativeHarness: boolean;
createdAgentId: string | null;
}) {
const navigate = useNavigate();
@@ -124,7 +125,7 @@ function Setup({
const { openNewIssue } = useDialogActions();
const appearanceDraft = useAgentAppearanceDraft(`${companyId}:new-agent`);
const [runner, setRunner] = useState<AgentRunnerChoice>(initialRunner === "legacy" || initialRunner === "paperclip" ? initialRunner : "auto");
const requestAdapterType = preserveNativeCodex && runner === "paperclip" ? "paperclip_runner" : adapterType;
const requestAdapterType = preserveNativeHarness && runner === "paperclip" ? "paperclip_runner" : adapterType;
const isRunner = adapterType === "paperclip_runner";
const isDot = isRunner && runnerProvider === "openai_dot";
const brandType = isDot ? "openai_dot" : isRunner
@@ -192,6 +193,7 @@ function Setup({
);
const [error, setError] = useState<string | null>(null);
const generation = useRef(0);
const qualificationTransition = useRef<{ run: number; companyId: string; environmentId: string | null; runner: AgentRunner } | null>(null);
const savingRef = useRef(false);
useEffect(
() => () => {
@@ -200,6 +202,7 @@ function Setup({
[],
);
const resetTest = () => {
qualificationTransition.current = null;
generation.current++;
setResult(null);
setTestState("idle");
@@ -286,12 +289,19 @@ function Setup({
: "Could not resolve the environment.";
}
const environment = envs.data?.find((env) => env.id === environmentId);
const setupEnvironmentKey = JSON.stringify([companyId, environmentId, environment?.config]);
const setupEnvironmentRef = useRef("");
setupEnvironmentRef.current = setupEnvironmentKey;
useEffect(() => { resetTest(); }, [setupEnvironmentKey]);
const adapters = useQuery({
queryKey: queryKeys.adapters.availability(companyId, environmentId),
queryFn: () => adaptersApi.list({ companyId, environmentId }),
});
const defaultRunner = adapters.data?.find(adapter => adapter.type === "codex_local")?.defaultRunner;
const defaultRunner = adapters.data?.find(adapter => adapter.type === adapterType)?.defaultRunner;
useEffect(() => {
const transition = qualificationTransition.current;
if (transition?.run === generation.current && transition.companyId === companyId
&& transition.environmentId === environmentId && transition.runner === defaultRunner) return;
resetTest();
}, [companyId, environmentId, runner === "auto" ? defaultRunner : undefined]);
const sandboxProvider =
@@ -343,9 +353,9 @@ function Setup({
!adapter.disabled &&
!getAdapterDisplay(adapterType).comingSoon,
) && (requestAdapterType !== "paperclip_runner" || adapterType === "paperclip_runner" || adapterInventory.data?.some(adapter => adapter.type === "paperclip_runner" && adapter.loaded && !adapter.disabled));
const ready = Boolean(
const connectionSettingsReady = Boolean(
available &&
(adapterType !== "codex_local" || runner !== "auto" || Boolean(adapters.data)) &&
(!["codex_local", "claude_local"].includes(adapterType) || runner !== "auto" || Boolean(adapters.data)) &&
!environmentError &&
!envs.isPending &&
!settings.isPending &&
@@ -361,16 +371,26 @@ function Setup({
!caps.error &&
(!(managedOnly || forced.forced) || environmentId),
);
const needsCurrentSetup = ["codex_local", "claude_local"].includes(adapterType);
const ready = connectionSettingsReady && (!needsCurrentSetup || (
envs.isSuccess && !envs.isFetching
&& settings.isSuccess && !settings.isFetching
&& experimental.isSuccess && !experimental.isFetching
&& general.isSuccess && !general.isFetching
&& (runner !== "auto" || (adapters.isSuccess && !adapters.isFetching))
));
const busy = testState === "running" || saving;
function buildConfig(
nextConnection = connection,
binding = selectedBinding,
setupRunner?: AgentRunner,
): Record<string, unknown> {
const values = {
...defaultCreateValues,
adapterType,
runner,
resolvedRunner: runner === "auto" ? setupRunner ?? defaultRunner : runner,
model:
model || (brandType === "codex_local" ? DEFAULT_CODEX_LOCAL_MODEL : ""),
thinkingEffort: effort,
@@ -386,9 +406,9 @@ function Setup({
}
: {}),
};
const config = getUIAdapter(adapterType).buildAdapterConfig(values);
if (preserveNativeCodex && runner === "paperclip") {
Object.assign(config, { provider: "codex", codexPermissionMode: "never", lifecycleMode: "per_turn" });
const config = getUIAdapter(preserveNativeHarness && runner === "paperclip" ? "paperclip_runner" : adapterType).buildAdapterConfig(preserveNativeHarness && runner === "paperclip" ? { ...values, adapterSchemaValues: runnerProvider === "claude" ? { provider: "acpx", acpxAgent: "claude", acpxPermissionMode: "approve-all" } : { provider: "codex" } } : values);
if (preserveNativeHarness && runner === "paperclip") {
Object.assign(config, runnerProvider === "claude" ? { provider: "acpx", acpxAgent: "claude", acpxPermissionMode: "approve-all", lifecycleMode: "per_turn" } : { provider: "codex", codexPermissionMode: "never", lifecycleMode: "per_turn" });
}
if (isRunner)
Object.assign(config, {
@@ -423,7 +443,7 @@ function Setup({
}
return config;
}
function preparedConfig(nextConnection = connection) {
function preparedConfig(nextConnection = connection, setupRunner?: AgentRunner) {
if (isDot && !allowUnmeteredProvider) throw new Error("Acknowledge external provider billing before creating your Dot agent.");
if (multiProvider && (!model.trim() || !model.includes("/")))
throw new Error("Choose or enter a model in provider/model format.");
@@ -452,7 +472,7 @@ function Setup({
}
if (usingKimiApi && !kimiModel.trim())
throw new Error("Enter the Kimi API model name.");
return buildConfig(nextConnection);
return buildConfig(nextConnection, selectedBinding, setupRunner);
}
function pendingCredentials(nextConnection = connection) {
if (aiBinding || nextConnection?.aiConnection) return {};
@@ -464,34 +484,48 @@ function Setup({
};
}
async function runTest(nextConnection = connection): Promise<boolean> {
if (!ready) return false;
async function runTest(nextConnection = connection): Promise<Record<string, unknown> | null> {
if (!ready) return null;
const run = ++generation.current;
const setupEnvironment = setupEnvironmentRef.current;
setTestState("running");
setResult(null);
setError(null);
try {
const config = await preparedConfig(nextConnection);
preparedConfig(nextConnection);
const selection = { adapterType, runner, savedAgentId: createdAgentId, environment };
const qualified = await qualifyClaudeSetup({ ...selection,
readAdapters: () => adaptersApi.list({ companyId, environmentId }),
probeEnvironment: () => environmentsApi.probe(environmentId!, companyId),
isCurrent: () => run === generation.current && setupEnvironmentRef.current === setupEnvironment,
});
if (run !== generation.current || setupEnvironmentRef.current !== setupEnvironment
|| (needsClaudeSetupQualification(selection) && !qualified)) return null;
if (qualified) {
qualificationTransition.current = { run, companyId, environmentId, runner: qualified.runner };
cache.setQueryData(queryKeys.adapters.availability(companyId, environmentId), qualified.adapters);
}
const config = preparedConfig(nextConnection, qualified?.runner);
const tested = await testAgentSetup({
companyId,
adapterType: requestAdapterType,
providerAdapter: brandType,
...(adapterType === "codex_local" && runner !== "auto" ? { runner } : {}),
...(["codex_local", "claude_local"].includes(adapterType) && runner !== "auto" ? { runner } : {}),
adapterConfig: config,
testCredentials: pendingCredentials(nextConnection),
aiConnection: runtimeAiBinding ?? nextConnection?.aiConnection,
environmentId,
});
if (run !== generation.current) return false;
if (run !== generation.current) return null;
setResult(tested);
setTestState(blocking(tested) ? "fail" : tested.status);
return (
!blocking(tested) &&
(!connectionAdapter ||
tested.checks.some((check) =>
check.code.endsWith("hello_probe_passed"),
check.code.endsWith("hello_probe_passed") || check.code === "ai_connection_api_key_reverified",
))
);
) ? config : null;
} catch (cause) {
if (run === generation.current) {
setError(
@@ -499,7 +533,7 @@ function Setup({
);
setTestState("fail");
}
return false;
return null;
}
}
async function finish() {
@@ -520,11 +554,11 @@ function Setup({
const staged: Array<{ remove: () => Promise<unknown> }> = [];
let hired = false;
try {
if (connectionAdapter && testState === "idle" && !(await runTest())) return;
const config = preparedConfig();
const credentials = pendingCredentials();
// Untested entered keys must pass a probe before they can be stored.
if (Object.keys(credentials).length && !(await runTest())) return;
const config = (connectionAdapter && testState === "idle") || Object.keys(credentials).length
? await runTest()
: preparedConfig();
if (!config) return;
for (const [key, value] of Object.entries(credentials)) {
const store = connectionAdapter
? storeProviderApiKey
@@ -549,7 +583,7 @@ function Setup({
role: existing.length ? "general" : "ceo",
...(leader ? { reportsTo: leader.id } : {}),
adapterType: requestAdapterType,
...(adapterType === "codex_local" && runner !== "auto" ? { runner } : {}),
...(["codex_local", "claude_local"].includes(adapterType) && runner !== "auto" ? { runner } : {}),
adapterConfig: config,
defaultEnvironmentId:
environmentOverride ||
@@ -764,7 +798,7 @@ function Setup({
center
/>
</div>
{ready ? <AgentProviderConnection
{connectionSettingsReady ? <fieldset disabled={!ready} className="contents"><AgentProviderConnection
key={environmentId ?? "local"}
companyId={companyId}
adapterType={connectionAdapter}
@@ -797,7 +831,7 @@ function Setup({
setRuntimeAiBinding(undefined);
resetTest();
}}
testConnection={runTest}
testConnection={async next => (await runTest(next)) !== null}
testError={
error ??
(
@@ -816,13 +850,13 @@ function Setup({
setRuntimeAiBinding(undefined);
setScreen("runtime");
}}
/> : <p role="status" className="text-sm text-muted-foreground">Loading connection settings…</p>}
{adapterType === "codex_local" && <details className="mt-5 space-y-3">
/></fieldset> : <p role="status" className="text-sm text-muted-foreground">Loading connection settings…</p>}
{["codex_local", "claude_local"].includes(adapterType) && <details className="mt-5 space-y-3">
<summary className="cursor-pointer text-sm text-muted-foreground">Advanced</summary>
<CodexRunnerSelect value={runner} disabled={busy}
<AgentRunnerSelect value={runner} disabled={busy}
pending={adapters.isFetching} error={adapters.error?.message} onRetry={() => { void adapters.refetch(); }}
defaultRunner={adapters.data?.find(item => item.type === "codex_local")?.defaultRunner}
supportedRunners={adapters.data?.find(item => item.type === "codex_local")?.supportedRunners}
defaultRunner={adapters.data?.find(item => item.type === adapterType)?.defaultRunner}
supportedRunners={adapters.data?.find(item => item.type === adapterType)?.supportedRunners}
onChange={value => { setRunner(value); resetTest(); }} />
</details>}
</OnboardingCard>
@@ -1168,12 +1202,12 @@ function Setup({
</Field>
</div>
)}
{adapterType === "codex_local" && <details className="space-y-3">
{["codex_local", "claude_local"].includes(adapterType) && <details className="space-y-3">
<summary className="cursor-pointer text-sm text-muted-foreground">Advanced</summary>
<CodexRunnerSelect value={runner} disabled={busy}
<AgentRunnerSelect value={runner} disabled={busy}
pending={adapters.isFetching} error={adapters.error?.message} onRetry={() => { void adapters.refetch(); }}
defaultRunner={adapters.data?.find(item => item.type === "codex_local")?.defaultRunner}
supportedRunners={adapters.data?.find(item => item.type === "codex_local")?.supportedRunners}
defaultRunner={adapters.data?.find(item => item.type === adapterType)?.defaultRunner}
supportedRunners={adapters.data?.find(item => item.type === adapterType)?.supportedRunners}
onChange={value => { setRunner(value); resetTest(); }} />
</details>}
</section>
@@ -1,7 +1,7 @@
import { useEffect, useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode, type Ref } from "react";
import { useQuery } from "@tanstack/react-query";
import { ArrowLeft, Check, ChevronDown, Plus, RotateCcw, Search, X, Zap } from "lucide-react";
import { aiRuntimeConnectionBindingSchema, type Agent, type IssueAssigneeAdapterOverrides } from "@paperclipai/shared";
import { aiRuntimeConnectionBindingSchema, agentHarnessType, type Agent, type IssueAssigneeAdapterOverrides } from "@paperclipai/shared";
import { agentsApi, type AdapterModel } from "@/api/agents";
import { queryKeys } from "@/lib/queryKeys";
import { cn } from "@/lib/utils";
@@ -51,7 +51,7 @@ const HARNESS_LABELS: Record<string, string> = {
function harnessLabel(agent: Agent | undefined): string {
if (!agent) return "Choose an agent";
const harness = HARNESS_LABELS[agent.adapterType] ?? agent.adapterType;
const harness = HARNESS_LABELS[agentHarnessType(agent.adapterType, agent.adapterConfig)] ?? agent.adapterType;
const provider = composerCatalogProvider(agent);
const binding = aiRuntimeConnectionBindingSchema.safeParse(agent?.runtimeConfig?.aiConnection).data;
const poolId = binding?.mode === "router" ? binding.connectionId : undefined;
@@ -3,7 +3,7 @@ import { claudeLocalReasoningEffortsForModel } from "@paperclipai/adapter-claude
import { resolvePaperclipRunnerModel } from "@paperclipai/adapter-utils";
import { DEFAULT_GROK_LOCAL_MODEL, grokLocalReasoningEffortsForModel } from "@paperclipai/adapter-grok-local";
import { DEFAULT_KIMI_LOCAL_MODEL, modelSupportsEffort, KIMI_SUPPORTED_EFFORTS } from "@paperclipai/adapter-kimi-local";
import { aiConnectionBindingSchema, aiRuntimeConnectionBindingSchema, type Agent, type IssueAssigneeAdapterOverrides } from "@paperclipai/shared";
import { aiConnectionBindingSchema, aiRuntimeConnectionBindingSchema, agentHarnessType, type Agent, type IssueAssigneeAdapterOverrides } from "@paperclipai/shared";
export interface ComposerRunSettings {
model: string | null;
@@ -66,12 +66,12 @@ export function composerEfforts(agent: Agent | undefined, model: string, catalog
: agent.adapterType === "kimi_local" ? DEFAULT_KIMI_LOCAL_MODEL : "");
if (!effectiveModel) return [];
const pool = aiRuntimeConnectionBindingSchema.safeParse(agent.runtimeConfig?.aiConnection).data?.mode === "router";
if (pool && agent.adapterType === "paperclip_runner") return isCodexLocalKnownModel(effectiveModel) ? codexLocalReasoningEffortsForModel(effectiveModel) : [];
if (pool && agent.adapterType === "paperclip_runner") return isCodexLocalKnownModel(effectiveModel) ? codexLocalReasoningEffortsForModel(effectiveModel) : /^claude-/.test(effectiveModel) ? claudeLocalReasoningEffortsForModel(effectiveModel) : [];
if (agent.adapterType === "codex_local" || (agent.adapterType === "paperclip_runner" && composerCatalogProvider(agent) === "codex")) {
return isCodexLocalKnownModel(effectiveModel) ? codexLocalReasoningEffortsForModel(effectiveModel) : [];
}
if (!catalogIds.includes(effectiveModel)) return [];
if (agent.adapterType === "claude_local") return claudeLocalReasoningEffortsForModel(effectiveModel);
if (agentHarnessType(agent.adapterType, agent.adapterConfig) === "claude_local") return claudeLocalReasoningEffortsForModel(effectiveModel);
if (agent.adapterType === "grok_local") return grokLocalReasoningEffortsForModel(effectiveModel);
if (agent.adapterType === "pi_local") return ["off", "minimal", "low", "medium", "high", "xhigh"];
if (agent.adapterType === "kimi_local" && typeof agent.adapterConfig.engine === "string"
+67
View File
@@ -0,0 +1,67 @@
import { describe, expect, it, vi } from "vitest";
import type { AdapterInfo } from "../api/adapters";
import { qualifyClaudeSetup } from "./qualify-claude-setup";
const image = `registry.example.test/runtime@sha256:${"a".repeat(64)}`;
const discovery = (runner: "paperclip" | "legacy", overrides = {}) => [
{ type: "claude_local", defaultRunner: runner, supportedRunners: [runner], ...overrides },
{ type: "paperclip_runner" },
] as AdapterInfo[];
function setup() {
return {
adapterType: "claude_local", runner: "auto" as const,
environment: { driver: "sandbox" as const, config: { provider: "daytona", image } },
readAdapters: vi.fn().mockResolvedValueOnce(discovery("legacy")).mockResolvedValue(discovery("paperclip")),
probeEnvironment: vi.fn().mockResolvedValue({ ok: true, driver: "sandbox", summary: "Measured image",
details: { platformQualificationRecorded: true, leaseId: "owned-probe-lease" } }),
isCurrent: vi.fn(() => true),
};
}
describe("Claude explicit setup qualification", () => {
it.each(["paperclip", "legacy"] as const)("uses fresh discovery and honors measured %s selection", async runner => {
const s = setup();
s.readAdapters.mockResolvedValueOnce(discovery(runner));
const result = await qualifyClaudeSetup(s);
expect(result?.runner).toBe(runner);
expect(s.readAdapters).toHaveBeenCalledTimes(2);
expect(s.probeEnvironment).toHaveBeenCalledTimes(1);
expect(s.readAdapters.mock.invocationCallOrder[0]).toBeLessThan(s.probeEnvironment.mock.invocationCallOrder[0]);
expect(s.probeEnvironment.mock.invocationCallOrder[0]).toBeLessThan(s.readAdapters.mock.invocationCallOrder[1]);
});
it.each(["permission", "cleanup", "missing-lease", "connection", "discovery"])("stops before adapter setup after %s failure", async failure => {
const s = setup();
if (failure === "permission") s.probeEnvironment.mockRejectedValue(new Error("Instance admin access required"));
else if (failure === "discovery") s.readAdapters.mockRejectedValue(new Error("Discovery unavailable"));
else s.probeEnvironment.mockResolvedValue({ ok: failure !== "connection", summary: "Qualification rejected",
details: { platformQualificationRecorded: failure !== "cleanup", ...(failure === "missing-lease" ? {} : { leaseId: "lease" }) } });
await expect(qualifyClaudeSetup(s)).rejects.toThrow(failure === "permission" ? "Instance admin" : failure === "discovery" ? "Discovery" : "Qualification rejected");
});
it.each(["legacy", "native", "local", "snapshot", "tag", "saved", "codex", "qualified", "disabled", "override", "missing-native"])("does not allocate an extra probe for %s", async mode => {
const s = setup();
const input: Parameters<typeof qualifyClaudeSetup>[0] = s;
if (mode === "legacy" || mode === "native") input.runner = mode === "native" ? "paperclip" : "legacy";
if (mode === "local") input.environment = { driver: "local", config: {} };
if (mode === "snapshot") input.environment = { driver: "sandbox", config: { image, snapshot: "named-snapshot" } };
if (mode === "tag") input.environment = { driver: "sandbox", config: { image: "registry.example.test/runtime:latest" } };
if (mode === "saved") input.savedAgentId = "saved-agent";
if (mode === "codex") input.adapterType = "codex_local";
if (mode === "qualified") s.readAdapters.mockReset().mockResolvedValue(discovery("paperclip"));
if (mode === "disabled" || mode === "override") s.readAdapters.mockReset().mockResolvedValue(discovery("legacy", mode === "disabled" ? { disabled: true } : { overriddenBuiltin: true }));
if (mode === "missing-native") s.readAdapters.mockReset().mockResolvedValue(discovery("legacy").slice(0, 1));
await qualifyClaudeSetup(input);
expect(s.probeEnvironment).not.toHaveBeenCalled();
});
it("abandons a stale selection before probing and after probing", async () => {
const before = setup(); before.isCurrent.mockReturnValue(false);
expect(await qualifyClaudeSetup(before)).toBeUndefined();
expect(before.probeEnvironment).not.toHaveBeenCalled();
const after = setup(); after.isCurrent.mockReturnValueOnce(true).mockReturnValue(false);
expect(await qualifyClaudeSetup(after)).toBeUndefined();
expect(after.probeEnvironment).toHaveBeenCalledTimes(1);
expect(after.readAdapters).toHaveBeenCalledTimes(1);
});
});
+48
View File
@@ -0,0 +1,48 @@
import type { AgentRunner, AgentRunnerChoice, Environment, EnvironmentProbeResult } from "@paperclipai/shared";
import type { AdapterInfo } from "../api/adapters";
export function needsClaudeSetupQualification(input: {
adapterType: string;
runner: AgentRunnerChoice;
savedAgentId?: string | null;
environment?: Pick<Environment, "driver" | "config"> | null;
}): boolean {
const config = input.environment?.config;
return !input.savedAgentId && input.adapterType === "claude_local" && input.runner === "auto"
&& input.environment?.driver === "sandbox" && typeof config?.image === "string"
&& /^[^\s@]+@sha256:[a-f0-9]{64}$/.test(config.image)
&& !config.snapshot && !config.template && !config.templateRef;
}
export async function qualifyClaudeSetup(input: Parameters<typeof needsClaudeSetupQualification>[0] & {
readAdapters: () => Promise<AdapterInfo[]>;
probeEnvironment: () => Promise<EnvironmentProbeResult>;
isCurrent: () => boolean;
}): Promise<{ runner: AgentRunner; adapters: AdapterInfo[] } | undefined> {
if (!needsClaudeSetupQualification(input)) return undefined;
const validate = (adapters: AdapterInfo[]) => {
const claude = adapters.find(adapter => adapter.type === "claude_local");
const runner = claude?.defaultRunner;
if (!claude || (runner !== "paperclip" && runner !== "legacy") || !claude.supportedRunners?.includes(runner)) {
throw new Error("Could not confirm Claude runner availability for this environment. Retry setup.");
}
return { runner, claude };
};
let adapters = await input.readAdapters();
if (!input.isCurrent()) return undefined;
const selected = validate(adapters);
const native = adapters.find(adapter => adapter.type === "paperclip_runner");
if (selected.runner === "paperclip" || selected.claude.disabled || selected.claude.overriddenBuiltin
|| !native || native.disabled || native.overriddenBuiltin) return { runner: selected.runner, adapters };
const probe = await input.probeEnvironment();
if (!input.isCurrent()) return undefined;
if (probe.ok !== true || probe.details?.platformQualificationRecorded !== true
|| typeof probe.details.leaseId !== "string" || !probe.details.leaseId) {
throw new Error(typeof probe.details?.platformQualificationDiagnostic === "string"
? probe.details.platformQualificationDiagnostic
: probe.summary || "The environment's platform qualification could not be confirmed. Retry the connection probe or select Legacy runner in Advanced.");
}
adapters = await input.readAdapters();
if (!input.isCurrent()) return undefined;
return { runner: validate(adapters).runner, adapters };
}
+8
View File
@@ -83,6 +83,14 @@ it("probes native Grok credentials with the pinned prerequisite in the selected
});
expect(result.checks.some((check) => check.code === "grok_hello_probe_passed")).toBe(true);
});
it("accepts verified native API authentication without requiring a Legacy CLI", async () => {
testEnvironment.mockResolvedValue({ ...ready, checks: [
{ code: "acpx_runtime_ready", level: "info", message: "Runtime verified" },
{ code: "ai_connection_api_key_reverified", level: "info", message: "Key verified" },
] });
expect((await testAgentSetup(input)).status).toBe("pass");
expect(testEnvironment).toHaveBeenCalledTimes(1);
});
it("does not repeat a completed model probe", async () => {
testEnvironment.mockResolvedValue({
...ready,
+3 -3
View File
@@ -2,8 +2,8 @@ import type { AdapterEnvironmentTestResult } from "@paperclipai/shared";
import { ADAPTER_AUTH_MISSING_CHECK_CODE } from "@paperclipai/shared";
import { agentsApi } from "../api/agents";
/** ACP readiness checks do not authenticate a provider. Verify credentials with
* the adapter's existing read-only CLI hello probe before calling setup connected. */
/** Readiness alone does not authenticate a provider. Reuse the provider's key
* verification when present, otherwise retain the existing CLI hello probe. */
export async function testAgentSetup(input: {
companyId: string;
agentId?: string;
@@ -34,7 +34,7 @@ export async function testAgentSetup(input: {
runtime.checks.some(
(check) => check.code === ADAPTER_AUTH_MISSING_CHECK_CODE,
) ||
runtime.checks.some((check) => check.code.includes("hello_probe")) ||
runtime.checks.some((check) => check.code.includes("hello_probe") || check.code === "ai_connection_api_key_reverified") ||
!["claude_local", "codex_local", "grok_local"].includes(input.providerAdapter)
)
return runtime;
+73 -8
View File
@@ -1260,10 +1260,75 @@ describe("CompanyImport", () => {
}
await clickButton(text => text.startsWith("Import 3 file"));
await settle();
expect(lastImportMeta().adapterOverrides).toEqual({ coder: { adapterType: "codex_local", runner } });
expect(lastImportMeta().adapterOverrides).toEqual({ coder: { adapterType: sourceType === "paperclip_runner" ? "paperclip_runner" : "codex_local", runner } });
expect(preview.manifest.agents[0]!.adapterConfig).toEqual(savedSourceConfig);
});
it.each(["deny-all", "approve-reads"].flatMap(permission => ["paperclip_runner", "claude_local"].map(sourceType => ({ permission, sourceType }))))("saves the selected Claude permission from $sourceType ($permission)", async ({ permission, sourceType }) => {
mockOptionalCompany.mockReturnValue({ selectedCompanyId: "company-1" });
mockAdaptersApi.list.mockResolvedValue([
{ type: "claude_local", disabled: false, supportedRunners: ["paperclip", "legacy"], defaultRunner: "paperclip" },
{ type: "paperclip_runner", disabled: false },
]);
const preview = buildPreviewResult();
const source = { provider: "acpx", acpxAgent: "claude", model: "claude-opus-5-5", modelReasoningEffort: "high", acpxPermissionMode: permission, timeoutSec: 180, lifecycleMode: "per_turn" };
preview.manifest.agents[0]!.adapterType = sourceType;
preview.manifest.agents[0]!.runner = sourceType === "paperclip_runner" ? "paperclip" : "legacy";
preview.manifest.agents[0]!.adapterConfig = source;
mockCompaniesApi.importPreview.mockResolvedValue(preview);
await renderPage();
await enterGithubUrl();
await clickButton(text => text === "Preview import");
await clickButton(text => text.includes("configure adapter"));
if (sourceType === "claude_local") {
await clickButton(text => text.includes("Advanced"));
await act(async () => container.querySelector<HTMLButtonElement>('[aria-label="Runner"]')!.click());
await act(async () => document.querySelector<HTMLButtonElement>('[role="option"][data-value="paperclip"]')!.click());
await flushReact();
}
const select = container.querySelector<HTMLButtonElement>('[aria-label="Permission mode"]');
if (sourceType === "paperclip_runner") expect(select?.textContent).toContain(permission === "deny-all" ? "Deny all" : "Allow Paperclip reads");
await act(async () => select!.click());
const nextPermission = permission === "deny-all" ? "approve-reads" : "deny-all";
const option = [...document.querySelectorAll<HTMLElement>('[role="option"]')].find(element => element.textContent === (nextPermission === "deny-all" ? "Deny all" : "Allow Paperclip reads"));
expect(option).toBeTruthy();
await act(async () => option!.click());
await flushReact();
await clickButton(text => text.startsWith("Import 3 file"));
await settle();
expect(lastImportMeta().adapterOverrides?.coder).toMatchObject({
runner: "paperclip",
adapterConfig: sourceType === "paperclip_runner" ? { ...source, acpxPermissionMode: nextPermission } : { provider: "acpx", acpxAgent: "claude", acpxPermissionMode: nextPermission },
});
expect(preview.manifest.agents[0]!.adapterConfig).toEqual(source);
});
it("keeps native Claude configuration at the server translation boundary when switching an import to Legacy", async () => {
mockOptionalCompany.mockReturnValue({ selectedCompanyId: "company-1" });
mockAdaptersApi.list.mockResolvedValue([
{ type: "claude_local", disabled: false, supportedRunners: ["paperclip", "legacy"], defaultRunner: "paperclip" },
{ type: "paperclip_runner", disabled: false },
]);
const preview = buildPreviewResult();
const source = { provider: "acpx", acpxAgent: "claude", model: "claude-opus-5-5", modelReasoningEffort: "high", acpxPermissionMode: "approve-all", timeoutSec: 180, lifecycleMode: "per_turn" };
preview.manifest.agents[0]!.adapterType = "paperclip_runner";
preview.manifest.agents[0]!.runner = "paperclip";
preview.manifest.agents[0]!.adapterConfig = source;
mockCompaniesApi.importPreview.mockResolvedValue(preview);
await renderPage();
await enterGithubUrl();
await clickButton(text => text === "Preview import");
await clickButton(text => text.includes("configure adapter"));
await clickButton(text => text.includes("Advanced"));
await act(async () => container.querySelector<HTMLButtonElement>('[aria-label="Runner"]')!.click());
await act(async () => document.querySelector<HTMLButtonElement>('[role="option"][data-value="legacy"]')!.click());
await flushReact();
await clickButton(text => text.startsWith("Import 3 file"));
await settle();
expect(lastImportMeta().adapterOverrides?.coder).toMatchObject({ adapterType: "paperclip_runner", runner: "legacy" });
expect(preview.manifest.agents[0]!.adapterConfig).toEqual(source);
});
it.each([
{ dangerouslyBypassApprovalsAndSandbox: false },
{ dangerouslyBypassSandbox: false },
@@ -1296,22 +1361,22 @@ describe("CompanyImport", () => {
expect(lastImportMeta().adapterOverrides?.coder?.adapterConfig).not.toHaveProperty("codexLegacySandboxBypass");
});
it.each(["model-clear", "effort-clear", "env-remove", "legacy-model", "changed-harness"] as const)("preserves native Codex source configuration during an ordinary import edit (%s)", async edit => {
it.each(["codex_local", "claude_local"].flatMap(harness => ["model-clear", "effort-clear", "env-remove", "legacy-model", "changed-harness"].map(edit => ({ harness, edit }))))("preserves native source configuration during an ordinary import edit ($harness, $edit)", async ({ harness, edit }) => {
mockOptionalCompany.mockReturnValue({ selectedCompanyId: "company-1" });
mockAdaptersApi.list.mockResolvedValue([
{ type: "claude_local", disabled: false },
{ type: "claude_local", disabled: false, supportedRunners: ["paperclip", "legacy"], defaultRunner: "paperclip" },
{ type: "codex_local", disabled: false, supportedRunners: ["paperclip", "legacy"], defaultRunner: "paperclip" },
{ type: "paperclip_runner", disabled: false },
]);
const preview = buildPreviewResult();
const sourceConfig = {
provider: "codex", model: "gpt-5.6-sol", modelReasoningEffort: "high", reasoningEffort: "low",
...(harness === "codex_local" ? { provider: "codex", model: "gpt-5.6-sol" } : { provider: "acpx", acpxAgent: "claude", model: "claude-opus-5-5", acpxPermissionMode: "approve-all" }), modelReasoningEffort: "high", reasoningEffort: "low",
env: { CODEX_HOME: { type: "secret_ref", secretId: "saved-account", version: "latest" },
OPENAI_API_KEY: { type: "user_secret_ref", key: "OPENAI_API_KEY", version: "latest", required: true } },
timeoutSec: 120, graceSec: 30, cwd: "/saved/workspace", instructionsFilePath: "/saved/AGENTS.md",
workspaceStrategy: { type: "git_worktree", baseRef: "main", branchTemplate: "qa/{runId}", worktreeParentDir: "/saved/worktrees" },
workspaceRuntime: { services: [{ name: "app", command: "pnpm dev" }] },
lifecycleMode: "warm", idleTimeoutMs: 45000,
lifecycleMode: "per_turn", idleTimeoutMs: 45000,
};
preview.manifest.agents[0]!.adapterType = "paperclip_runner";
preview.manifest.agents[0]!.runner = "paperclip";
@@ -1323,7 +1388,7 @@ describe("CompanyImport", () => {
if (edit === "changed-harness") {
const harness = findAdapterSelects()[0]!;
await act(async () => {
Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, "value")!.set!.call(harness, "claude_local");
Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, "value")!.set!.call(harness, sourceConfig.provider === "codex" ? "claude_local" : "codex_local");
harness.dispatchEvent(new Event("change", { bubbles: true }));
});
}
@@ -1354,14 +1419,14 @@ describe("CompanyImport", () => {
await clickButton(text => text.startsWith("Import 3 file"));
await settle();
if (edit === "changed-harness") {
expect(lastImportMeta().adapterOverrides?.coder?.adapterType).toBe("claude_local");
expect(lastImportMeta().adapterOverrides?.coder?.adapterType).toBe(harness === "codex_local" ? "claude_local" : "codex_local");
const config = lastImportMeta().adapterOverrides?.coder?.adapterConfig;
for (const field of ["provider", "env", "workspaceStrategy", "workspaceRuntime", "lifecycleMode", "idleTimeoutMs", "modelReasoningEffort"]) expect(config).not.toHaveProperty(field);
expect(config?.timeoutSec).toBe(0);
return;
}
expect(lastImportMeta().adapterOverrides?.coder).toEqual({
adapterType: "codex_local", runner: edit === "legacy-model" ? "legacy" : "paperclip", adapterConfig: expected,
adapterType: "paperclip_runner", runner: edit === "legacy-model" ? "legacy" : "paperclip", adapterConfig: expected,
});
expect(preview.manifest.agents[0]!.adapterConfig).toEqual(sourceConfig);
});
+39 -15
View File
@@ -576,13 +576,14 @@ function importAdapterValues(
sourceAdapterType?: string,
runner?: AgentRunnerChoice,
): CreateConfigValues {
const nativeCodex = sourceAdapterType === "paperclip_runner" && choice === "codex_local";
const nativeHarness = sourceAdapterType === "paperclip_runner" && ["codex_local", "claude_local"].includes(choice);
const nativeCodex = nativeHarness && choice === "codex_local";
const savedBypass = sourceConfig?.dangerouslyBypassApprovalsAndSandbox
?? (nativeCodex ? undefined : sourceConfig?.dangerouslyBypassSandbox);
return {
...defaultCreateValues,
adapterType: choice === "openai_dot" || choice === "muse" ? "paperclip_runner" : choice,
...(runner !== undefined ? { runner } : nativeCodex ? { runner: agentRunner("paperclip_runner") } : {}),
adapterType: (choice === "openai_dot" || choice === "muse") ? "paperclip_runner" : choice,
...(runner !== undefined ? { runner } : nativeHarness ? { runner: agentRunner("paperclip_runner") } : {}),
...(sourceConfig && choice === "codex_local" ? {
model: typeof sourceConfig.model === "string" ? sourceConfig.model : "",
thinkingEffort: String(sourceConfig.modelReasoningEffort ?? sourceConfig.reasoningEffort ?? ""),
@@ -592,7 +593,8 @@ function importAdapterValues(
? (sourceConfig.codexPermissionMode ?? PAPERCLIP_RUNNER_PERMISSION_CAPABILITIES.codex.defaultMode) === "never"
: resolveCodexLocalCliPolicy(sourceConfig).bypass,
} : {}),
...(nativeCodex && sourceConfig ? {
...(sourceConfig && choice === "claude_local" ? { model: typeof sourceConfig.model === "string" ? sourceConfig.model : "", thinkingEffort: String(sourceConfig.modelReasoningEffort ?? sourceConfig.reasoningEffort ?? sourceConfig.effort ?? "") } : {}),
...(nativeHarness && sourceConfig ? {
cwd: typeof sourceConfig.cwd === "string" ? sourceConfig.cwd : "",
instructionsFilePath: typeof sourceConfig.instructionsFilePath === "string" ? sourceConfig.instructionsFilePath : "",
envBindings: { ...asObject(sourceConfig.env) },
@@ -1621,7 +1623,7 @@ export function CompanyImport() {
resetMutationState();
const agent = adapterAgents.find((a) => a.slug === slug);
const currentType = agent ? effectiveAdapterType(agent) : adapterOverrides[slug] ?? "claude_local";
if (Object.keys(patch).some(key => key !== "runner")) {
if (Object.keys(patch).some(key => key !== "runner" && key !== "resolvedRunner")) {
setAdapterConfigEditedSlugs(prev => new Set([...prev, slug]));
}
setAdapterConfigValues((prev) => ({
@@ -1679,14 +1681,15 @@ export function CompanyImport() {
return importPreview.manifest.agents.map((a) => {
const nativeCodex = a.adapterType === "paperclip_runner"
&& (a.adapterConfig?.provider === "codex" || a.adapterConfig?.provider === undefined);
const nativeHarness = nativeCodex || (a.adapterType === "paperclip_runner" && agentHarnessType(a.adapterType, a.adapterConfig) === "claude_local");
const adapterChoice = a.adapterType === "paperclip_runner" && a.adapterConfig?.provider === "openai_dot"
? "openai_dot" : a.adapterType === "paperclip_runner" && a.adapterConfig?.provider === "muse" ? "muse" : nativeCodex ? agentHarnessType(a.adapterType, a.adapterConfig) : a.adapterType;
? "openai_dot" : a.adapterType === "paperclip_runner" && a.adapterConfig?.provider === "muse" ? "muse" : nativeHarness ? agentHarnessType(a.adapterType, a.adapterConfig) : a.adapterType;
let fallbackAdapterType: string | null = null;
let availabilityWarning: string | undefined;
if (a.adapterType === "paperclip_runner") {
const available = nativeCodex ? runnerInstalled : adapterChoice === "openai_dot" ? dotAvailable : adapterChoice === "muse" ? museAvailable : nativeRunnerAvailable;
if (!available && (availableAdapterTypes || !nativeCodex)) {
availabilityWarning = nativeCodex
const available = nativeHarness ? runnerInstalled : adapterChoice === "openai_dot" ? dotAvailable : adapterChoice === "muse" ? museAvailable : nativeRunnerAvailable;
if (!available && (availableAdapterTypes || !nativeHarness)) {
availabilityWarning = nativeHarness
? "Paperclip Runner is unavailable here. Choose a runner or another harness explicitly."
: `Paperclip Runner provider ${String(a.adapterConfig?.provider)} requires its experimental feature to be enabled. This import keeps its saved harness until you choose another one.`;
}
@@ -1729,8 +1732,11 @@ export function CompanyImport() {
for (const agent of adapterAgents) {
const selectedType = effectiveAdapterType(agent);
const configVals = adapterConfigValues[agent.slug];
if (selectedType === agent.adapterChoice && !configVals) continue;
const apiType = selectedType === "openai_dot" || selectedType === "muse" ? "paperclip_runner" : selectedType;
if (selectedType === agent.adapterChoice && !adapterConfigEditedSlugs.has(agent.slug)
&& (!configVals || configVals.runner === importAdapterValues(selectedType, agent.adapterConfig, agent.adapterType, agent.runner).runner)) continue;
const retainsNativeHarness = agent.adapterType === "paperclip_runner"
&& selectedType === agent.adapterChoice && ["codex_local", "claude_local"].includes(selectedType);
const apiType = selectedType === "openai_dot" || selectedType === "muse" || retainsNativeHarness ? "paperclip_runner" : selectedType;
const override: CompanyPortabilityAdapterOverride = { adapterType: apiType };
if (configVals?.runner !== undefined) override.runner = configVals.runner;
if (configVals && (selectedType !== agent.adapterChoice || adapterConfigEditedSlugs.has(agent.slug))) {
@@ -1744,11 +1750,14 @@ export function CompanyImport() {
if (selectedType === "muse" && configVals.adapterSchemaValues?.allowUnmeteredProvider !== true) {
throw new Error("Muse requires acknowledgement of external billing. Configure Muse and enable Allow externally billed provider.");
}
const build = getUIAdapter(configVals.adapterType).buildAdapterConfig;
const nativeClaudeForm = selectedType === "claude_local" && (retainsNativeHarness
|| (configVals.runner !== "legacy" && configVals.adapterSchemaValues?.provider === "acpx"
&& configVals.adapterSchemaValues.acpxAgent === "claude"));
const build = getUIAdapter(nativeClaudeForm ? "paperclip_runner" : configVals.adapterType).buildAdapterConfig;
const edited = build(configVals);
if (agent.adapterType === "paperclip_runner" && selectedType === agent.adapterChoice
&& selectedType === "codex_local" && configVals.adapterType === "codex_local"
&& agentHarnessType("paperclip_runner", configVals.adapterSchemaValues) === "codex_local") {
&& ["codex_local", "claude_local"].includes(selectedType) && configVals.adapterType === selectedType
&& agentHarnessType("paperclip_runner", configVals.adapterSchemaValues) === selectedType) {
const original = importAdapterValues(selectedType, agent.adapterConfig, agent.adapterType, configVals.runner);
const baseline = build(original);
const retained = { ...agent.adapterConfig };
@@ -1761,7 +1770,22 @@ export function CompanyImport() {
};
applyChanges(original.adapterSchemaValues ?? {}, configVals.adapterSchemaValues ?? {});
applyChanges(baseline, edited);
if (!overlayValuesEqual(baseline.modelReasoningEffort, edited.modelReasoningEffort)) delete retained.reasoningEffort;
if (selectedType === "claude_local") {
const commonBuild = getUIAdapter("claude_local").buildAdapterConfig;
const commonBefore = commonBuild(original);
const commonAfter = commonBuild(configVals);
for (const key of ["cwd", "instructionsFilePath", "env", "workspaceStrategy", "workspaceRuntime"]) {
if (overlayValuesEqual(commonBefore[key], commonAfter[key])) continue;
if (commonAfter[key] === undefined) delete retained[key];
else retained[key] = commonAfter[key];
}
}
if (original.model !== configVals.model && !configVals.model) delete retained.model;
if (original.thinkingEffort !== configVals.thinkingEffort) {
delete retained.reasoningEffort;
delete retained.effort;
if (!configVals.thinkingEffort) delete retained.modelReasoningEffort;
}
override.adapterConfig = retained;
} else {
override.adapterConfig = edited;
+205
View File
@@ -0,0 +1,205 @@
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, expect, vi } from "vitest";
import { TooltipProvider } from "@/components/ui/tooltip";
import { queryKeys } from "@/lib/queryKeys";
import { NewAgent } from "./NewAgent";
import { ApiError } from "@/api/client";
const api = vi.hoisted(() => ({
get: vi.fn(),
adapterModels: vi.fn(),
list: vi.fn(),
hire: vi.fn(),
testEnvironment: vi.fn(),
getAdapterAuthSignal: vi.fn(),
getClaudeOAuthTokenStatus: vi.fn(),
}));
const envApi = vi.hoisted(() => ({ list: vi.fn(), capabilities: vi.fn(), probe: vi.fn() }));
const settings = vi.hoisted(() => ({
get: vi.fn(),
getExperimental: vi.fn(),
getGeneral: vi.fn(),
}));
const secrets = vi.hoisted(() => ({
create: vi.fn(),
remove: vi.fn(),
list: vi.fn(),
listMyUserSecrets: vi.fn(),
createUserSecretDefinition: vi.fn(),
createMyUserSecret: vi.fn(),
rotateMyUserSecret: vi.fn(),
removeUserSecretDefinition: vi.fn(),
}));
const state = vi.hoisted(() => ({
params: new URLSearchParams(),
adapters: [] as object[],
navigate: vi.fn(),
openNewIssue: vi.fn(),
}));
const managedApi = vi.hoisted(() => ({
list: vi.fn(async () => ({ currentUserId: "user-1", canManageConnections: true, connections: [] })),
create: vi.fn(async () => ({ connectionId: "managed-connection", grantId: "managed-grant" })),
setDefault: vi.fn(async () => ({})),
}));
vi.mock("@/api/ai-connections", () => ({ aiConnectionsApi: managedApi }));
vi.mock("@/api/agents", () => ({ agentsApi: api }));
vi.mock("@/api/environments", () => ({ environmentsApi: envApi }));
vi.mock("@/api/instanceSettings", () => ({ instanceSettingsApi: settings }));
vi.mock("@/api/secrets", () => ({ secretsApi: secrets }));
const adapterApi = vi.hoisted(() => ({ list: vi.fn() }));
vi.mock("@/api/adapters", () => ({ adaptersApi: adapterApi }));
vi.mock("@/context/CompanyContext", () => ({
useCompany: () => ({ selectedCompanyId: "company-1" }),
}));
vi.mock("../context/BreadcrumbContext", () => ({
useBreadcrumbs: () => ({ setBreadcrumbs: vi.fn() }),
}));
vi.mock("@/context/DialogContext", () => ({
useDialogActions: () => ({ openNewIssue: state.openNewIssue }),
}));
vi.mock("@/lib/router", () => ({
useNavigate: () => state.navigate,
useSearchParams: () => [state.params],
}));
vi.mock("@/components/AgentConfigForm", () => ({
ModelDropdown: ({
value,
onChange,
}: {
value: string;
onChange: (value: string) => void;
}) => (
<input
aria-label="Model"
value={value}
onChange={(e) => onChange(e.target.value)}
/>
),
AdapterLoginPanel: ({ onConnected }: { onConnected: (id: string) => void }) => (
<button onClick={() => onConnected("login-session")}>
Complete subscription login
</button>
),
}));
vi.mock("@/components/onboarding/PillGuy", () => ({ PillGuy: () => null }));
vi.mock("motion/react", () => ({
AnimatePresence: ({ children }: any) => children,
MotionConfig: ({ children }: any) => children,
motion: {
span: ({ children }: any) => <span>{children}</span>,
div: ({ children, initial, animate, exit, transition, ...rest }: any) => (
<div {...rest}>{children}</div>
),
},
}));
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
let root: Root;
let container: HTMLDivElement;
let cache: QueryClient;
async function settle() {
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 20));
});
}
async function click(text: string) {
const button = [...container.querySelectorAll("button")].find(
(b) => b.textContent?.trim() === text,
);
expect(button, `Missing button ${text}`).toBeTruthy();
await act(async () => button!.click());
await settle();
}
async function fill(label: string, value: string) {
const input = container.querySelector(
`[aria-label="${label}"]`,
) as HTMLInputElement;
expect(input).toBeTruthy();
await act(async () => {
Object.getOwnPropertyDescriptor(
HTMLInputElement.prototype,
"value",
)!.set!.call(input, value);
input.dispatchEvent(new Event("input", { bubbles: true }));
});
}
async function render(adapter = "pi_local", runnerProvider = "codex") {
state.params = new URLSearchParams({
name: "Atlas",
adapterType: adapter,
runnerProvider,
});
await renderCurrentParams();
}
async function renderCurrentParams() {
await act(async () =>
root.render(
<QueryClientProvider client={cache}>
<TooltipProvider><NewAgent /></TooltipProvider>
</QueryClientProvider>,
),
);
await settle();
}
async function connect(provider: string) {
await click(provider + "Subscription");
await click("Connect");
}
const pass = {
adapterType: "pi_local",
status: "pass",
checks: [
{ code: "hello_probe_passed", level: "info", message: "Model replied" },
],
testedAt: "2026-09-07T00:00:00Z",
};
beforeEach(() => {
vi.clearAllMocks();
adapterApi.list.mockImplementation(async () => state.adapters);
managedApi.list.mockResolvedValue({ currentUserId: "user-1", canManageConnections: true, connections: [] });
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
cache = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
state.adapters = [
"claude_local",
"codex_local",
"opencode_local",
"pi_local",
"paperclip_runner", "cursor_cloud", "cursor", "gemini_local", "kimi_local", "grok_local", "hermes_local", "hermes_gateway",
].map((type) => ({ type, loaded: true, disabled: false }));
api.adapterModels.mockResolvedValue([]);
api.list.mockResolvedValue([{ id: "ceo", role: "ceo", status: "idle" }]);
api.getAdapterAuthSignal.mockResolvedValue({ status: "present" });
api.getClaudeOAuthTokenStatus.mockRejectedValue(new ApiError("Not found", 404, null));
api.testEnvironment.mockResolvedValue(pass);
api.hire.mockImplementation(async (_company, input) => ({
agent: { ...input, id: "new-agent", status: "idle", urlKey: "atlas" },
}));
envApi.list.mockResolvedValue([
{ id: "local-1", name: "Local", driver: "local", config: {} },
]);
envApi.capabilities.mockResolvedValue({ sandboxProviders: {} });
envApi.probe.mockReset().mockResolvedValue({ ok: true, driver: "sandbox", summary: "Measured image",
details: { platformQualificationRecorded: true, leaseId: "owned-platform-lease" } });
settings.get.mockResolvedValue({ defaultEnvironmentId: "local-1" });
settings.getExperimental.mockResolvedValue({ enableNativeRunner: true });
settings.getGeneral.mockResolvedValue({ executionMode: "any" });
secrets.list.mockResolvedValue([]);
secrets.create.mockResolvedValue({ id: "org-secret-1" });
secrets.remove.mockResolvedValue({ ok: true });
secrets.listMyUserSecrets.mockResolvedValue([]);
secrets.createUserSecretDefinition.mockResolvedValue({ id: "definition-1" });
secrets.createMyUserSecret.mockResolvedValue({ id: "secret-1" });
secrets.removeUserSecretDefinition.mockResolvedValue({ ok: true });
});
afterEach(async () => {
await act(async () => root.unmount());
cache.clear();
container.remove();
});
export { api, envApi, settings, secrets, state, managedApi, adapterApi, root, container, cache, settle, click, fill, render, connect, pass, renderCurrentParams };
+208 -207
View File
@@ -1,205 +1,203 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { TooltipProvider } from "@/components/ui/tooltip";
import { describe, expect, it, vi } from "vitest";
import { queryKeys } from "@/lib/queryKeys";
import { NewAgent } from "./NewAgent";
import { ApiError } from "@/api/client";
import {
api, envApi, settings, secrets, state, managedApi, adapterApi,
root, container, cache, settle, click, fill, render, connect, pass,
renderCurrentParams,
} from "./NewAgent.test-support";
const api = vi.hoisted(() => ({
get: vi.fn(),
adapterModels: vi.fn(),
list: vi.fn(),
hire: vi.fn(),
testEnvironment: vi.fn(),
getAdapterAuthSignal: vi.fn(),
getClaudeOAuthTokenStatus: vi.fn(),
}));
const envApi = vi.hoisted(() => ({ list: vi.fn(), capabilities: vi.fn() }));
const settings = vi.hoisted(() => ({
get: vi.fn(),
getExperimental: vi.fn(),
getGeneral: vi.fn(),
}));
const secrets = vi.hoisted(() => ({
create: vi.fn(),
remove: vi.fn(),
list: vi.fn(),
listMyUserSecrets: vi.fn(),
createUserSecretDefinition: vi.fn(),
createMyUserSecret: vi.fn(),
rotateMyUserSecret: vi.fn(),
removeUserSecretDefinition: vi.fn(),
}));
const state = vi.hoisted(() => ({
params: new URLSearchParams(),
adapters: [] as object[],
navigate: vi.fn(),
openNewIssue: vi.fn(),
}));
const managedApi = vi.hoisted(() => ({
list: vi.fn(async () => ({ currentUserId: "user-1", canManageConnections: true, connections: [] })),
create: vi.fn(async () => ({ connectionId: "managed-connection", grantId: "managed-grant" })),
setDefault: vi.fn(async () => ({})),
}));
vi.mock("@/api/ai-connections", () => ({ aiConnectionsApi: managedApi }));
vi.mock("@/api/agents", () => ({ agentsApi: api }));
vi.mock("@/api/environments", () => ({ environmentsApi: envApi }));
vi.mock("@/api/instanceSettings", () => ({ instanceSettingsApi: settings }));
vi.mock("@/api/secrets", () => ({ secretsApi: secrets }));
const adapterApi = vi.hoisted(() => ({ list: vi.fn() }));
vi.mock("@/api/adapters", () => ({ adaptersApi: adapterApi }));
vi.mock("@/context/CompanyContext", () => ({
useCompany: () => ({ selectedCompanyId: "company-1" }),
}));
vi.mock("../context/BreadcrumbContext", () => ({
useBreadcrumbs: () => ({ setBreadcrumbs: vi.fn() }),
}));
vi.mock("@/context/DialogContext", () => ({
useDialogActions: () => ({ openNewIssue: state.openNewIssue }),
}));
vi.mock("@/lib/router", () => ({
useNavigate: () => state.navigate,
useSearchParams: () => [state.params],
}));
// Exercise API/persistence contracts with deterministic presentation primitives.
// The actual searchable dropdown and login panel are covered by their tests and browser verification.
vi.mock("@/components/AgentConfigForm", () => ({
ModelDropdown: ({
value,
onChange,
}: {
value: string;
onChange: (value: string) => void;
}) => (
<input
aria-label="Model"
value={value}
onChange={(e) => onChange(e.target.value)}
/>
),
AdapterLoginPanel: ({ onConnected }: { onConnected: (id: string) => void }) => (
<button onClick={() => onConnected("login-session")}>
Complete subscription login
</button>
),
}));
vi.mock("@/components/onboarding/PillGuy", () => ({ PillGuy: () => null }));
vi.mock("motion/react", () => ({
AnimatePresence: ({ children }: any) => children,
MotionConfig: ({ children }: any) => children,
motion: {
span: ({ children }: any) => <span>{children}</span>,
div: ({ children, initial, animate, exit, transition, ...rest }: any) => (
<div {...rest}>{children}</div>
),
},
}));
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
let root: Root;
let container: HTMLDivElement;
let cache: QueryClient;
async function settle() {
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 20));
});
}
async function click(text: string) {
const button = [...container.querySelectorAll("button")].find(
(b) => b.textContent?.trim() === text,
);
expect(button, `Missing button ${text}`).toBeTruthy();
await act(async () => button!.click());
await settle();
}
async function fill(label: string, value: string) {
const input = container.querySelector(
`[aria-label="${label}"]`,
) as HTMLInputElement;
expect(input).toBeTruthy();
await act(async () => {
Object.getOwnPropertyDescriptor(
HTMLInputElement.prototype,
"value",
)!.set!.call(input, value);
input.dispatchEvent(new Event("input", { bubbles: true }));
});
}
async function render(adapter = "pi_local", runnerProvider = "codex") {
state.params = new URLSearchParams({
name: "Atlas",
adapterType: adapter,
runnerProvider,
});
await act(async () =>
root.render(
<QueryClientProvider client={cache}>
<TooltipProvider><NewAgent /></TooltipProvider>
</QueryClientProvider>,
),
);
await settle();
}
async function connect(provider: string) {
await click(provider + "Subscription");
await click("Connect");
}
const pass = {
adapterType: "pi_local",
status: "pass",
checks: [
{ code: "hello_probe_passed", level: "info", message: "Model replied" },
],
testedAt: "2026-09-07T00:00:00Z",
};
beforeEach(() => {
vi.clearAllMocks();
adapterApi.list.mockImplementation(async () => state.adapters);
managedApi.list.mockResolvedValue({ currentUserId: "user-1", canManageConnections: true, connections: [] });
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
cache = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
state.adapters = [
"claude_local",
"codex_local",
"opencode_local",
"pi_local",
"paperclip_runner", "cursor_cloud", "cursor", "gemini_local", "kimi_local", "grok_local", "hermes_local", "hermes_gateway",
].map((type) => ({ type, loaded: true, disabled: false }));
api.adapterModels.mockResolvedValue([]);
api.list.mockResolvedValue([{ id: "ceo", role: "ceo", status: "idle" }]);
api.getAdapterAuthSignal.mockResolvedValue({ status: "present" });
api.getClaudeOAuthTokenStatus.mockRejectedValue(new ApiError("Not found", 404, null));
api.testEnvironment.mockResolvedValue(pass);
api.hire.mockImplementation(async (_company, input) => ({
agent: { ...input, id: "new-agent", status: "idle", urlKey: "atlas" },
}));
envApi.list.mockResolvedValue([
{ id: "local-1", name: "Local", driver: "local", config: {} },
]);
envApi.capabilities.mockResolvedValue({ sandboxProviders: {} });
settings.get.mockResolvedValue({ defaultEnvironmentId: "local-1" });
settings.getExperimental.mockResolvedValue({ enableNativeRunner: true });
settings.getGeneral.mockResolvedValue({ executionMode: "any" });
secrets.list.mockResolvedValue([]);
secrets.create.mockResolvedValue({ id: "org-secret-1" });
secrets.remove.mockResolvedValue({ ok: true });
secrets.listMyUserSecrets.mockResolvedValue([]);
secrets.createUserSecretDefinition.mockResolvedValue({ id: "definition-1" });
secrets.createMyUserSecret.mockResolvedValue({ id: "secret-1" });
secrets.removeUserSecretDefinition.mockResolvedValue({ ok: true });
});
afterEach(async () => {
await act(async () => root.unmount());
cache.clear();
container.remove();
});
describe("New agent setup", () => {
it.each([
["paperclip", "Connect"], ["legacy", "Connect"],
["paperclip", "Finish setup"], ["legacy", "Finish setup"],
] as const)("qualifies new managed Claude and saves measured %s defaults through %s", async (measured, step) => {
const image = `registry.example.test/runtime@sha256:${"a".repeat(64)}`;
envApi.list.mockResolvedValue([{ id: "sandbox-1", driver: "sandbox", config: { provider: "daytona", image } }]);
settings.get.mockResolvedValue({ defaultEnvironmentId: "sandbox-1" });
settings.getExperimental.mockResolvedValue({ enableManagedSandboxOnly: true, enableNativeRunner: false });
api.getClaudeOAuthTokenStatus.mockResolvedValue({ secretId: "saved-oauth", latestVersion: 1 });
state.adapters = state.adapters.map((adapter: any) => adapter.type === "claude_local"
? { ...adapter, defaultRunner: "legacy", supportedRunners: ["legacy"] } : adapter);
let qualified = false;
const calls: string[] = [];
adapterApi.list.mockImplementation(async context => {
if (context?.companyId) calls.push(qualified ? "discovery-after" : "discovery-before");
return state.adapters;
});
envApi.probe.mockImplementation(async () => {
calls.push("platform"); qualified = true;
state.adapters = state.adapters.map((adapter: any) => adapter.type === "claude_local"
? { ...adapter, defaultRunner: measured, supportedRunners: [measured] } : adapter);
return { ok: true, driver: "sandbox", summary: "Measured image", details: { platformQualificationRecorded: true, leaseId: "owned-platform-lease" } };
});
api.testEnvironment.mockImplementation(async () => { calls.push("adapter"); return pass; });
await render("claude_local");
expect(envApi.probe).not.toHaveBeenCalled();
if (step === "Connect") {
await click("ClaudeSubscription");
calls.length = 0;
await click("Use saved subscription");
} else {
managedApi.list.mockResolvedValue({ currentUserId: "user-1", connections: [{
id: "00000000-0000-4000-8000-000000000021", grantId: "00000000-0000-4000-8000-000000000022", companyId: "company-1",
provider: "openrouter", method: "api_key", name: "Company OpenRouter", ownership: "shared", isDefault: false, status: "connected",
routing: { kind: "openrouter", protocol: "messages", auth: "bearer", models: [] },
}] } as any);
await click("AdvancedCustom Gateway");
const picker = container.querySelector('[role="combobox"][aria-label="Connection"]')!;
await act(async () => picker.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true })));
await settle();
const option = [...document.querySelectorAll('[role="option"]')].find(node => node.textContent?.includes("Company OpenRouter")) as HTMLElement;
await act(async () => option.click());
await settle();
await click("Use connection");
expect(api.testEnvironment).not.toHaveBeenCalled();
calls.length = 0;
}
await click("Finish setup");
expect(calls).toEqual(["discovery-before", "platform", "discovery-after", "adapter"]);
expect(envApi.probe).toHaveBeenCalledExactlyOnceWith("sandbox-1", "company-1");
expect(api.testEnvironment).toHaveBeenCalledTimes(1);
expect(api.hire).toHaveBeenCalledTimes(1);
const tested = api.testEnvironment.mock.calls[0][2].adapterConfig;
const saved = api.hire.mock.calls[0][1];
expect(saved).not.toHaveProperty("runner");
expect(saved.adapterConfig).toEqual(tested);
if (measured === "paperclip") expect(tested).not.toHaveProperty("maxTurnsPerRun");
else expect(tested.maxTurnsPerRun).toBe(1000);
});
it("drops a held managed Claude probe when the same environment changes configuration", async () => {
const config = { provider: "daytona", image: `registry.example.test/runtime@sha256:${"a".repeat(64)}` };
envApi.list.mockResolvedValue([{ id: "sandbox-1", driver: "sandbox", config }]);
settings.get.mockResolvedValue({ defaultEnvironmentId: "sandbox-1" });
state.adapters = state.adapters.map((adapter: any) => adapter.type === "claude_local"
? { ...adapter, defaultRunner: "legacy", supportedRunners: ["legacy"] } : adapter);
api.getClaudeOAuthTokenStatus.mockResolvedValue({ secretId: "saved-oauth", latestVersion: 1 });
let finish!: (value: object) => void;
envApi.probe.mockReturnValue(new Promise(resolve => { finish = resolve; }));
await render("claude_local");
await click("ClaudeSubscription");
await click("Use saved subscription");
expect(envApi.probe).toHaveBeenCalledTimes(1);
await act(async () => cache.setQueryData(queryKeys.environments.list("company-1"), [{ id: "sandbox-1", driver: "sandbox", config: {
...config, image: `registry.example.test/runtime@sha256:${"b".repeat(64)}`,
} }]));
await settle();
await act(async () => finish({ ok: true, driver: "sandbox", summary: "Measured image", details: { platformQualificationRecorded: true, leaseId: "owned-lease" } }));
await settle();
expect(envApi.probe).toHaveBeenCalledTimes(1);
expect(api.testEnvironment).not.toHaveBeenCalled();
expect(api.hire).not.toHaveBeenCalled();
});
it("retains the existing managed Claude platform-probe permission gate", async () => {
envApi.list.mockResolvedValue([{ id: "sandbox-1", driver: "sandbox", config: {
provider: "daytona", image: `registry.example.test/runtime@sha256:${"a".repeat(64)}`,
} }]);
settings.get.mockResolvedValue({ defaultEnvironmentId: "sandbox-1" });
state.adapters = state.adapters.map((adapter: any) => adapter.type === "claude_local"
? { ...adapter, defaultRunner: "legacy", supportedRunners: ["legacy"] } : adapter);
api.getClaudeOAuthTokenStatus.mockResolvedValue({ secretId: "saved-oauth", latestVersion: 1 });
envApi.probe.mockRejectedValue(new Error("Instance admin access required"));
await render("claude_local");
await click("ClaudeSubscription");
await click("Use saved subscription");
expect(container.textContent).toContain("Instance admin access required");
expect(envApi.probe).toHaveBeenCalledTimes(1);
expect(api.testEnvironment).not.toHaveBeenCalled();
expect(api.hire).not.toHaveBeenCalled();
});
it("waits for the current instance default instead of testing cached SSH discovery", async () => {
let finish!: (value: object) => void;
settings.get.mockReturnValue(new Promise<object>(resolve => { finish = resolve; }));
const environments = [
{ id: "local-1", name: "Local", driver: "local", config: {} },
{ id: "linux-ssh", name: "SSH", driver: "ssh", config: {} },
];
envApi.list.mockResolvedValue(environments);
cache.setQueryData(queryKeys.environments.list("company-1"), environments);
cache.setQueryData(queryKeys.instance.settings, { defaultEnvironmentId: "linux-ssh" });
adapterApi.list.mockImplementation(async (context?: { environmentId?: string | null }) => state.adapters.map((adapter: any) => adapter.type === "claude_local"
? { ...adapter, supportedRunners: ["paperclip", "legacy"], defaultRunner: context?.environmentId === "local-1" ? "paperclip" : "legacy" } : adapter));
await render("claude_local");
await connect("Claude");
expect(api.testEnvironment).not.toHaveBeenCalled();
expect(api.hire).not.toHaveBeenCalled();
await act(async () => finish({ defaultEnvironmentId: "local-1" }));
await settle();
await connect("Claude");
await click("Finish setup");
expect(api.testEnvironment).toHaveBeenCalledTimes(1);
expect(api.testEnvironment.mock.calls[0][2].environmentId).toBe("local-1");
expect(api.hire.mock.calls[0][1].adapterConfig).not.toHaveProperty("maxTurnsPerRun");
});
it("keeps the API key draft while waiting for refreshed discovery", async () => {
state.adapters = state.adapters.map((adapter: any) => adapter.type === "claude_local"
? { ...adapter, defaultRunner: "paperclip", supportedRunners: ["paperclip", "legacy"] } : adapter);
await render("claude_local");
await click("ClaudeAPI key");
await fill("API key", "example-test-secret");
const draft = container.querySelector('[aria-label="API key"]') as HTMLInputElement;
let finish!: (value: object[]) => void;
adapterApi.list.mockReturnValue(new Promise<object[]>(resolve => { finish = resolve; }));
let refresh!: Promise<void>;
await act(async () => {
refresh = cache.invalidateQueries({ queryKey: queryKeys.adapters.availability("company-1", "local-1") });
});
await settle();
expect(container.querySelector('[aria-label="API key"]')).toBe(draft);
expect(draft.value).toBe("example-test-secret");
await click("Connect");
expect(managedApi.create).not.toHaveBeenCalled();
expect(api.testEnvironment).not.toHaveBeenCalled();
await act(async () => { finish(state.adapters); await refresh; });
await settle();
expect(container.querySelector('[aria-label="API key"]')).toBe(draft);
expect(draft.value).toBe("example-test-secret");
await click("Connect");
await click("Finish setup");
expect(api.testEnvironment).toHaveBeenCalledTimes(1);
expect(api.hire).toHaveBeenCalledTimes(1);
expect(api.hire.mock.calls[0][1].adapterConfig).not.toHaveProperty("maxTurnsPerRun");
});
it.each(["paperclip", "legacy", undefined] as const)("uses Claude's discovered %s default for Auto setup and hire", async (defaultRunner) => {
settings.getExperimental.mockResolvedValue({ enableNativeRunner: false });
state.adapters = (state.adapters as any[]).map(adapter => adapter.type === "claude_local"
? { ...adapter, supportedRunners: ["paperclip", "legacy"], defaultRunner } : adapter);
await render("claude_local");
await connect("Claude");
await click("Finish setup");
expect(api.hire).toHaveBeenCalledTimes(1);
for (const config of [api.testEnvironment.mock.calls[0][2].adapterConfig, api.hire.mock.calls[0][1].adapterConfig]) {
if (defaultRunner === "paperclip") expect(config).not.toHaveProperty("maxTurnsPerRun");
else expect(config.maxTurnsPerRun).toBe(1000);
expect(config).not.toHaveProperty("resolvedRunner");
}
expect(api.hire.mock.calls[0][1]).not.toHaveProperty("runner");
});
it("finishes ordinary native Claude API setup when the key is verified without a CLI hello", async () => {
settings.getExperimental.mockResolvedValue({ enableNativeRunner: false });
state.adapters = (state.adapters as any[]).map(adapter => adapter.type === "claude_local"
? { ...adapter, supportedRunners: ["paperclip", "legacy"], defaultRunner: "paperclip" } : adapter);
api.testEnvironment.mockResolvedValue({ ...pass, adapterType: "paperclip_runner", checks: [
{ code: "acpx_runtime_ready", level: "info", message: "Runtime verified" },
{ code: "ai_connection_api_key_reverified", level: "info", message: "Key verified" },
] });
await render("claude_local");
await connect("Claude");
await click("Finish setup");
expect(api.testEnvironment).toHaveBeenCalledTimes(1);
expect(api.hire).toHaveBeenCalledTimes(1);
expect(api.hire.mock.calls[0][1]).not.toHaveProperty("runner");
});
it("records an explicit legacy choice when it matches the Windows default, then preserves it on Linux", async () => {
settings.get.mockResolvedValue({ defaultEnvironmentId: "local-1" });
envApi.list.mockResolvedValue([
@@ -412,11 +410,21 @@ describe("New agent setup", () => {
await click("Finish setup");
expect(api.hire.mock.calls[0][1].defaultEnvironmentId).toBe("grok-sandbox");
});
it.each([false, true])("keeps non-Codex runner setup links gated when the experiment is disabled (cloud=%s)", async (cloud) => {
it.each([false, true])("keeps other native setup links gated when the experiment is disabled (cloud=%s)", async (cloud) => {
cache.setQueryData(queryKeys.health, { status: "ok", cloud: { managed: cloud } });
settings.getExperimental.mockResolvedValue({ enableNativeRunner: false });
await render("paperclip_runner", "grok");
expect(container.textContent).toContain("This adapter is unavailable");
expect(api.hire).not.toHaveBeenCalled();
});
it.each([false, true])("restores native Claude setup intent and its connection flow with experiment=false (cloud=%s)", async cloud => {
cache.setQueryData(queryKeys.health, { status: "ok", cloud: { managed: cloud } });
settings.getExperimental.mockResolvedValue({ enableNativeRunner: false });
await render("paperclip_runner", "claude");
expect(container.textContent).toContain("This adapter is unavailable");
expect(container.textContent).not.toContain("This adapter is unavailable");
expect(container.textContent).toContain("Claude Code");
expect(container.textContent).toContain("Paperclip Runner");
expect(container.textContent).toContain("Connect a model");
expect(api.hire).not.toHaveBeenCalled();
});
it("blocks direct setup links for unsupported Cloud adapters", async () => {
@@ -582,14 +590,7 @@ describe("New agent setup", () => {
adapterType: "pi_local",
createdAgentId: "saved-agent",
});
await act(async () =>
root.render(
<QueryClientProvider client={cache}>
<TooltipProvider><NewAgent /></TooltipProvider>
</QueryClientProvider>,
),
);
await settle();
await renderCurrentParams();
expect(container.textContent).toContain("Your agent is ready");
expect(container.textContent).not.toContain("Finish setup");
expect(api.hire).not.toHaveBeenCalled();
+19 -1
View File
@@ -163,6 +163,9 @@ function installStorybookApiFixtures() {
if (url.pathname === "/api/instance/settings/experimental") {
return Response.json(experimentalSettings());
}
if (url.pathname === "/api/instance/settings/general") {
return Response.json(instanceGeneralSettingsSchema.parse({}));
}
if (url.pathname === "/api/health") {
return Response.json({ deploymentMode: "authenticated", localAiLoginSupported: onboardingFixtureState.environments === "local" });
@@ -483,9 +486,11 @@ function installStorybookApiFixtures() {
type: "claude_local",
label: "Claude Code",
source: "builtin",
modelsCount: 2,
modelsCount: 4,
loaded: true,
disabled: false,
supportedRunners: ["paperclip", "legacy"],
defaultRunner: "legacy",
capabilities: {
supportsInstructionsBundle: true,
supportsSkills: true,
@@ -511,6 +516,8 @@ function installStorybookApiFixtures() {
modelsCount: 3,
loaded: true,
disabled: false,
supportedRunners: ["paperclip", "legacy"],
defaultRunner: "legacy",
capabilities: {
supportsInstructionsBundle: true,
supportsSkills: true,
@@ -523,6 +530,16 @@ function installStorybookApiFixtures() {
},
},
},
{
type: "paperclip_runner",
label: "Paperclip Runner",
source: "builtin",
modelsCount: 0,
loaded: true,
disabled: false,
supportedRunners: ["paperclip"],
defaultRunner: "paperclip",
},
]);
}
@@ -533,6 +550,7 @@ function installStorybookApiFixtures() {
const [, , resource] = adapterModelsMatch;
if (resource === "models") {
return Response.json([
{ id: "claude-opus-5-5", label: "Claude Opus 5.5" },
{ id: "claude-opus-4-7", label: "Claude Opus 4.7" },
{ id: "claude-sonnet-4-6", label: "Claude Sonnet 4.6" },
{ id: "claude-haiku-4-5", label: "Claude Haiku 4.5" },