feat: default new Codex agents to Paperclip Runner on Linux and macOS (#15683)

## Thinking Path

> - Paperclip is the open source control plane for agent companies.
> - People choose a harness when creating an agent.
> - The server should resolve that harness to a qualified runner.
> - Codex is the first harness in the staged default rollout.
> - Saved agents must retain their execution choices.
> - New Codex agents therefore use Paperclip Runner on qualified
macOS/Linux targets, with an explicit Legacy override.

## Linked Issues or Issue Description

Refs #16028, #15555, #15673, #15422, #15631, #15926, #15932, #16149.

The runtime/install prerequisite and release repair are merged. This
slice owns Codex creation, setup, configuration and preservation. Other
harness defaults remain unchanged. It adds no database migration;
incoming master migrations, required maps and regression coverage are
retained.

## What Changed

- Resolve `runner: auto | paperclip | legacy` at the common creation
boundary across onboarding, API/CLI, hires, built-ins, plugins and
imports. Freeze pending hires and export resolved execution choices.
- Preserve existing runners on upgrades, unrelated edits and import
updates, including compatible accounts, workspace settings, Codex effort
and intentional clears.
- Use the underlying harness picker and branding; put the runner
override in Advanced. Use the shared dropdown and update
production-component stories and creation contracts.
- Support qualified Apple Silicon, Intel Mac, Linux x64 and Linux ARM64.
Linux requires GNU libc 2.39+. Disabled, external, unsupported and
unqualified combinations retain their applicable Legacy execution.
- Test the selected runtime/account/model/environment. Ordinary Connect
probes eligible immutable managed targets and refreshes scoped
discovery. Creation/discovery do not provision. Auth, dependency,
incompatible-setting and cleanup failures remain actionable without
changing runners.
- Keep setup probes read-only, preserve local/SSH isolation and resolve
packaged assets before staging credentials. Bound cleanup reads by the
remaining absolute deadline; physically close every owned ingress socket
before endpoint release. Unconfirmed cleanup retains recovery state.
- Preserve validated completed-onboarding callbacks through startup
recovery. First file-producing work belongs to the child task and uses
the existing dependency/block handoff and task-scoped registration.
- Reuse existing E2E fixtures, artifact checks, reporting and controls.
Keep Docker/Cargo/native compilation remote. Paperclip tarballs contain
no Codex payloads; official optional npm platform dependencies remain.

## Verification

Current tested head: `1c03a8815672d82c264a9b08d4ea1984e8fae107`.
**Merge-ready: required CI, fresh review, package qualification, Mac
core acceptance and Test task/follow-up acceptance pass.** The existing
attachment and native-continuity oracles independently verify both Test
downloads, run attribution and unchanged native/provider/workspace
identities. The maintainer authorized a code-owner-review exception for
this PR only. The first Test setup failure and normal Board
completion-review friction are retained below. Actual post-merge
packages and private Cloud distribution remain separate qualification
gates.

- [Current
CI](https://github.com/paperclipai/paperclip/actions/runs/38564241625):
all 48 ordinary jobs and required verify/E2E pass. Attempt 1 retained
six simultaneous host-shutdown cancellations. One failed-only retry
passes those six lanes and two aggregates, preserving 40 successes.
Actual integration checkout `53104c1` combines this head with master
`c80`; it is not relabeled as the branch tree. Fresh exact-head Greptile
5/5, 30 resolved threads, no actionable findings.
- [Exact-source package
qualification](https://github.com/paperclipai/paperclip/actions/runs/38565287239):
nine applicable jobs pass, nine conditional jobs skip. Four remote
native producers; Linux x64 clean install; Intel Mac, Apple Silicon and
Linux ARM64 native startup/physical cleanup controls. All 18
tarballs/four daemon source/hash/manifest bindings and 343 migrations
through 0344 verify. No Codex payload is packed. These hosted tarballs
are not a published npm canary.
- Normal Apple Silicon installation and credential-free native
startup/cleanup pass using the packaged daemon, with no source build or
provider turn. Fresh installed DB initialization passes. Upgrade
651a→1c03 preserves all three complete execution configs, accounts, ten
recorded run profiles, paused budgets, active incidents, cost summaries
and two historical unpriced events. Historical recovery state is
retained.
- Cleanup regressions: four delayed-read baseline failures become 15
passing deadline/authority controls. No-acknowledgement and prior-error
socket ownership regressions become 12 passing controls. Direct
runtime/server types, maps: 35 areas / 176 IDs, diff checks and
independent reviews pass. Permission/auth/reconnect policy and the
10-second setup budget remain intact.
- Existing source coverage includes creation/selection/imports, pending
approvals, recovery, account/lifecycle handling, mounted forms,
dropdowns and E2E support. Full typechecks/tests/build/token and
applicable Storybook checks pass in the recorded current CI; no
duplicate suite or weakened grader.
- Literal 9546 live Mac proof: ordinary Auto Codex onboarding, useful
document, real completed-child callback/reply, seven successful native
runs, manual follow-up and independently downloaded1,101-byte child
file. Existing Legacy title edit/reload preserves execution; two real
Legacy runs produce independently checked15/34-byte files. These remain
historical source records.
- Current literal 1c03 Mac core acceptance passes independent checks:
ordinary Auto native Codex onboarding without runner override; only
timeout 180 seconds/concurrency 1 saved; six successful native runs and
six serialized turns (maximum overlap 1); both same-task continuity
checks and five mechanical callback checks; actual rendered parent reply
and exact child downloads. The useful handoff advances from revision 1
to 2. Both files are 48 bytes, SHA-256
`8b742bd4e8123a7c078f0a8d9f3449fbfa8a320d7d90be641a2818db3c53d92c` and
`f42db9774fa06e804ab7d596fc8b922a2b638921c418b87d0111151d290a6afe`;
exactly one byte changes and old history remains. The agent is paused
with zero active runs/errors. Both artifact work products have
`isPrimary=false`, and descriptive titles fail a stricter title-helper
assumption; these retained results are scoped editorial follow-up, not
new merge gates. Six source-bound screenshots are uploaded and verified
in the private primary QA report.
- [Literal 9546 managed
proof](https://d1p6rlowie26tp.cloudfront.net/runner-e2e/campaigns/gha-38485488621-1/index.html):
original 19 assertions and completed judge pass, four native turns,
child-bound240-byte file and session/workspace continuity. Checkout
reports a verified generated target-lock mutation; full final porcelain
was not retained. Signed execution-image identity remains separate from
Core source.
- Actual 651a Test deployment succeeds on one owned/pinned target. One
ordinary Auto API-key Connect then fails native cleanup before hire.
Controller turn/drain/suspend completion and service-recorded
environment cleanup are observed; they do not certify physical deletion
or native authority retirement. Failure/recovery/unknown holds are
retained. No Legacy switch or unchanged paid retry. The two source
repairs above require actual treatment.
- [Current immutable Core
preview](https://github.com/paperclipai/paperclip/actions/runs/38570365725)
and matching DB/shared artifacts pass. One owned, pinned Test target
serves exact `1c03`; identical migration history correctly skips the
migrator. Ordinary Auto Codex Connect succeeds and hires native; saved
native selection and QA bounds survive reload. The initial child
preparation attempt fails before provider work, with actual stdout
identifying `renewal_deadline`; its trigger remains unknown and the
failure is retained. Normal Board **Try again** and **Resume** recover
the child to Done, producing a browser-verified 44-byte file, SHA-256
`0a8458b1efcc2d7a0d815689458c4caab039a46c7c7a3e5805de6bc8b1a96ecc`. The
actual parent callback reports the completed child document/file. The
existing validator requires a parent-owned attachment, so recovery
enters normal Review; after byte checking, normal **Approve completion**
reaches parent Done. No forced status or duplicate parent file is used.
The validator is unchanged from integrated and current master.
Autonomous initial callback-to-Done is not proven. The same-child
follow-up completes document revision 2 and publishes a second
independently downloaded 44-byte file, SHA-256
`22022257300fae762784632d08d3bbe0bab61e2ce81f91864a14dcd7aa464591`; only
the revision digit changes. The unchanged existing native-continuity and
attachment oracles pass. All ten recorded runs are terminal: nine native
successes and the retained pre-provider setup failure. The QA agent is
paused with zero active runs. The pre-existing parent evidence scope
mismatch is a separate follow-up, with ordinary completion review
exercised rather than a forced status or relaxed validation. This
unsigned Core preview remains distinct from post-merge private Cloud
composition.

## Risks

- Saved agents are not migrated. Explicit runner changes use normal
configuration/session rules; historical runs retain their recorded
execution.
- Native readiness depends on verified assets and server-owned target
qualification. A Legacy probe, HTTP200 or successful account save cannot
prove native readiness.
- Recovery exceptions remain restricted to validated
completed-onboarding reports; process-loss, ownership and terminal-state
safeguards remain active. Unexecuted live journeys stay open.
- Linux ARM paid tasks, Windows, Alpine and 32-bit ARM remain deferred.
Cold Cargo cache misses are recorded; no warm-cache speedup is claimed.
- Historical invoices/physical cleanup remain unconfirmed and held.
After merge, exact published npm canary, signed standard image/migrator,
private composition and serving/live acceptance still require
qualification. Production deployment/default promotion is not
authorized.

## Model Used

OpenAI Codex, GPT-6 family, with reasoning, tools, code execution and
parallel agents. Exact serving snapshot and context-window size are not
exposed.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used with available 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 linked public issues or PRs above
- [x] I have not referenced internal/instance-local Paperclip issues or
links
- [x] My branch name describes the change and contains no internal
ticket id
- [x] I have run the stated focused tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation
- [x] I have considered and documented risks
- [x] All Paperclip CI gates are green on the current candidate
- [x] Greptile is 5/5 with no open findings on the current candidate
- [x] I will address all reviewer comments before requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip authored and GitHub committed 2026-10-14 11:33:02 -05:00
1 parent 739f9e40a4
commit adb69e8f09
129 files changed
+8500 -890

No files matched your search

+12
View File
@@ -70,6 +70,18 @@ describe("agent lifecycle commands", () => {
]);
});
it("sends automatic selection when omitted and supports explicit runner overrides", async () => {
const fetchMock = vi.fn().mockImplementation(() => Promise.resolve(jsonResponse()));
vi.stubGlobal("fetch", fetchMock);
const payload = { name: "Builder", adapterType: "codex_local" };
await run(["agent", "create", "--company-id", COMPANY_ID, "--payload-json", JSON.stringify(payload)]);
await run(["agent", "create", "--company-id", COMPANY_ID, "--payload-json", JSON.stringify({ ...payload, runner: "auto" }), "--runner", "legacy"]);
await run(["agent", "hire", "--company-id", COMPANY_ID, "--payload-json", JSON.stringify(payload), "--runner", "paperclip"]);
expect(JSON.parse(fetchMock.mock.calls[0]![1].body).runner).toBeUndefined();
expect(JSON.parse(fetchMock.mock.calls[1]![1].body).runner).toBe("legacy");
expect(JSON.parse(fetchMock.mock.calls[2]![1].body).runner).toBe("paperclip");
});
it("wraps configuration, runtime, skills, and instructions endpoints", async () => {
const fetchMock = vi.fn().mockImplementation(() => Promise.resolve(jsonResponse()));
vi.stubGlobal("fetch", fetchMock);
+5 -2
View File
@@ -57,6 +57,7 @@ interface AgentWakeOptions extends BaseClientOptions {
}
interface AgentJsonPayloadOptions extends BaseClientOptions {
runner?: "auto" | "paperclip" | "legacy";
companyId?: string;
payloadJson: string;
}
@@ -348,10 +349,11 @@ export function registerAgentCommands(program: Command): void {
.description("Create an agent from a JSON payload")
.option("-C, --company-id <id>", "Company ID")
.requiredOption("--payload-json <json>", "CreateAgent JSON payload")
.option("--runner <runner>", "Runner: auto (default), paperclip, or legacy")
.action(async (opts: AgentJsonPayloadOptions) => {
try {
const ctx = resolveCommandContext(opts, { requireCompany: true });
const payload = createAgentSchema.parse(parseJson(opts.payloadJson));
const payload = createAgentSchema.parse({ ...parseJsonObject(opts.payloadJson), ...(opts.runner ? { runner: opts.runner } : {}) });
const created = await ctx.api.post<Agent>(apiPath`/api/companies/${ctx.companyId}/agents`, payload);
printOutput(created, { json: ctx.json });
} catch (err) {
@@ -367,10 +369,11 @@ export function registerAgentCommands(program: Command): void {
.description("Create an agent hire request")
.option("-C, --company-id <id>", "Company ID")
.requiredOption("--payload-json <json>", "CreateAgentHire JSON payload")
.option("--runner <runner>", "Runner: auto (default), paperclip, or legacy")
.action(async (opts: AgentJsonPayloadOptions) => {
try {
const ctx = resolveCommandContext(opts, { requireCompany: true });
const result = await ctx.api.post(apiPath`/api/companies/${ctx.companyId}/agent-hires`, parseJson(opts.payloadJson));
const result = await ctx.api.post(apiPath`/api/companies/${ctx.companyId}/agent-hires`, { ...parseJsonObject(opts.payloadJson), ...(opts.runner ? { runner: opts.runner } : {}) });
printOutput(result, { json: ctx.json });
} catch (err) {
handleCommandError(err);
File diff suppressed because it is too large. Load diff
+41
View File
@@ -11,6 +11,12 @@ Each capability needs operation-level review before its entire scope can be
qualified. No live agent execution is claimed. Follow the [map contract](../README.md)
for format, permission checks, evidence and maintenance.
Scoped runner-default source review: 2026-10-12 at
`eee0534154eaf5d0d3f2bbc97c1e24d86f34a5a5`, limited to creation/configuration,
approval activation, built-in provisioning and import preservation described
below. The original baseline commit above still governs unrelated entries; this
update does not claim operation-complete review or live agent verification.
## `hire`
Review: `baseline`.
@@ -26,17 +32,45 @@ native hires.
Policy: Agent creation/configuration uses action grants, resource policies and change
consent; some controls require a board actor.
The creation boundary resolves the request-level runner before adapter validation
and credential normalization. Omitted/automatic new Codex selection uses the
qualified target's default; explicit Legacy is retained. Disabled adapters and
external overrides affect new selection, while missing credentials/runtime or
incompatible settings remain errors. Existing agents and reviewed pending hires
retain their recorded execution. Native `hire_agent` explicitly inherits its
caller's runtime and remains subject to permission and credential restrictions;
it is not an arbitrary host-configuration or login tool. REST/CLI creation and
hire runner options use the [shared request contract](../../packages/shared/src/validators/agent.ts).
Evidence: `source-only` — [implementation](../../server/src/routes/agents.ts#L1395) at
the reviewed commit above.
Scoped Evidence: `source-only` — [creation policy](../../server/src/services/agent-hiring.ts),
[runner resolution](../../server/src/services/agent-runner-selection.ts),
[native hire binding](../../server/src/services/native-runtime/paperclip-runner-tool-authority.ts),
[frozen approval activation](../../server/src/modules/agent-lifecycle/adapters/approvals.ts)
and [same-harness import preservation](../../server/src/services/company-portability.ts)
at the scoped commit. No permitted-agent journey was executed for this map update.
Verification: `not-run` — exercise a listed permitted operation with actual agent
credentials/run context; inspect its result and applicable persistence/attribution.
Assert the relevant denied actor, owner, company or mode from Policy. Review the
remaining operations before qualifying the whole capability.
For this scope, compare automatic and explicit Legacy creation, assert foreign
company/environment and missing-grant denials, then compare pending approval
configuration across restart and activation. An unrelated edit/import update must
not change existing execution; a new package without a runner choice receives
creation defaults. Do not count a board call or component test as agent evidence.
Gaps: Identity/persona hiring works. Operator account login and sensitive
adapter/environment configuration are separate guarded actions.
The runner request does not grant new account, environment-probe or native API
fallback authority; see [validation access](./runs-adapters.md#validation) and
[environment administration](./execution-environments.md#probe). Full access
qualification remains not run.
## `instructions`
Review: `baseline`.
@@ -141,6 +175,13 @@ remaining operations before qualifying the whole capability.
Gaps: Provisioning checks agents:create. Enabling/running its built-in routine is not
the same permission path.
Scoped source review: [built-in provisioning](../../server/src/services/built-in-agents.ts)
recognizes the underlying Codex harness of a native company agent. New configured
provisioning resolves the runner, while unrelated reconciliation and same-harness
edits retain existing execution. Verify explicit runner changes preserve compatible
saved settings; the general creation/access policy above still applies. This is
source-only evidence at the scoped commit, not a live provisioning result.
## `primary-agent`
Review: `baseline`.
+89
View File
@@ -9,6 +9,11 @@ Each capability needs operation-level review before its entire scope can be
qualified. No live agent execution is claimed. Follow the [map contract](../README.md)
for format, permission checks, evidence and maintenance.
Scoped runner-default source review: 2026-10-12 at
`eee0534154eaf5d0d3f2bbc97c1e24d86f34a5a5`, limited to first-agent selection,
selected-runtime validation and draft restoration. Unrelated baseline entries
retain the original source commit; no live agent verification is added.
## `instance`
Review: `baseline`.
@@ -74,10 +79,58 @@ provider-account/environment setup.
Policy: Instance setup is operator work; company creation is board; agent creation/task
creation retain their individual permissions.
The wizard selects the Codex harness and lets the server resolve an automatic
new-agent runner; explicit Legacy remains available. Supported-platform and
immutable sandbox evidence follow [environment probe policy](./execution-environments.md#probe).
Creation/discovery do not provision a target to determine this default. Adapter
setup validation requires `agents:create`, plus saved-agent update access when
testing an existing agent; interactive account login additionally requires a
board user and owner-bound session. An agent's right to hire is not login or
instance-environment administration authority.
For fresh managed Codex Auto setup, the wizard's explicit Connect path can call
`POST /api/environments/{environmentId}/probe?companyId={companyId}` before
selected-runtime testing, then refresh scoped adapter discovery. This reuses the
existing board instance-admin/local-implicit probe gate; `agents:create` alone
is insufficient. A 403, absent proof, changed configuration, missing response
identity or unconfirmed cleanup remains visible. No probe is added to creation
or discovery, no agent/company grant is broadened, and provider authentication
errors never cause an automatic runner switch.
Native setup derives outer sandbox isolation from the server-resolved execution
target, as task execution does. This is an internal transport choice, not a
request/configuration permission. Local and SSH setup keep Codex isolation;
caller-supplied environment flags do not authorize an external sandbox. Existing
company, account, probe and registration-cleanup gates still apply.
The bounded hello probe retains read-only permissions and no dynamic tools. Its
trusted launch skips project-instruction discovery in the new, empty setup
workspace. This grants no filesystem or networking permission and does not
change instruction discovery for ordinary task execution.
Scoped source review: 2026-10-13, exact baseline
`1a0ca66242121e39d88455aa2b52a07acc4a07d9` plus the privately composed onboarding
correction. [Wizard controls](../../ui/src/components/OnboardingWizard.test.tsx)
and [existing Product E2E observer controls](../../tests/runner-e2e/first-task.test.ts)
exercise mocked allowed/denied setup and sticky unknown cost coverage. They do
not prove live actor authorization or provider execution. The explicit manual
`confirmation-replies.runner-codex.daytona.task-card-accept` case requires an
eligible immutable OCI target and the existing probe authority, two identified
setup leases and separately authorized paid/cleanup reservations. Its fixture
neither prequalifies the environment nor creates the agent. Actual Cloud named
snapshot binding, warm/cold signup and live allowed/denied agent checks remain
`not-run`.
Evidence: `source-only` —
[implementation](../../ui/src/components/OnboardingWizard.tsx#L279) at the reviewed
commit above.
Scoped Evidence: `source-only` — [wizard/default selection](../../ui/src/components/OnboardingWizard.tsx),
[creation resolver](../../server/src/services/agent-runner-selection.ts) and
[setup/login authorization](../../server/src/routes/agents.ts) at the scoped
commit. Existing agent/approval execution preservation follows
[hiring access](./agents.md#hire); no live onboarding proof is claimed here.
Verification: `not-run` — exercise a listed permitted operation with actual agent
credentials/run context; inspect its result and applicable persistence/attribution.
Assert the relevant denied actor, owner, company or mode from Policy. Review the
@@ -86,6 +139,11 @@ remaining operations before qualifying the whole capability.
Gaps: Account authorization, environment administration and model selection need
separate checks.
Verify automatic onboarding without a post-hire runner change, explicit Legacy,
saved configuration after reload and the actual selected runtime's useful task
plus follow-up. Assert denied agent login/probe and foreign-company account or
environment access separately. These agent-context checks remain `not-run`.
## `first-task`
Review: `baseline`.
@@ -110,6 +168,20 @@ remaining operations before qualifying the whole capability.
Gaps: Task creation is agent-accessible where permitted; scheduling/readiness does not
follow from successful creation.
The Codex first-task fixture's operator cost guard uses the existing board
agent-configuration PATCH before admitting work. This is not evidence of agent
permission to update its runtime; the listed agent-context checks remain not-run.
Scoped source review: 2026-10-13, the first-task asset correction against
`ea6c8eb5e16d61111968f633a66748b397b64a3e`. The existing
[first-task instructions](../../server/src/onboarding-assets/first-task/skills/first-task/SKILL.md)
send file-producing work through the child's own execution and existing
dependency/block controls. Native `register_deliverable` retains its current-task
binding; this adds no cross-task file-registration authority or permission.
Document-only work may use the existing child-document API inline, and plan-only
approval does not authorize implementation. The existing asset tests check
wording only. The literal baseline live attempt failed child-file delivery and
completion reporting; revised live authorization and execution remain `not-run`.
## `resume`
@@ -134,3 +206,20 @@ evidence.
Gaps: No server onboarding-draft API for this browser-local state; classify explicitly
or intentionally make it a cross-client product feature.
Scoped source review of [draft restoration](../../ui/src/components/OnboardingWizard.tsx)
preserves harness/runner intent and invalidates obsolete setup-test results after
a selection change. Browser draft continuity is not API or live-agent evidence,
and opening a saved Legacy agent does not apply new-agent defaults.
The initial matching route opening preserves the authorized saved wizard step
on bare and company-prefixed reloads. Explicit new-company or changed-company
openings retain their navigation override and clear foreign company resource
IDs. [Existing component controls](../../ui/src/components/OnboardingWizard.test.tsx)
exercise these presentation and account boundaries. This scoped correction adds
no server draft API, agent grant or proof of live actor authorization. The
managed first-task eval still reloads before Get started and remains a separate
live acceptance gate. The existing mocked-login browser case also checks the
same company-scoped active session and saved selection through route reopening
and a real reload, with one login start and no pending-login hire. These fixture
assertions do not prove server authorization or live credential use.
+11
View File
@@ -62,6 +62,17 @@ Gaps: Eligible resolutions use native `call_api`. The route retains owner and
management authority, active-run/source-trust checks, board-only outcomes and
reconciliation controls.
Scoped source review: 2026-10-13, recovery correction against
`ea6c8eb5e16d61111968f633a66748b397b64a3e`. The
[recovery service](../../server/src/services/recovery/service.ts) reuses the
existing completed-onboarding handoff validator. It checks the source company,
onboarding origin, Done parent, assignee and Done child, with no unfinished
siblings, before exempting the report from the issue-terminal backstop. Other
terminal work and process-death/controller ownership checks remain enforced.
This internal wake handling adds no recovery-resolution or cross-company agent
authority. Real PostgreSQL regressions cover allowed and rejected bindings;
revised live agent authorization and callback delivery remain `not-run`.
## `source-action`
Review: `baseline`.
+82
View File
@@ -11,6 +11,11 @@ Each capability needs operation-level review before its entire scope can be
qualified. No live agent execution is claimed. Follow the [map contract](../README.md)
for format, permission checks, evidence and maintenance.
Scoped runner-default source review: 2026-10-12 at
`eee0534154eaf5d0d3f2bbc97c1e24d86f34a5a5`, limited to runner discovery,
selected-runtime testing and saved execution/session preservation. The original
baseline source and review depth remain unchanged for unrelated operations.
Scoped native cancellation source review: 2026-10-12 at
`ce23716503a2c9158b5a7ab74ab61571ff4ee7a3`, limited to owned startup and
shutdown/readiness ordering. The original baseline and review depth remain for
@@ -42,6 +47,20 @@ remaining operations before qualifying the whole capability.
Gaps: Raw instance adapter listing requires board, while company model discovery exists
for eligible agents.
Scoped source-only evidence: [adapter discovery](../../server/src/routes/adapters.ts)
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
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
a local target. Discovery and automatic creation keep the Legacy path without
provisioning it; explicit Paperclip selection returns qualification guidance.
The existing execution path owns lazy environment creation. Saved runners remain
unchanged. This is source/control evidence, not live Kubernetes qualification.
## `validation`
Review: `baseline`.
@@ -67,6 +86,42 @@ remaining operations before qualifying the whole capability.
Gaps: Environment test path checks creation/configuration policy; missing provider auth
must use its owner setup flow.
Scoped source review: [test authorization and resolution](../../server/src/routes/agents.ts)
requires same-company `agents:create`, saved-agent update access when supplied,
allowed environment selection, and normal secret/responsible-user checks. An
unchanged saved runner avoids new-creation policy but still receives selected
runtime validation. [Native readiness](../../server/src/services/native-runtime/setup-readiness.ts)
tests native Codex startup/model/account and bounded teardown; an authentication
or Legacy CLI check cannot replace that runtime evidence. Missing dependencies,
credentials, invalid models and incompatible settings fail without silently
changing runners. Account login remains board/owner-bound.
For a linked Codex subscription with server-resolved managed credential provenance,
setup checks the resolved owner and stages a private probe copy before local or
remote isolation. Without a selected API key, an unbound linked `CODEX_HOME` is
rejected with explicit Legacy
or managed-account guidance. Caller configuration cannot supply that provenance. Selected `CODEX_API_KEY` and
`OPENAI_API_KEY` authentication skips unrelated subscription reads and refresh
bindings, matching task materialization. The fake-credential controls verify key
precedence without changing the account source or exposing either credential.
The general native
home materializer retains its no-follow symlink guard. Refresh still targets the
checked account source, and confirmed bounded retirement removes its private
copy. Unconfirmed retirement retains the existing cleanup block and owned state.
Failures before probe handoff remove only the owned temporary preparation root;
local daemon resolution occurs before credential staging. Once a probe is handed
off, unconfirmed retirement still retains its recovery state.
[Existing fake-credential setup controls](../../server/src/services/native-runtime/setup-readiness.test.ts)
exercise linked/regular sources and failure cleanup. This adds no credential-read
API, agent login authority or cross-company account access. Its source-only
evidence does not qualify a live permitted/denied agent operation.
Verification for this scope remains `not-run`: use an admitted agent REST/CLI
validation path, assert missing grants and cross-company targets/accounts are
denied, then prove the useful task/follow-up separately. `hire_agent` inherits its
caller's runtime; do not assume its binding accepts arbitrary runner or account
settings. See [creation access](./agents.md#hire).
## `runs`
Review: `baseline`.
@@ -118,6 +173,23 @@ Gaps: Task-session listing and execution-context reset require board agent
management at their HTTP routes. Native `call_api` preserves these denials; native
continuation remains server-owned.
Scoped source-only evidence: [configuration changes](../../server/src/services/agent-configuration.ts)
and [native execution/recovery](../../server/src/services/native-runtime/native-session-executor.ts)
retain existing execution on unrelated edits and use recorded run configuration
for active/recovered runs. Explicit runner changes use revisions and session
invalidation. Verify saved configuration, current versus historical run identity,
restart and follow-up without granting session-reset authority to an agent.
Scoped source review: 2026-10-13, the recovery correction against
`ea6c8eb5e16d61111968f633a66748b397b64a3e` reuses
[completed onboarding handoff validation](../../server/src/services/chat-completion-delivery.ts)
before applying the issue-terminal backstop. The company, parent origin/status,
assignee, completed child and remaining children must match. Only that backstop
is suppressed for the validated report; process-death and controller/native
ownership checks still apply. No agent gains session or recovery control.
PostgreSQL regressions cover valid native/Legacy callbacks and rejected bindings;
revised live execution and actor authorization remain `not-run`.
Scoped Evidence: `source-only` — the
[native transport](../../packages/paperclip-runner/src/live/runnerd-codex-transport.ts)
joins owned startup before certifying cleanup. Rejected duplicates cannot replace
@@ -130,3 +202,13 @@ Scoped Verification: `not-run` — verify allowed/denied access using the actual
actor independently from the credential-free ownership controls. This correction
adds no account-setup, cleanup-override or session-reset authority. The real native
readiness/recovery fixtures and installed provider acceptance remain separate gates.
Scoped shutdown source review: 2026-10-14, against literal
`651a3de64fc943216cc414b64b1443809f9499da`. The suspension barrier bounds remote
state reads by its remaining deadline and rejects late proof. Intentional
[provider ingress shutdown](../../server/src/realtime/runner-prp-outbound.ts)
joins physical socket closure and retires the connection owner before endpoint
release. Unconfirmed termination stays blocked. Existing fake-peer and delayed
read controls are source evidence; this changes no actor, company, account or
session-reset permission. Operation-level verification remains `not-run` and
the failed Test setup does not qualify successful live cleanup.
+26 -1
View File
@@ -12,7 +12,7 @@ Agent access: [companion](../agent-access-map/agents.md).
## Sub-features
- `hire`: choose identity, adapter, model, and required account settings; edit
- `hire`: choose identity, harness, model, and required account settings; edit
reporting relationships through the agent configuration afterward.
- `grok-bot`: experimental existing-Bot agent setup through remote OAuth, routine credentials, and an authenticated two-way challenge; current contact, stop and explicit recovery.
- `instructions`: edit agent configuration and instruction files with durable revisions.
@@ -50,6 +50,25 @@ Automated: [new agent page](../../ui/src/pages/NewAgent.test.tsx) and [configura
Manual: Hire an agent with a distinct name and role. Complete any required approval, reload the roster, and open its configuration. Confirm manager, adapter, and model persisted; run a small task to prove execution readiness.
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
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).
Repeat with an explicit Legacy Codex hire. If approval is required, compare its
reviewed execution configuration before and after restart and activation; approval
must retain the reviewed runner. Test an existing Legacy and native agent through
an unrelated edit, reload and restart: neither is automatically upgraded.
Creation defaults also apply to API/CLI hires, built-ins, plugins and new team
imports. Exports preserve resolved execution choices; a package without a runner
choice uses the new-agent default, while updating an existing same-harness agent
preserves its execution. For a native Codex import, edit a model or account binding
and verify unchanged runtime settings survive; an explicit clear must stay cleared.
Use the [portability recipe](./companies.md) for package preparation and boundaries.
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
@@ -61,6 +80,12 @@ Automated: [instruction service](../../server/src/__tests__/agent-instructions-s
Manual: Save a harmless instruction edit, reload, and verify its revision and effect in the next eligible run. Pause and resume the disposable agent; confirm pause prevents new work and does not erase previous runs.
An explicit runner change is a configuration change, with normal revisions and
session invalidation. Incompatible settings must produce a field-specific error;
selecting another runner must not silently discard them or change an active run's
recorded execution. Keep runner choice in Advanced and verify the next eligible
run separately from historical transcripts.
### `organization`
Automated: [org chart](../../ui/src/pages/OrgChart.test.tsx) is component evidence. Built-in setup also has [configuration modal tests](../../ui/src/components/ConfigureBuiltInAgentModal.test.tsx).
+6 -6
View File
@@ -18,12 +18,6 @@ Agent access: [companion](../agent-access-map/instance-operations.md).
- `diagnostics`: use doctor/health output to distinguish readiness from a listening port.
- `backup`: create and inspect backups and verify restore only into a separate disposable target.
Routine HTTP monitoring may use `GET /api/liveness` (or `HEAD`). It returns only
process startup status and the public build commit without SQL or authentication.
It remains reachable during an idle drain. Use `/api/health` for explicit database
readiness, signed bootstrap, and authorized diagnostics; liveness does not
establish database health or claim a workspace.
## How to get to it (user POV)
### `operator-cli`
@@ -44,6 +38,12 @@ Automated: [doctor](../../cli/src/__tests__/doctor.test.ts), [update command](..
Manual: Inspect the selected installation/channel and configuration, start the isolated instance, confirm health and browser access, then stop that instance. For update changes, use the dry-run/check path first and verify installed version and rollback state on an owned disposable installation.
Routine HTTP monitoring may use `GET /api/liveness` (or `HEAD`). It returns only
process startup status and the public build commit without SQL or authentication.
It remains reachable during an idle drain. Use `/api/health` for explicit database
readiness, signed bootstrap, and authorized diagnostics; liveness does not
establish database health or claim a workspace.
### `settings-backup`
Automated: [experimental settings](../../ui/src/pages/InstanceExperimentalSettings.test.tsx) and [database backup routes](../../server/src/__tests__/instance-database-backups-routes.test.ts) cover their UI/API contracts.
+74
View File
@@ -54,6 +54,80 @@ 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
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
useful task and follow-up with an independently checked file artifact. Saved
execution configuration and recorded run identity must match after reload.
The Codex first-task eval applies operator QA bounds before Get started through
the existing agent configuration API. It verifies that only timeout and
concurrency change; automatic runner, account and environment choices remain.
These campaign bounds are separate from production creation defaults.
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 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
permission. Refresh company/environment-scoped discovery before testing the
selected runtime. A denied probe, unconfirmed cleanup or setup failure must be
visible; selecting Legacy remains an explicit choice. Creation and discovery
themselves allocate no target. A valid measured unsupported target follows the
server's Legacy default. Named Cloud snapshots remain a separate unqualified
boot-identity and fresh-signup gap.
Selected native setup derives its target isolation as task execution does. The
hello probe retains its read-only permission profile, approval-never policy and
bounded provider turn. It skips Codex's project-instruction discovery in its new,
empty private workspace; ordinary tasks still load their project instructions.
Local and SSH transports retain Codex isolation. An injected environment flag
cannot establish an outer sandbox identity. Verify these boundaries with the
existing native setup controls and a real managed task/follow-up; source checks
alone are insufficient.
The explicit manual Product E2E cell
`confirmation-replies.runner-codex.daytona.task-card-accept` covers this fresh
managed wizard path without fixture agent creation or a fixture preprobe. Its
existing attachment oracle checks a downloaded physical file and follow-up
continuity. Setup evidence must correlate both lease IDs with the same company,
environment and provider; missing responses or unmatched rows keep cost coverage
unknown. Run this cell only on an eligible target with separately authorized
provider, runtime and cleanup reservations. Its source and mock controls are
prepared. The literal `ea6c8eb5e16d61111968f633a66748b397b64a3e` live attempt
failed: the child document existed, but the physical file was attached to the
parent, and recovery stopped the completion callback before a provider turn.
The failed report remains evidence; the revised source still needs live proof.
Local cases and automatic selection remain unchanged.
For accepted file-producing work, verify exactly one self-assigned child with a
self-contained description. The onboarding run must yield through the existing
dependency/block disposition so the child's own execution can publish its file.
Native file registration remains bound to the executing task. On completion,
the original conversation must receive the actual child and downloadable file
links. Document-only child work may finish inline; a plan-only request must not
start implementation. Existing first-task asset controls check the instructions,
not model compliance. Inspect the child's execution, document, downloaded file
and source-correlated completion reply in the real journey.
Leave and resume during harness/runner setup. Confirm the draft retains the
explicit choice and compatible account bindings, old native Codex setup links
recover the harness and runner, and a changed harness or runner invalidates stale
test results. Existing Legacy agents remain Legacy when their onboarding/setup
configuration is reopened; this journey does not migrate them.
Reload both `/onboarding` and the company-prefixed route after the first agent is
saved but before Get started. The authorized draft must retain its current step
and that agent's execution choice. An explicit new-company opening still starts
fresh. Opening another company must clear the previous company's resource IDs;
an unauthorized account must not restore its draft. Existing component controls
cover these boundaries; they do not substitute for the managed first-task eval.
The existing mocked-login browser case reopens and reloads Connect directly. It
checks the saved company/name/harness/runner, exact active authorization session,
one login start and no hire while login is pending. This is login UI evidence,
not a provider or managed-environment qualification.
### `cli-setup`
Automated: [CLI onboarding](../../cli/src/__tests__/onboard.test.ts) and [isolated test drive](../../cli/src/__tests__/test-drive.test.ts) cover their setup contracts; they do not establish that every model account is usable.
+11
View File
@@ -102,6 +102,17 @@ cleanup. The [route regressions](../../server/src/__tests__/issue-recovery-actio
and [heartbeat regression](../../server/src/__tests__/heartbeat-process-recovery.test.ts)
cover this sequence without a live operator recovery claim.
An intentionally Done onboarding parent can still need its validated
child-completion report. Recovery must not mark that callback successful before
execution solely because the parent is Done. The existing
[stale-lock sweep suite](../../server/src/__tests__/recovery-stale-issue-lock-sweep.test.ts)
covers delayed native/Legacy startup, invalid parent/child/company/assignee
bindings and a dead owner. Process-death and ownership backstops remain active.
Repeat the real onboarding journey and correlate callback turn events with its
reply and downloadable child file; status alone does not prove delivery. The
literal `ea6c8eb5e16d61111968f633a66748b397b64a3e` live failure is retained,
and the revised behavior still needs live qualification.
### `run-detail`
Automated: [run workspace recovery](../../ui/src/components/RunWorkspaceRecoverySurface.test.tsx)
+73
View File
@@ -37,12 +37,75 @@ Automated: [adapter validation routes](../../server/src/__tests__/agent-adapter-
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
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
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;
an explicit Paperclip request must give target-qualification guidance. Begin the
ordinary task to exercise the existing lazy environment path. This live journey
remains separate from the resolver's source controls.
For sandbox defaults, follow the [environment probe](./execution-environments.md#environment-settings)
recipe first. Discovery/creation read company-bound immutable-image evidence
without allocating a target. A mutable image or snapshot name does not establish
native compatibility. Then test the actual selected runtime with its selected
account/model: a legacy CLI or authentication probe alone cannot establish native
readiness. Missing dependencies, failed authentication, invalid models and
incompatible custom settings must remain actionable errors without silent runner
switching. Saved unchanged execution can still be tested without applying new
selection policy, but runtime/account validation must still run.
Verify an automatic Codex hire and an explicit Legacy control through a useful
file artifact, follow-up and reload. Preserve Codex reasoning effort and account
isolation; retain explicit Legacy permission choices, including false. Reopen an
existing agent, make an unrelated edit, and confirm both its persisted runner and
next run stay unchanged. Use [hiring/import preservation](./agents.md#hire-agent)
for approval and portability controls.
Test native Codex with a saved subscription whose setup auth file is a managed
link, as well as a regular file and an API-key account. Setup must seed the
isolated probe from the checked account without exposing credentials or changing
its saved runner. With subscription authentication, an unbound configured
`CODEX_HOME` with linked credentials must fail setup with managed-account or explicit Legacy guidance; it must not claim
readiness when task isolation would omit the file. Dangling or incorrectly owned
credentials must also fail clearly. A selected `CODEX_API_KEY` or
`OPENAI_API_KEY` takes precedence over an unrelated subscription file. Test valid,
dangling and invalid subscription links with that selected key; setup must test
the key without reading or refreshing the unrelated account. The private probe copy must be removed after confirmed retirement following success,
failure or cancellation. Unconfirmed shutdown remains a cleanup failure.
Exercise an unavailable local daemon and a preparation failure before probe
handoff. No owned temporary credential copy may remain, and original account
files must be unchanged. A probe whose shutdown is unconfirmed must retain its
recovery state; do not treat it as a preparation-only failure.
Existing setup controls use fake credentials; real account execution remains
a separate acceptance requirement.
### `run-history`
Automated: [live run routes](../../server/src/__tests__/agent-live-run-routes.test.ts) and [run event sequencing](../../server/src/__tests__/heartbeat-run-event-sequencing.test.ts) cover run data contracts.
Manual: Start a disposable task, open its running transcript, and follow it to a terminal outcome. Reload and compare task/run IDs, status, output, and cost. Verify a failed run remains distinguishable from a completed run with useful output.
An explicit runner change starts a configuration revision and invalidates the
eligible session. Active runs, restart recovery and historical transcript parsing
retain their recorded execution configuration; do not infer their runner from a
later edit to the agent. Account/harness branding and execution identity are
different observations, both required in the evidence.
For an onboarding child-completion callback, verify actual session/turn events
and the source-correlated reply. A `succeeded` row alone is insufficient: the
literal `ea6c8eb5e16d61111968f633a66748b397b64a3e` managed attempt was marked
successful during startup without a provider turn. The recovery correction
preserves the existing validated Done-parent handoff; process-death and ownership
checks remain active. Its PostgreSQL controls are separate from the required
revised-source live onboarding and follow-up proof.
## Gotchas
- Model accounts are managed in Apps and inline agent account controls.
@@ -61,3 +124,13 @@ state. Readiness observes shutdown; resumed startup cannot reactivate ingress
after close. These are lifecycle controls, separate from installed provider proof.
The real daemon readiness and recovery controls require their existing native
fixtures in hosted CI; passing credential-free controls does not qualify them.
For remote setup shutdown, verify that each durable-state read respects the
remaining suspension deadline. A late result cannot establish suspension or
queue a replacement command after that deadline. An intentional ingress close
must observe physical socket closure before retiring its connection owner and
releasing the endpoint; an unresponsive peer must not extend the setup budget.
The existing transport and ingress suites exercise these conditions without a
provider. Repeat the actual managed setup and useful task journey separately;
the literal `651a3de64fc943216cc414b64b1443809f9499da` Test attempt failed
cleanup before hire and retains its unconfirmed recovery state.
+3
View File
@@ -370,6 +370,7 @@ export interface AdapterEnvironmentTestContext {
companyId: string;
adapterType: string;
config: Record<string, unknown>;
managedAiCredentialHome?: string;
/**
* Optional execution target the adapter should run probes against.
*
@@ -722,8 +723,10 @@ export interface CLIAdapterModule {
// ---------------------------------------------------------------------------
export interface CreateConfigValues {
runner?: "auto" | "paperclip" | "legacy";
adapterType: string;
codexPermissionMode?: "never" | "on-request" | "untrusted";
codexLegacySandboxBypass?: boolean;
paperclipRunnerLifecycleMode?: "per_turn" | "warm";
paperclipRunnerIdleTimeoutMs?: number;
cwd: string;
+29 -1
View File
@@ -1,6 +1,7 @@
import {
PAPERCLIP_RUNNER_DEFAULT_MODELS,
} from "@paperclipai/adapter-utils";
import { PAPERCLIP_RUNNER_MINIMUM_GLIBC_VERSION } from "@paperclipai/shared";
export const type = "codex_local";
export const label = "Codex";
@@ -12,6 +13,29 @@ export const SANDBOX_INSTALL_COMMAND = "npm install -g @openai/codex";
// Codex CLI warn ("Model metadata for `gpt-5.6` not found") and fall back to generic context limits.
export const DEFAULT_CODEX_LOCAL_MODEL = PAPERCLIP_RUNNER_DEFAULT_MODELS.codex;
export const DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX = true;
export function resolveCodexLocalCliPolicy(config: unknown, options: { networkAccess?: boolean } = {}) {
const record = typeof config === "object" && config !== null && !Array.isArray(config)
? config as Record<string, unknown> : {};
const stringArgs = (value: unknown): string[] => Array.isArray(value)
? value.filter((item): item is string => typeof item === "string") : [];
const configuredExtraArgs = stringArgs(record.extraArgs);
const extraArgs = configuredExtraArgs.length > 0 ? configuredExtraArgs : stringArgs(record.args);
const explicitSandbox = extraArgs.some((arg) =>
/^(--sandbox(?:=|$)|-s|--profile(?:=|$)|-p|--full-auto$|--yolo$|--dangerously-bypass-approvals-and-sandbox$)/.test(arg)
|| /^(?:(?:--config=|-c=?)\s*)?(?:sandbox_mode|profile)\s*=/.test(arg),
);
const explicitPermissionRestriction = extraArgs.some((arg) =>
/^(?:(?:--config=|-c=?)\s*)?(?:approval_policy\s*=|sandbox_workspace_write\.network_access\s*=\s*false)/.test(arg),
);
const defaultBypass = !explicitSandbox && !explicitPermissionRestriction
&& options.networkAccess !== false && DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX;
const bypass = typeof record.dangerouslyBypassApprovalsAndSandbox === "boolean"
? record.dangerouslyBypassApprovalsAndSandbox
: typeof record.dangerouslyBypassSandbox === "boolean" ? record.dangerouslyBypassSandbox : defaultBypass;
return { extraArgs, explicitSandbox, bypass };
}
export const CODEX_LOCAL_FAST_MODE_SUPPORTED_MODELS = [
"gpt-6-astra",
"gpt-6.1-sol",
@@ -188,6 +212,10 @@ export const models = [
export const agentConfigurationDoc = `# codex_local agent configuration
New agents select the Codex harness with adapterType: codex_local and an optional request-level runner: auto, paperclip, or legacy. Automatic selection uses Paperclip Runner on qualified Linux x64/ARM64 targets with GNU libc ${PAPERCLIP_RUNNER_MINIMUM_GLIBC_VERSION} or newer and macOS arm64/x64 targets. It stores adapterType: paperclip_runner with provider: codex. Windows, 32-bit ARM, Alpine/musl, and older or unknown Linux libc retain automatic legacy selection. Qualification and artifact checks use the selected execution environment, including its architecture, rather than the controller's platform. Other harness defaults are unchanged.
Set runner: legacy to retain this adapter's execution and custom settings. Explicit runner: paperclip requires a qualified target. Existing agents and reviewed pending hires retain their saved runner; authentication, model, dependency, and runtime errors never silently switch runners. The fields below describe legacy codex_local execution. Native creation translates supported settings and rejects unsupported settings with the explicit Legacy runner option.
Adapter: codex_local
Core fields:
@@ -229,7 +257,7 @@ Notes:
- Paperclip injects desired local skills into the effective CODEX_HOME/skills/ directory at execution time so Codex can discover "$paperclip" and related skills without polluting the project working directory. For new and updated agents, Paperclip assigns an isolated managed home at ~/.paperclip/instances/<id>/companies/<companyId>/agents/<agentId>/codex-home/skills/; when CODEX_HOME is explicitly overridden in adapter config, that override is used instead.
- New and updated codex_local agents persist an empty OPENAI_API_KEY override by default so a host-level OPENAI_API_KEY cannot leak into Codex runs through process inheritance. Explicit CODEX_HOME overrides must not point at the shared company codex-home, $CODEX_HOME, or ~/.codex.
- Some model/tool combinations reject certain effort levels (for example minimal with web search enabled).
- With ChatGPT sign-in, the Codex backend accepts a model only from a recent enough Codex CLI: GPT-6.1 Sol needs 0.159.0 or newer, GPT-6 Sol and GPT-6 Luna need 0.157.0 or newer. An older CLI fails every turn with "The '<model>' model is not supported when using Codex with a ChatGPT account." The environment Test and the remote runner compare the installed \`codex --version\` against these floors before running; in a managed sandbox, the fix is a sandbox image that ships the Codex version Paperclip pins.
- With ChatGPT sign-in, model availability depends on the installed Codex capabilities. Legacy setup reports known model/version mismatches. Native setup tests the actual app-server and selected model; a version number alone does not reject or replace an installed CLI. If the provider rejects the model, select an available model or update Codex in the execution environment.
- Fast mode is supported on GPT-6 (astra/sol/luna), GPT-6.1 Sol, GPT-5.6 (sol/terra/luna), GPT-5.5, GPT-5.4 and manual model IDs. When enabled for those models, Paperclip applies \`service_tier="fast"\` and \`features.fast_mode=true\`.
- When Paperclip realizes a workspace/runtime for a run, it injects PAPERCLIP_WORKSPACE_* and PAPERCLIP_RUNTIME_* env vars for agent-side tooling.
- The ACP engine keeps its workspace sandbox and enables network access on each turn. Explicit sandbox_workspace_write.network_access overrides in extraArgs (or env.PAPERCLIP_CODEX_ACP_NETWORK_ACCESS="false") disable it; execution-target network denial wins. The bundled ACP patch is needed because upstream mode presets override Codex config.toml on every turn.
@@ -1,7 +1,22 @@
import { describe, expect, it } from "vitest";
import { buildCodexExecArgs } from "./codex-args.js";
import { resolveCodexLocalCliPolicy } from "../index.js";
describe("buildCodexExecArgs", () => {
it.each([
{ config: { extraArgs: ["--sandbox", "read-only"] }, bypass: false, args: ["exec", "--json", "--sandbox", "read-only", "-"] },
{ config: { args: ["-c", "approval_policy=on-request"] }, bypass: false, args: ["exec", "--json", "-c", 'sandbox_mode="workspace-write"', "-c", "sandbox_workspace_write.network_access=true", "-c", "approval_policy=on-request", "-"] },
{ config: { extraArgs: [], args: ["--profile", "restricted"] }, bypass: false, args: ["exec", "--json", "--profile", "restricted", "-"] },
{ config: { extraArgs: [0, null], args: ["--sandbox", "read-only"] }, bypass: false, args: ["exec", "--json", "--sandbox", "read-only", "-"] },
{ config: { extraArgs: ["--color", "never"], args: ["--sandbox", "read-only"] }, bypass: true, args: ["exec", "--json", "--dangerously-bypass-approvals-and-sandbox", "--color", "never", "-"] },
{ config: { dangerouslyBypassApprovalsAndSandbox: true, extraArgs: ["--sandbox", "read-only"] }, bypass: true, args: ["exec", "--json", "--dangerously-bypass-approvals-and-sandbox", "--sandbox", "read-only", "-"] },
{ config: { dangerouslyBypassSandbox: true, args: ["--sandbox", "read-only"] }, bypass: true, args: ["exec", "--json", "--dangerously-bypass-approvals-and-sandbox", "--sandbox", "read-only", "-"] },
{ config: { dangerouslyBypassApprovalsAndSandbox: false, dangerouslyBypassSandbox: true, extraArgs: ["--color", "never"] }, bypass: false, args: ["exec", "--json", "-c", 'sandbox_mode="workspace-write"', "-c", "sandbox_workspace_write.network_access=true", "--color", "never", "-"] },
])("keeps the browser policy and exact execution arguments consistent for $config", ({ config, bypass, args }) => {
expect(resolveCodexLocalCliPolicy(config).bypass).toBe(bypass);
expect(buildCodexExecArgs(config).args).toEqual(args);
});
it.each([null, "existing-session"])("defaults direct and resumed launches to full bypass (%s)", (resumeSessionId) => {
const { args } = buildCodexExecArgs({}, { resumeSessionId });
expect(args).toContain("--dangerously-bypass-approvals-and-sandbox");
@@ -1,7 +1,7 @@
import { asBoolean, asString, asStringArray } from "@paperclipai/adapter-utils/server-utils";
import { asBoolean, asString } from "@paperclipai/adapter-utils/server-utils";
import {
CODEX_LOCAL_FAST_MODE_SUPPORTED_MODELS,
DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX,
resolveCodexLocalCliPolicy,
isCodexLocalFastModeSupported,
normalizeCodexModel,
} from "../index.js";
@@ -16,12 +16,6 @@ export type BuildCodexExecArgsResult = {
fastModeIgnoredReason: string | null;
};
function readExtraArgs(config: unknown): string[] {
const fromExtraArgs = asStringArray(asRecord(config).extraArgs);
if (fromExtraArgs.length > 0) return fromExtraArgs;
return asStringArray(asRecord(config).args);
}
function asRecord(value: unknown): Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
? (value as Record<string, unknown>)
@@ -49,20 +43,7 @@ export function buildCodexExecArgs(
const search = asBoolean(record.search, false);
const fastModeRequested = asBoolean(record.fastMode, false);
const fastModeApplied = fastModeRequested && isCodexLocalFastModeSupported(model);
const extraArgs = readExtraArgs(record);
// Explicit CLI modes/profiles remain deliberate overrides. An omitted
// setting uses the same full-auto default as agent creation and onboarding.
const explicitSandbox = extraArgs.some((arg) =>
/^(--sandbox(?:=|$)|-s|--profile(?:=|$)|-p|--full-auto$|--yolo$|--dangerously-bypass-approvals-and-sandbox$)/.test(arg)
|| /^(?:(?:--config=|-c=?)\s*)?(?:sandbox_mode|profile)\s*=/.test(arg),
);
const explicitPermissionRestriction = extraArgs.some((arg) =>
/^(?:(?:--config=|-c=?)\s*)?(?:approval_policy\s*=|sandbox_workspace_write\.network_access\s*=\s*false)/.test(arg),
);
const bypass = asBoolean(
record.dangerouslyBypassApprovalsAndSandbox,
asBoolean(record.dangerouslyBypassSandbox, !explicitSandbox && !explicitPermissionRestriction && options.networkAccess !== false && DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX),
);
const { extraArgs, explicitSandbox, bypass } = resolveCodexLocalCliPolicy(record, options);
const args = ["exec", "--json"];
if (!bypass && !explicitSandbox) {
args.push("-c", 'sandbox_mode="workspace-write"');
+4 -1
View File
@@ -231,7 +231,10 @@ scripts; it verifies native startup and package continuity separately from the
restricted Docker installation check. This does not prove a normal OS package
installation journey or a paid provider task on ARM64.
Windows, 32-bit ARM, and Alpine/musl native qualification remain deferred.
Automatic creation selects legacy execution when a Linux host has musl, unknown
libc, or a measured GNU libc version below the baseline. Existing agents retain
their stored runner. Windows, 32-bit ARM, and Alpine/musl native qualification
remain deferred.
Native release jobs cache Cargo dependencies per platform and OS baseline,
including compiler, lockfile, and external generated-contract inputs. Workspace
@@ -1787,12 +1787,52 @@ describe("Capability live runnerd and Codex session", () => {
turnTimeoutMs: 500,
});
state.holdAfterTool = true;
const killedTurn = captureTurnRejection(first.sendMessage("Apply idempotent progress once."));
await vi.waitFor(async () => {
expect((await firstStore.load(binding.sessionId))?.mockState).toContain(
"Progress persisted through the live Codex tool loop.",
);
let turnSettled = false;
let turnFailure: unknown = null;
const killedTurn = captureTurnRejection(first.sendMessage("Apply idempotent progress once.")).then((error) => {
turnSettled = true;
turnFailure = error;
return error;
});
try {
await vi.waitFor(async () => {
expect((await firstStore.load(binding.sessionId))?.mockState).toContain(
"Progress persisted through the live Codex tool loop.",
);
});
} catch (error) {
try {
const summarize = (snapshot: CapabilityLiveSessionSnapshot | null) => {
if (snapshot === null) return null;
const mock = JSON.parse(snapshot.mockState);
return {
revision: snapshot.revision,
status: snapshot.status,
activeTurnId: snapshot.activeTurnId,
mockRevision: mock.revision,
comments: mock.comments.length,
evidenceKinds: snapshot.evidence.slice(-8).map((entry) => entry.kind),
};
};
const stored = await firstStore.load(binding.sessionId).catch(() => null);
const diagnostics = {
turnSettled,
turnFailure: turnFailure instanceof Error
? { name: turnFailure.name, message: turnFailure.message.slice(0, 240) }
: typeof turnFailure,
requests: state.transports[0]?.requests.slice(-16).map((request) => request.method),
memory: summarize(first.snapshot()),
stored: summarize(stored),
};
if (error instanceof Error) error.message += `\nDurable progress diagnostics: ${JSON.stringify(diagnostics)}`;
} catch (diagnosticError) {
const diagnosticUnavailable = diagnosticError instanceof Error
? { name: diagnosticError.name, message: diagnosticError.message.slice(0, 240) }
: { name: typeof diagnosticError };
if (error instanceof Error) error.message += `\nDurable progress diagnostics unavailable: ${JSON.stringify(diagnosticUnavailable)}`;
}
throw error;
}
await expect(first.recordUsage({
receiptId: "provider-response-1",
providerResponseId: "response-1",
@@ -1404,6 +1404,61 @@ it("reserves a bounded suspension window after close preparation", () => {
});
});
it.each([
{ existingCommand: false, rejectLateRead: false },
{ existingCommand: false, rejectLateRead: true },
{ existingCommand: true, rejectLateRead: false },
{ existingCommand: true, rejectLateRead: true },
])("returns unconfirmed suspension at its deadline while a remote state read is still pending ($existingCommand, $rejectLateRead)", async ({ existingCommand, rejectLateRead }) => {
vi.useFakeTimers();
const commands: { commandId: string; type: string; status: string }[] = existingCommand
? [{ commandId: "exact-suspend", type: "runner.suspend", status: "completed" }]
: [];
const readRunnerState = vi.fn(async () => {
await new Promise<void>(resolveRead => setTimeout(resolveRead, 10_000));
if (rejectLateRead) throw new Error("remote state unavailable after its own timeout");
return { lifecycle: "suspended" };
});
const unhandled = vi.fn();
process.on("unhandledRejection", unhandled);
const queueSuspend = vi.fn((commandId: string) => commands.push({ commandId, type: "runner.suspend", status: "completed" }));
const release = vi.fn();
let settled = false;
let suspended: boolean | undefined;
const result = runnerdRecoveryInternals.awaitRunnerSuspensionBarrier({
commands: () => commands,
queueSuspend,
readRunnerState,
runnerHasExited: async () => true,
pump: () => undefined,
deadline: Date.now() + 2_500,
pollIntervalMs: 0,
}).then(value => {
settled = true;
suspended = value;
if (value) release();
return value;
});
try {
await vi.advanceTimersByTimeAsync(2_500);
expect(readRunnerState).toHaveBeenCalledOnce();
expect(settled).toBe(true);
expect(suspended).toBe(false);
expect(queueSuspend).toHaveBeenCalledTimes(existingCommand ? 0 : 1);
expect(release).not.toHaveBeenCalled();
} finally {
try {
await vi.advanceTimersByTimeAsync(10_000);
await expect(result).resolves.toBe(false);
expect(release).not.toHaveBeenCalled();
expect(unhandled).not.toHaveBeenCalled();
} finally {
process.off("unhandledRejection", unhandled);
vi.useRealTimers();
}
}
});
it.each([
{ provider: "acpx" as const, acpxAgent: "pi" as const, closeGraceMs: undefined, drained: true },
{ provider: "codex" as const, acpxAgent: undefined, closeGraceMs: undefined, drained: false },
@@ -2140,6 +2195,7 @@ it("denies the isolated Codex home without denying a remote execution workspace"
readOnlyRoots: ["/usr/local"],
});
const serialized = args.join("\n");
expect(args).not.toContain("project_doc_max_bytes=0");
expect(serialized).toContain(
'"/workspaces/task/.paperclip-runtime/paperclip-runner/sessions/session/filesystem/codex-home"="none"',
@@ -2830,12 +2886,14 @@ const fakeCodex = resolve(
"../../runner/target/debug/fake-codex-app-server",
);
it.each(["stuck", "late-release", "late-release-error", "stuck-release", "cooperative-release", "launch-failure"] as const)(
it.each(["stuck", "late-release", "late-release-error", "stuck-release", "cooperative-release", "launch-failure", "launch-failure-sandbox", "launch-failure-unsandboxed"] as const)(
"bounds setup registration teardown and retains unconfirmed state (%s)",
async mode => {
const root = await mkdtemp(join(tmpdir(), "runnerd-setup-registration-"));
const runtimeDirectory = join(root, "native-state");
await mkdir(runtimeDirectory, { mode: 0o700 });
const launchFailure = mode.startsWith("launch-failure");
let template: Record<string, unknown> | null = null;
let registrationStarted!: () => void;
const started = new Promise<void>(resolve => { registrationStarted = resolve; });
let returnRegistration!: (registration: { connection: { mode: "listen"; listenAddress: string; listenPort: number; listenPath: string }; release: () => Promise<void> }) => void;
@@ -2853,17 +2911,22 @@ it.each(["stuck", "late-release", "late-release-error", "stuck-release", "cooper
};
let registrationSignal: AbortSignal | undefined;
const cleanup = vi.fn(async () => { await rm(runtimeDirectory, { recursive: true, force: true }); });
const launch = vi.fn(() => { throw new Error("owned launcher failed before execution"); });
const launch = vi.fn((spec: durableControlPlane.RunnerProcessLaunchSpec) => {
expect(spec.environment.PAPERCLIP_RUNNER_EXTERNAL_SANDBOX).toBeUndefined();
throw new Error("owned launcher failed before execution");
});
let settled = false;
vi.useFakeTimers();
const result = probeNativeRunnerEnvironment({
runtimeDirectory, provider: "codex", model: "gpt-test", timeoutMs: 1_000,
environment: { PATH: "", OPENAI_API_KEY: "inert-test-key" },
environment: { PATH: "", OPENAI_API_KEY: "inert-test-key", PAPERCLIP_RUNNER_EXTERNAL_SANDBOX: "true" },
transportOptions: {
...(mode === "launch-failure-sandbox" ? { externallySandboxed: true } : mode === "launch-failure-unsandboxed" ? { externallySandboxed: false } : {}),
runnerBinary: resolve(import.meta.dirname, "../../package.json"),
codexCommand: resolve(import.meta.dirname, "../../package.json"),
sourceCodexHome: "", runnerProcessLauncher: launch,
controlPlaneRegistration: async (_authority, _identity, signal) => {
template = structuredClone(_authority.store.state.runAttachTemplate!);
registrationSignal = signal;
if (mode === "cooperative-release") signal?.addEventListener("abort", () => returnRegistration(registered), { once: true });
registrationStarted();
@@ -2874,16 +2937,23 @@ it.each(["stuck", "late-release", "late-release-error", "stuck-release", "cooper
}).then(value => { settled = true; return value; }, (error: unknown) => { settled = true; return error; });
try {
await started;
if (mode === "launch-failure") returnRegistration(registered);
if (launchFailure) returnRegistration(registered);
await vi.advanceTimersByTimeAsync(1_000);
if (mode === "late-release" || mode === "late-release-error" || mode === "stuck-release") returnRegistration(registered);
await vi.advanceTimersByTimeAsync(10_000);
if (mode === "late-release" || mode === "cooperative-release" || mode === "launch-failure") await result;
if (mode === "late-release" || mode === "cooperative-release" || launchFailure) await result;
expect(settled).toBe(true);
expect(registrationSignal?.aborted).toBe(true);
const failure = await result;
expect(template).toMatchObject({ provider: { externallySandboxed: mode === "launch-failure-sandbox", readOnly: true, approvalPolicy: "never", collaborationMode: "plan" } });
const args = (template!.provider as Record<string, unknown>).args as string[];
expect(args).toContain("project_doc_max_bytes=0");
expect(args[args.indexOf("project_doc_max_bytes=0") - 1]).toBe("-c");
expect(args.indexOf("project_doc_max_bytes=0")).toBeLessThan(args.indexOf("app-server"));
expect(args).not.toContain("--dangerously-bypass-approvals-and-sandbox");
expect(args).toContain('permissions.paperclip-runner-workspace-read-only.network.enabled=false');
expect(failure).toBeInstanceOf(Error);
expect((failure as Error).message).toContain(mode === "launch-failure" ? "owned launcher failed before execution" : "Native provider hello probe timed out.");
expect((failure as Error).message).toContain(launchFailure ? "owned launcher failed before execution" : "Native provider hello probe timed out.");
if (mode === "stuck" || mode === "stuck-release" || mode === "late-release-error") {
expect(failure).toBeInstanceOf(AggregateError);
expect((failure as AggregateError).cause).toMatchObject({ message: "Native provider hello probe timed out." });
@@ -2900,7 +2970,7 @@ it.each(["stuck", "late-release", "late-release-error", "stuck-release", "cooper
expect(cleanup).toHaveBeenCalledOnce();
await expect(stat(runtimeDirectory)).rejects.toThrow();
}
expect(launch).toHaveBeenCalledTimes(mode === "launch-failure" ? 1 : 0);
expect(launch).toHaveBeenCalledTimes(launchFailure ? 1 : 0);
expect(vi.getTimerCount()).toBe(0);
} finally {
returnRegistration(registered);
@@ -781,6 +781,22 @@ async function awaitRunnerSuspensionBarrier(input: {
deadline: number;
pollIntervalMs?: number;
}): Promise<boolean> {
if (Date.now() >= input.deadline) return false;
const readRunnerState = async (): Promise<Record<string, unknown> | undefined> => {
const remainingMs = input.deadline - Date.now();
if (remainingMs <= 0) return undefined;
let timer: ReturnType<typeof setTimeout> | undefined;
try {
return await Promise.race([
input.readRunnerState(),
new Promise<undefined>(resolveDeadline => {
timer = setTimeout(() => resolveDeadline(undefined), remainingMs);
}),
]);
} finally {
clearTimeout(timer);
}
};
let existing = [...input.commands()]
.reverse()
.find(
@@ -793,13 +809,16 @@ async function awaitRunnerSuspensionBarrier(input: {
// Reuse its receipt only when the current exact durable state is already
// suspended; otherwise issue a new command rather than waiting on history.
try {
if ((await input.readRunnerState()).lifecycle === "suspended")
const state = await readRunnerState();
if (state === undefined) return false;
if (state.lifecycle === "suspended")
return Date.now() < input.deadline;
} catch {
// The normal bounded barrier below handles unavailable state.
}
existing = undefined;
}
if (Date.now() >= input.deadline) return false;
const commandId =
existing?.commandId ??
`command_close_suspend_${randomUUID().replaceAll("-", "")}`;
@@ -819,11 +838,14 @@ async function awaitRunnerSuspensionBarrier(input: {
}
let lifecycle: unknown;
try {
lifecycle = (await input.readRunnerState()).lifecycle;
const state = await readRunnerState();
if (state === undefined) return false;
lifecycle = state.lifecycle;
} catch {
// A remote filesystem can lag the command-result delivery by a small
// amount. Keep the single close deadline as the fail-closed bound.
}
if (Date.now() >= input.deadline) return false;
if (
command?.status === "completed" &&
lifecycle === "suspended" &&
@@ -1261,6 +1283,7 @@ export interface CapabilityRunnerdCodexTransportOptions {
runnerBinary?: string;
codexCommand?: string;
codexArgs?: string[];
disableCodexProjectInstructions?: boolean;
/** Controller-visible Codex home used only to seed the isolated runner home. */
sourceCodexHome?: string | null;
opencodeCommand?: string;
@@ -3462,20 +3485,24 @@ export function createRunnerdCodexAppServerArgs(input: {
codexCommand?: string;
readOnlyRoots?: string[];
instructionWorkingCopyRoot?: string;
disableProjectInstructions?: boolean;
}): string[] {
// The filesystem policy denies HOME and CODEX_HOME to keep credentials and
// runner state outside provider reach. Always bind those names to the actual
// isolated runner home; a stale controller environment must never cause the
// execution workspace itself to become an explicit deny root.
return createIsolatedCodexAppServerArgs(
{
...input.environment,
HOME: input.codexHome,
CODEX_HOME: input.codexHome,
},
[...(input.readOnlyRoots ?? []), ...codexExecutableReadOnlyRoots(input.environment ?? {}, input.codexCommand)],
input.instructionWorkingCopyRoot,
);
return [
...(input.disableProjectInstructions ? ["-c", "project_doc_max_bytes=0"] : []),
...createIsolatedCodexAppServerArgs(
{
...input.environment,
HOME: input.codexHome,
CODEX_HOME: input.codexHome,
},
[...(input.readOnlyRoots ?? []), ...codexExecutableReadOnlyRoots(input.environment ?? {}, input.codexCommand)],
input.instructionWorkingCopyRoot,
),
];
}
export function unwrapToolResponse(response: Record<string, unknown>, preserveEnvelope = false): {
@@ -5028,6 +5055,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
codexHome,
codexCommand: effectiveCodexCommand,
instructionWorkingCopyRoot: runtimeContext?.instructions.workingCopy?.rootPath,
disableProjectInstructions: this.options.disableCodexProjectInstructions,
readOnlyRoots: [
...trustedRuntimeReadOnlyRoots(
this.options.environment,
@@ -5523,6 +5551,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
codexHome,
codexCommand: typeof recordedCommand === "string" ? recordedCommand : this.options.codexCommand,
instructionWorkingCopyRoot: runtimeContext?.instructions.workingCopy?.rootPath,
disableProjectInstructions: this.options.disableCodexProjectInstructions,
readOnlyRoots: [
...trustedRuntimeReadOnlyRoots(this.options.environment),
...(runtimeContext
@@ -7210,7 +7239,8 @@ export async function probeNativeRunnerEnvironment(options: {
sourceCodexHome: sourceCodexHome ?? "",
stateDirectory: options.runtimeDirectory,
environment,
externallySandboxed: false,
externallySandboxed: options.transportOptions?.externallySandboxed === true,
disableCodexProjectInstructions: true,
closeGraceMs: 10_000,
lifecyclePolicy: { mode: "per_turn", idleTimeoutMs: null },
});
+45
View File
@@ -0,0 +1,45 @@
export type AgentRunnerChoice = "auto" | "paperclip" | "legacy";
export type AgentRunner = Exclude<AgentRunnerChoice, "auto">;
export interface AgentRunnerAvailability {
supportedRunners: AgentRunner[];
defaultRunner: AgentRunner;
}
export function paperclipRunnerProfileForHarness(harness: string): { provider: "codex" } | undefined {
return harness === "codex_local" ? { provider: "codex" } : undefined;
}
export const PAPERCLIP_RUNNER_MINIMUM_GLIBC_VERSION = "2.39";
function supportsLinuxLibc(libc?: "glibc" | "musl" | "unknown", version?: string): boolean {
if (libc !== undefined && libc !== "glibc") return false;
if (version === undefined) return true;
const match = typeof version === "string" ? version.trim().match(/^(?:glibc\s+)?(\d+)\.(\d+)(?:\.\d+)?$/) : null;
if (!match) return false;
const major = Number(match[1]), minor = Number(match[2]);
if (!Number.isSafeInteger(major) || !Number.isSafeInteger(minor)) return false;
const [requiredMajor, requiredMinor] = PAPERCLIP_RUNNER_MINIMUM_GLIBC_VERSION.split(".").map(Number);
return major > requiredMajor! || (major === requiredMajor && minor >= requiredMinor!);
}
export function paperclipRunnerSupportsPlatform(harness: string, platform: string, architecture: string, libc?: "glibc" | "musl" | "unknown", glibcVersion?: string): boolean {
return !!paperclipRunnerProfileForHarness(harness)
&& ((platform === "linux" && supportsLinuxLibc(libc, glibcVersion) && (architecture === "x64" || architecture === "arm64"))
|| (platform === "darwin" && (architecture === "arm64" || architecture === "x64")));
}
export function agentHarnessType(adapterType: string, config: Record<string, unknown> = {}): string {
if (adapterType !== "paperclip_runner") return adapterType;
if (config.provider === undefined || config.provider === "codex") return "codex_local";
if (config.provider === "opencode") return "opencode_local";
if (config.provider === "acpx") {
const agent = config.acpxAgent ?? "claude";
return ({ codex: "codex_local", claude: "claude_local", grok: "grok_local", cursor: "cursor", pi: "pi_local" } as Record<string, string>)[String(agent)] ?? `acpx:${String(agent)}`;
}
return String(config.provider);
}
export function agentRunner(adapterType: string): AgentRunner {
return adapterType === "paperclip_runner" ? "paperclip" : "legacy";
}
+1
View File
@@ -2870,6 +2870,7 @@ export { updatePrimaryAgentSchema, type UpdatePrimaryAgent, type PrimaryAgentPre
export * from "./agent-avatar-upload.js";
export type { DotBinding, DotInvitation, DotConnection, DotPairing } from "./dot-invitations.js";
export { agentHarnessType, agentRunner, paperclipRunnerProfileForHarness, paperclipRunnerSupportsPlatform, PAPERCLIP_RUNNER_MINIMUM_GLIBC_VERSION, type AgentRunnerChoice, type AgentRunner, type AgentRunnerAvailability } from "./agent-runner.js";
export { EXTERNAL_AGENT_TOOL_GUIDANCE, DOT_AGENT_TOOL_GUIDANCE, GROK_BOT_AGENT_TOOL_GUIDANCE } from "./external-agent-guidance.js";
export { AGENT_LIFECYCLE_STATES, type AgentLifecycleState, type AgentLifecycleOperation } from "./types/agent-lifecycle.js";
@@ -1,4 +1,5 @@
import type { AgentAppearance } from "../agent-appearance.js";
import type { AgentRunnerChoice } from "../agent-runner.js";
import type { AgentEnvConfig } from "./secrets.js";
import type { RoutineVariable } from "./routine.js";
import type { IssueCommentAuthorType, PermissionKey } from "../constants.js";
@@ -211,6 +212,7 @@ export interface CompanyPortabilityIssueManifestEntry {
}
export interface CompanyPortabilityAgentManifestEntry {
runner?: AgentRunnerChoice;
slug: string;
name: string;
path: string;
@@ -387,6 +389,7 @@ export interface CompanyPortabilityPreviewResult {
}
export interface CompanyPortabilityAdapterOverride {
runner?: AgentRunnerChoice;
adapterType: string;
adapterConfig?: Record<string, unknown>;
}
+3
View File
@@ -93,6 +93,7 @@ export const createAgentSchema = z.object({
capabilities: z.string().optional().nullable(),
desiredSkills: z.array(agentDesiredSkillSelectionSchema).optional(),
adapterType: agentAdapterTypeSchema,
runner: z.enum(["auto", "paperclip", "legacy"]).optional(),
adapterConfig: adapterConfigSchema.optional().default({}),
instructionsBundle: createAgentInstructionsBundleSchema.optional(),
runtimeConfig: agentRuntimeConfigSchema.optional().default({}),
@@ -121,6 +122,7 @@ export type CreateAgent = z.infer<typeof createAgentSchema>;
export const builtInAgentProvisionSchema = z.object({
adapterType: agentAdapterTypeSchema.optional(),
runner: z.enum(["auto", "paperclip", "legacy"]).optional(),
adapterConfig: adapterConfigSchema.optional(),
budgetMonthlyCents: z.number().int().nonnegative().optional(),
}).strict();
@@ -256,6 +258,7 @@ export const resetAgentSessionSchema = z.object({
export type ResetAgentSession = z.infer<typeof resetAgentSessionSchema>;
export const testAdapterEnvironmentSchema = z.object({
runner: z.enum(["auto", "paperclip", "legacy"]).optional(),
aiConnection: aiConnectionBindingSchema.optional(),
/** Saved agent whose redacted environment entries are restored for this probe. */
agentId: z.string().guid().optional(),
@@ -71,6 +71,7 @@ export const portabilityBlobManifestEntrySchema = z.object({
});
export const portabilityAgentManifestEntrySchema = z.object({
runner: z.enum(["auto", "paperclip", "legacy"]).optional(),
slug: z.string().min(1),
name: z.string().min(1),
path: z.string().min(1),
@@ -324,6 +325,7 @@ export const companyPortabilityPreviewSchema = z.object({
export type CompanyPortabilityPreview = z.infer<typeof companyPortabilityPreviewSchema>;
export const portabilityAdapterOverrideSchema = z.object({
runner: z.enum(["auto", "paperclip", "legacy"]).optional(),
adapterType: z.string().min(1),
adapterConfig: z.record(z.string(), z.unknown()).optional(),
});
@@ -113,6 +113,8 @@ vi.mock("@paperclipai/adapter-codex-local/server", async (importOriginal) => {
});
let currentActor: Record<string, unknown>;
let agentRoutes: typeof import("../routes/agents.js").agentRoutes;
let errorHandler: typeof import("../middleware/index.js").errorHandler;
function boardActor(userId: string, companyIds: string[] = [COMPANY_1, OTHER_COMPANY]): Record<string, unknown> {
return {
@@ -125,10 +127,6 @@ function boardActor(userId: string, companyIds: string[] = [COMPANY_1, OTHER_COM
}
async function createApp() {
const [{ agentRoutes }, { errorHandler }] = await Promise.all([
vi.importActual<typeof import("../routes/agents.js")>("../routes/agents.js"),
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
]);
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
@@ -144,7 +142,7 @@ const authSignalPath = (companyId: string, type: string, environmentId?: string)
`/api/companies/${companyId}/adapters/${type}/auth-signal${environmentId ? `?environmentId=${environmentId}` : ""}`;
describe("adapter auth-signal route", () => {
beforeEach(() => {
beforeEach(async () => {
vi.resetModules();
vi.clearAllMocks();
currentActor = boardActor(OWNER_A);
@@ -176,6 +174,12 @@ describe("adapter auth-signal route", () => {
effectiveHome: "/tmp/codex-home",
sharedSourceHome: "/tmp/codex-shared-home",
});
const [routes, middleware] = await Promise.all([
vi.importActual<typeof import("../routes/agents.js")>("../routes/agents.js"),
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
]);
agentRoutes = routes.agentRoutes;
errorHandler = middleware.errorHandler;
});
afterEach(() => {
@@ -15,8 +15,14 @@ import {
import {
resolveExternalAdapterRegistration,
setOverridePaused,
hasActiveAdapterOverride,
} from "../adapters/registry.js";
const nativeSetup = vi.hoisted(() => ({ authenticate: vi.fn() }));
vi.mock("../services/native-runtime/setup-readiness.js", () => ({
testNativeRunnerAuthentication: nativeSetup.authenticate,
}));
vi.mock("../vendor/paperclip-runner/live/index.js", () => ({
probeAcpxClaudeInstallation: vi.fn(async () => undefined),
probeAcpxGrokInstallation: vi.fn(async () => undefined),
@@ -267,6 +273,26 @@ describe("server adapter registry", () => {
});
});
it.each(["pass", "fail"] as const)("uses the selected native Codex runtime for setup (%s)", async status => {
nativeSetup.authenticate.mockReset().mockResolvedValue({ adapterType: "paperclip_runner", status, testedAt: "", checks: [{ code: `codex_hello_probe_${status === "pass" ? "passed" : "auth_required"}`, level: status === "pass" ? "info" : "error", message: "Selected native account" }] });
const context = { companyId: "company-1", adapterType: "paperclip_runner", config: { provider: "codex", model: "gpt-5.6-sol", modelReasoningEffort: "high", env: { OPENAI_API_KEY: "selected-key" } } };
const result = await requireServerAdapter("paperclip_runner").testEnvironment(context);
expect(result.status).toBe(status);
expect(nativeSetup.authenticate).toHaveBeenCalledWith(context, "codex", "gpt-5.6-sol");
});
it("identifies only active external overrides for automatic runner selection", () => {
try {
expect(hasActiveAdapterOverride("codex_local")).toBe(false);
registerServerAdapter({ ...externalAdapter, type: "codex_local" });
expect(hasActiveAdapterOverride("codex_local")).toBe(true);
setOverridePaused("codex_local", true);
expect(hasActiveAdapterOverride("codex_local")).toBe(false);
setOverridePaused("codex_local", false);
expect(hasActiveAdapterOverride("codex_local")).toBe(true);
} finally { unregisterServerAdapter("codex_local"); }
});
it.each([
["claude_managed", {
managedProfileId: "managed-primary",
+48 -2
View File
@@ -147,11 +147,11 @@ describe("adapter routes", () => {
}
});
it("keeps paperclip_runner hidden from selection unless the rollout flag is enabled", async () => {
it("keeps the shared runner implementation available for Codex without the experimental flag", async () => {
const disabledResponse = await request(createApp()).get("/api/adapters");
expect(disabledResponse.status).toBe(200);
expect(disabledResponse.body.find((adapter: any) => adapter.type === "paperclip_runner"))
.toMatchObject({ disabled: true });
.toMatchObject({ disabled: false });
const enabledResponse = await request(createApp({}, {
getNativeRunnerEnabled: async () => true,
@@ -166,6 +166,52 @@ describe("adapter routes", () => {
});
});
it.each([
[{ driver: "ssh", platform: "linux", architecture: "x64" }, "paperclip"],
[{ driver: "ssh", platform: "darwin", architecture: "arm64" }, "paperclip"],
[{ driver: "ssh", platform: "darwin", architecture: "x64" }, "paperclip"],
[{ driver: "ssh", platform: "linux", architecture: "arm64" }, "paperclip"],
[{ driver: "ssh", platform: "linux", architecture: "arm" }, "legacy"],
[{ driver: "local", platform: "win32", architecture: "x64" }, "legacy"],
[{ driver: "sandbox", platform: "linux", architecture: "arm64", libc: "glibc", glibcVersion: "2.39" }, "paperclip"],
])("projects the selected environment once for the whole adapter inventory (%s)", async (target, defaultRunner) => {
const resolveRunnerTarget = vi.fn(async () => target);
const response = await request(createApp({}, { resolveRunnerTarget })).get("/api/adapters?companyId=company-1&environmentId=selected");
expect(response.status).toBe(200);
expect(resolveRunnerTarget).toHaveBeenCalledExactlyOnceWith("company-1", "selected");
expect(response.body.find((adapter: any) => adapter.type === "codex_local")).toMatchObject({ defaultRunner });
expect(response.body.find((adapter: any) => adapter.type === "claude_local")).toMatchObject({ defaultRunner: "legacy" });
});
it("uses the company's effective default environment when discovery omits an override", async () => {
const resolveRunnerTarget = vi.fn(async () => ({ driver: "sandbox" }));
const response = await request(createApp({}, { resolveRunnerTarget })).get("/api/adapters?companyId=company-1");
expect(response.status).toBe(200);
expect(resolveRunnerTarget).toHaveBeenCalledExactlyOnceWith("company-1", undefined);
expect(response.body.find((adapter: any) => adapter.type === "codex_local")).toMatchObject({ defaultRunner: "legacy" });
});
it("keeps unscoped inventory independent of company target discovery", async () => {
const resolveRunnerTarget = vi.fn();
const response = await request(createApp({}, { resolveRunnerTarget })).get("/api/adapters");
expect(response.status).toBe(200);
expect(resolveRunnerTarget).not.toHaveBeenCalled();
});
it("rejects a foreign company before selecting or probing an environment", async () => {
const resolveRunnerTarget = vi.fn();
const response = await request(createApp({ source: "session", companyIds: ["allowed"] }, { resolveRunnerTarget })).get("/api/adapters?companyId=foreign&environmentId=selected");
expect(response.status).toBe(403);
expect(resolveRunnerTarget).not.toHaveBeenCalled();
});
it.each(["environmentId=selected", "companyId=", "companyId=one&companyId=two", "companyId=one&environmentId="])("rejects malformed discovery scope before resolution (%s)", async query => {
const resolveRunnerTarget = vi.fn();
const response = await request(createApp({}, { resolveRunnerTarget })).get(`/api/adapters?${query}`);
expect(response.status).toBe(400);
expect(resolveRunnerTarget).not.toHaveBeenCalled();
});
it("keeps the shared implementation available for Dot independently, while honoring adapter-admin disabling", async () => {
const options = { getNativeRunnerEnabled: async () => false, getOpenAiDotEnabled: async () => true };
const enabled = await request(createApp({}, options)).get("/api/adapters");
@@ -657,6 +657,78 @@ describe("agent routes adapter validation", () => {
expect(res.status, JSON.stringify(res.body)).toBe(200);
});
it.each(["pass", "fail"] as const)("tests saved legacy Codex on a disabled adapter and retains its %s runtime verdict", async (status) => {
const adapterConfig = { model: "gpt-5.4", extraArgs: ["--legacy-setting"], env: { CODEX_HOME: "/saved-codex-home" } };
mockAgentService.getById.mockResolvedValue({ ...(await mockAgentService.getById()), adapterConfig });
mockAdapterPluginStore.getDisabledAdapterTypes.mockReturnValue(["codex_local"]);
const probe = vi.fn(async () => ({ adapterType: "codex_local", status, testedAt: new Date(0).toISOString(),
checks: status === "fail" ? [{ code: "adapter_auth_missing", level: "error" as const, message: "Sign in to the saved Codex account." }] : [] }));
const { registerServerAdapter } = await import("../adapters/index.js");
registerServerAdapter({ ...externalAdapter, type: "codex_local", testEnvironment: probe });
try {
const app = await createApp();
const res = await requestApp(app, baseUrl => request(baseUrl)
.post("/api/companies/company-1/adapters/codex_local/test-environment")
.send({ agentId: "11111111-1111-4111-8111-111111111111", adapterConfig: {
...adapterConfig, env: { CODEX_HOME: { type: "plain", value: "***REDACTED***" } },
} }));
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(res.body).toMatchObject({ adapterType: "codex_local", status });
expect(probe).toHaveBeenCalledOnce();
expect(probe).toHaveBeenCalledWith(expect.objectContaining({ adapterType: "codex_local", config: adapterConfig }));
expect(mockSecretService.normalizeAdapterConfigForPersistence).toHaveBeenCalledWith("company-1", adapterConfig, expect.objectContaining({ adapterType: "codex_local" }));
expect(mockAgentService.create).not.toHaveBeenCalled();
expect(mockAgentService.update).not.toHaveBeenCalled();
} finally { await unregisterTestAdapter("codex_local"); }
});
it.each(["draft", "explicit legacy", "explicit auto", "explicit native", "changed harness"] as const)("rejects a disabled Codex setup selection for %s", async (mode) => {
mockAdapterPluginStore.getDisabledAdapterTypes.mockReturnValue(["codex_local"]);
if (mode === "changed harness") mockAgentService.getById.mockResolvedValue({ ...(await mockAgentService.getById()), adapterType: "external_test" });
const probe = vi.fn();
const { registerServerAdapter } = await import("../adapters/index.js");
registerServerAdapter({ ...externalAdapter, type: "codex_local", testEnvironment: probe });
try {
const runner = mode === "explicit legacy" ? "legacy" : mode === "explicit auto" ? "auto" : mode === "explicit native" ? "paperclip" : undefined;
const app = await createApp();
const res = await requestApp(app, baseUrl => request(baseUrl)
.post("/api/companies/company-1/adapters/codex_local/test-environment")
.send({ adapterConfig: {}, ...(mode === "draft" ? {} : { agentId: "11111111-1111-4111-8111-111111111111" }), ...(runner ? { runner } : {}) }));
expect(res.status, JSON.stringify(res.body)).toBe(422);
expect(res.body.details).toMatchObject({ code: "agent_runner_unavailable" });
expect(probe).not.toHaveBeenCalled();
expect(mockSecretService.normalizeAdapterConfigForPersistence).not.toHaveBeenCalled();
} finally { await unregisterTestAdapter("codex_local"); }
});
it("still rejects new Codex creation when its adapter is disabled", async () => {
mockAdapterPluginStore.getDisabledAdapterTypes.mockReturnValue(["codex_local"]);
const app = await createApp();
const res = await requestApp(app, baseUrl => request(baseUrl)
.post("/api/companies/company-1/agents").send({ name: "New Codex", adapterType: "codex_local" }));
expect(res.status, JSON.stringify(res.body)).toBe(422);
expect(res.body.details).toMatchObject({ code: "agent_runner_unavailable" });
expect(mockAgentService.create).not.toHaveBeenCalled();
});
it("rechecks selection policy when a saved native adapter switches its underlying harness", async () => {
mockAgentService.getById.mockResolvedValue({ ...(await mockAgentService.getById()), adapterType: "paperclip_runner", adapterConfig: { provider: "codex" } });
mockInstanceSettingsService.getExperimental.mockResolvedValue({ enableNativeRunner: false });
const probe = vi.fn();
const { registerServerAdapter } = await import("../adapters/index.js");
registerServerAdapter({ ...externalAdapter, type: "paperclip_runner", testEnvironment: probe });
try {
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" } }));
expect(res.status, JSON.stringify(res.body)).toBe(422);
expect(res.body.details).toMatchObject({ code: "paperclip_runner_rollout_disabled" });
expect(probe).not.toHaveBeenCalled();
expect(mockSecretService.normalizeAdapterConfigForPersistence).not.toHaveBeenCalled();
} finally { await unregisterTestAdapter("paperclip_runner"); }
});
it("still creates an agent on an adapter that is registered and enabled", async () => {
const { registerServerAdapter } = await import("../adapters/index.js");
registerServerAdapter(externalAdapter);
@@ -672,20 +744,45 @@ describe("agent routes adapter validation", () => {
expect(res.status, JSON.stringify(res.body)).toBe(201);
});
it("rejects a new paperclip_runner selection while the rollout flag is off", async () => {
it("defaults new Codex on a qualified Linux target while the experimental flag is off", async () => {
const selection = await import("../services/agent-runner-selection.js");
vi.spyOn(selection, "resolveNewAgentRunnerForCompany").mockImplementation(async (_db, _companyId, input) =>
selection.resolveNewAgentRunner({ ...input, target: { driver: "local", platform: "linux", architecture: "x64" } }));
mockInstanceSettingsService.getExperimental.mockResolvedValue({ enableNativeRunner: false, enableOpenAiDot: true });
const app = await createApp();
const res = await requestApp(app, (baseUrl) =>
request(baseUrl)
.post("/api/companies/company-1/agents")
.send({ name: "Native Codex", adapterType: "paperclip_runner" }),
.send({ name: "Native Codex", adapterType: "codex_local" }),
);
expect(res.status, JSON.stringify(res.body)).toBe(422);
expect(res.body.details).toMatchObject({ code: "paperclip_runner_rollout_disabled" });
expect(res.status, JSON.stringify(res.body)).toBe(201);
expect(mockAgentService.create.mock.calls.at(-1)?.[1]).toMatchObject({ adapterType: "paperclip_runner", adapterConfig: { provider: "codex" } });
expect(mockAgentService.create.mock.calls.at(-1)?.[2]).toMatchObject({ runnerResolved: true });
});
it("retains explicit legacy Codex and rejects incompatible automatic settings before persistence", async () => {
const selection = await import("../services/agent-runner-selection.js");
vi.spyOn(selection, "resolveNewAgentRunnerForCompany").mockImplementation(async (_db, _companyId, input) =>
selection.resolveNewAgentRunner({ ...input, target: { driver: "local", platform: "linux", architecture: "x64" } }));
const app = await createApp();
const legacy = await requestApp(app, baseUrl => request(baseUrl).post("/api/companies/company-1/agents").send({ name: "Legacy", adapterType: "codex_local", runner: "legacy", adapterConfig: { extraArgs: ["--search"] } }));
expect(legacy.status, JSON.stringify(legacy.body)).toBe(201);
expect(mockAgentService.create.mock.calls.at(-1)?.[1]).toMatchObject({ adapterType: "codex_local", adapterConfig: { extraArgs: ["--search"] } });
mockAgentService.create.mockClear();
const incompatible = await requestApp(app, baseUrl => request(baseUrl).post("/api/companies/company-1/agents").send({ name: "Custom", adapterType: "codex_local", adapterConfig: { extraArgs: ["--search"] } }));
expect(incompatible.status).toBe(422);
expect(incompatible.body.details).toMatchObject({ code: "agent_runner_config_incompatible", fields: ["extraArgs"] });
expect(mockAgentService.create).not.toHaveBeenCalled();
});
it("keeps non-Codex 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" } }));
expect(response.status).toBe(422);
expect(response.body.details).toMatchObject({ code: "paperclip_runner_rollout_disabled" });
});
it("allows a new paperclip_runner selection while the rollout flag is on", async () => {
mockInstanceSettingsService.getExperimental.mockResolvedValue({ enableNativeRunner: true });
const app = await createApp();
@@ -768,15 +865,14 @@ describe("agent routes adapter validation", () => {
expect(mockAgentService.update).not.toHaveBeenCalled();
});
it("requires the general Runner rollout when changing an existing Dot to Codex", async () => {
it("allows an explicit Dot to Codex change without enabling other native providers", async () => {
mockInstanceSettingsService.getExperimental.mockResolvedValue({ enableNativeRunner: false, enableOpenAiDot: true });
mockAgentService.getById.mockResolvedValue({ ...(await mockAgentService.getById()), adapterType: "paperclip_runner", adapterConfig: { provider: "openai_dot", allowUnmeteredProvider: true } });
const app = await createApp();
const response = await requestApp(app, baseUrl => request(baseUrl).patch("/api/agents/11111111-1111-4111-8111-111111111111")
.send({ adapterConfig: { provider: "codex" }, replaceAdapterConfig: true }));
expect(response.status, JSON.stringify(response.body)).toBe(422);
expect(response.body.details).toMatchObject({ code: "paperclip_runner_rollout_disabled" });
expect(mockAgentService.update).not.toHaveBeenCalled();
expect(response.status, JSON.stringify(response.body)).toBe(200);
expect(mockAgentService.update.mock.calls.at(-1)?.[1]).toMatchObject({ adapterConfig: { provider: "codex" } });
});
it("normalizes legacy skills and permissions when switching to paperclip_runner", async () => {
@@ -495,7 +495,7 @@ describe("hired agents sharing a subscription", () => {
}
it("keeps a credential-lock timeout on automatic retry without blocking the task or starting a provider", async () => {
const f = await fixture("openai", "subscription");
const agent = hired(await request(f.app).post(`/api/companies/${f.companyId}/agent-hires`).send({ name: "Waiting teammate", role: "engineer", adapterType: f.adapterType, reportsTo: f.agentId, adapterConfig: { engine: "cli" }, runtimeConfig: { heartbeat: { enabled: false } } }));
const agent = hired(await request(f.app).post(`/api/companies/${f.companyId}/agent-hires`).send({ name: "Waiting teammate", role: "engineer", adapterType: f.adapterType, runner: "legacy", reportsTo: f.agentId, adapterConfig: { engine: "cli" }, runtimeConfig: { heartbeat: { enabled: false } } }));
// Only an operator may set host execution paths after the agent is hired.
await db.update(agents).set({ adapterConfig: { ...agent.adapterConfig, cwd: home } }).where(eq(agents.id, agent.id));
await finishSetup(agent.id);
@@ -539,7 +539,7 @@ describe("hired agents sharing a subscription", () => {
it.each(["openai", "anthropic"] as const)("runs the %s child alongside a live parent and inherits its connection", async (provider) => {
const f = await fixture(provider, "subscription");
const agent = hired(await request(f.app).post(`/api/companies/${f.companyId}/agent-hires`).send({ name: "Concurrent teammate", role: "engineer", adapterType: f.adapterType, reportsTo: f.agentId, adapterConfig: { engine: "cli" }, runtimeConfig: { heartbeat: { enabled: false } } }));
const agent = hired(await request(f.app).post(`/api/companies/${f.companyId}/agent-hires`).send({ name: "Concurrent teammate", role: "engineer", adapterType: f.adapterType, runner: "legacy", reportsTo: f.agentId, adapterConfig: { engine: "cli" }, runtimeConfig: { heartbeat: { enabled: false } } }));
// Host working directories are configured by an operator, not an agent key.
await db.update(agents).set({ adapterConfig: { ...agent.adapterConfig, cwd: home } }).where(eq(agents.id, agent.id));
await finishSetup(agent.id);
@@ -117,13 +117,22 @@ const mockSyncInstructionsBundleConfigFromFilePath = vi.hoisted(() => vi.fn());
const mockEnsureOpenCodeModelConfiguredAndAvailable = vi.hoisted(() => vi.fn());
const mockEnvironmentService = vi.hoisted(() => ({
getById: vi.fn(),
listBoundCompanyIds: vi.fn(),
}));
const mockInstanceSettingsService = vi.hoisted(() => ({
get: vi.fn(),
getGeneral: vi.fn(),
getExperimental: vi.fn(),
}));
const mockRunSshCommand = vi.hoisted(() => vi.fn());
function registerModuleMocks() {
vi.doMock("@paperclipai/adapter-utils/ssh", async (importOriginal) => ({
...(await importOriginal<typeof import("@paperclipai/adapter-utils/ssh")>()),
runSshCommand: mockRunSshCommand,
}));
vi.doMock("@paperclipai/adapter-opencode-local/server", async () => {
const actual = await vi.importActual<typeof import("@paperclipai/adapter-opencode-local/server")>("@paperclipai/adapter-opencode-local/server");
return {
@@ -336,7 +345,11 @@ describe("agent permission routes", () => {
mockTrackAgentCreated.mockReset();
mockGetTelemetryClient.mockReset();
mockSyncInstructionsBundleConfigFromFilePath.mockReset();
mockInstanceSettingsService.get.mockReset().mockResolvedValue({ defaultEnvironmentId: null, experimental: {}, general: {} });
mockInstanceSettingsService.getExperimental.mockReset().mockResolvedValue({});
mockInstanceSettingsService.getGeneral.mockReset();
mockEnvironmentService.listBoundCompanyIds.mockReset().mockResolvedValue([]);
mockRunSshCommand.mockReset().mockResolvedValue({ stdout: "Linux\nx86_64\n", stderr: "", code: 0 });
mockEnvironmentService.getById.mockReset();
mockEnsureOpenCodeModelConfiguredAndAvailable.mockReset();
mockSyncInstructionsBundleConfigFromFilePath.mockImplementation((_agent, config) => config);
@@ -1205,6 +1218,9 @@ describe("agent permission routes", () => {
expect(res.status).toBe(403);
expect(res.body.error).toContain("instructions path or bundle configuration");
expect(mockInstanceSettingsService.get).not.toHaveBeenCalled();
expect(mockEnvironmentService.getById).not.toHaveBeenCalled();
expect(mockRunSshCommand).not.toHaveBeenCalled();
expect(mockAgentService.create).not.toHaveBeenCalled();
expect(mockLogActivity).not.toHaveBeenCalled();
});
@@ -1235,6 +1251,36 @@ describe("agent permission routes", () => {
expect(mockLogActivity).not.toHaveBeenCalled();
});
it.each(["agents", "agent-hires"])("rejects agent-supplied Codex host commands before resolving the runner via %s", async (route) => {
mockAccessService.hasPermission.mockResolvedValue(true);
mockInstanceSettingsService.get.mockRejectedValue(new Error("Denied requests must not inspect execution targets"));
const app = createApp({ type: "agent", agentId, companyId, source: "agent_key" });
const res = await requestApp(app, (baseUrl) => request(baseUrl)
.post(`/api/companies/${companyId}/${route}`)
.send({ name: "Injected", role: "engineer", adapterType: "codex_local", adapterConfig: { command: "untrusted-codex" } }));
expect(res.status, JSON.stringify(res.body)).toBe(403);
expect(res.body.error).toContain("host-executed local adapter settings");
expect(mockInstanceSettingsService.get).not.toHaveBeenCalled();
expect(mockEnvironmentService.getById).not.toHaveBeenCalled();
expect(mockRunSshCommand).not.toHaveBeenCalled();
expect(mockAgentService.create).not.toHaveBeenCalled();
});
it.each(["agents", "agent-hires"])("requires instance admin for external instructions before resolving the runner via %s", async (route) => {
mockInstanceSettingsService.get.mockRejectedValue(new Error("Denied requests must not inspect execution targets"));
const app = createApp({ type: "board", userId: "member-user", source: "session", companyIds: [companyId], isInstanceAdmin: false });
const res = await requestApp(app, (baseUrl) => request(baseUrl)
.post(`/api/companies/${companyId}/${route}`)
.send({ name: "External", role: "engineer", adapterType: "codex_local", adapterConfig: { instructionsBundleMode: "external" } }));
expect(res.status, JSON.stringify(res.body)).toBe(403);
expect(mockInstanceSettingsService.get).not.toHaveBeenCalled();
expect(mockEnvironmentService.getById).not.toHaveBeenCalled();
expect(mockRunSshCommand).not.toHaveBeenCalled();
expect(mockAgentService.create).not.toHaveBeenCalled();
});
it("allows direct agent creation for authenticated board users with agent create permission when approval is not required", async () => {
mockAccessService.canUser.mockResolvedValue(true);
@@ -1261,7 +1307,7 @@ describe("agent permission routes", () => {
expect.objectContaining({
status: "idle",
}),
{ createdByUserId: "agent-admin-user", responsibleUserId: "agent-admin-user", claudeLogin: { storedSessionId: null, ownerUserId: "agent-admin-user", applyExistingWithoutClaim: false } },
{ runnerResolved: true, aiConnectionInstall: undefined, createdByUserId: "agent-admin-user", responsibleUserId: "agent-admin-user", claudeLogin: { storedSessionId: null, ownerUserId: "agent-admin-user", applyExistingWithoutClaim: false } },
);
expect(mockAccessService.setPrincipalPermission).toHaveBeenCalledWith(
companyId,
@@ -1391,7 +1437,7 @@ describe("agent permission routes", () => {
},
},
}),
{ createdByUserId: "board-user", responsibleUserId: "board-user", claudeLogin: { storedSessionId: null, ownerUserId: "board-user", applyExistingWithoutClaim: false } },
{ runnerResolved: true, aiConnectionInstall: undefined, createdByUserId: "board-user", responsibleUserId: "board-user", claudeLogin: { storedSessionId: null, ownerUserId: "board-user", applyExistingWithoutClaim: false } },
);
});
@@ -1427,7 +1473,7 @@ describe("agent permission routes", () => {
model: DEFAULT_OPENCODE_LOCAL_MODEL,
}),
}),
{ createdByUserId: "board-user", responsibleUserId: "board-user", claudeLogin: { storedSessionId: null, ownerUserId: "board-user", applyExistingWithoutClaim: false } },
{ runnerResolved: true, aiConnectionInstall: undefined, createdByUserId: "board-user", responsibleUserId: "board-user", claudeLogin: { storedSessionId: null, ownerUserId: "board-user", applyExistingWithoutClaim: false } },
);
});
@@ -1465,7 +1511,7 @@ describe("agent permission routes", () => {
model: "anthropic/claude-sonnet-4-5",
}),
}),
{ createdByUserId: "board-user", responsibleUserId: "board-user", claudeLogin: { storedSessionId: null, ownerUserId: "board-user", applyExistingWithoutClaim: false } },
{ runnerResolved: true, aiConnectionInstall: undefined, createdByUserId: "board-user", responsibleUserId: "board-user", claudeLogin: { storedSessionId: null, ownerUserId: "board-user", applyExistingWithoutClaim: false } },
);
});
@@ -1505,6 +1551,8 @@ describe("agent permission routes", () => {
},
}),
{
runnerResolved: true,
aiConnectionInstall: undefined,
createdByUserId: "board-user", responsibleUserId: "board-user",
claudeLogin: {
storedSessionId: null,
@@ -1716,7 +1764,7 @@ describe("agent permission routes", () => {
expect.objectContaining({
defaultEnvironmentId: environmentId,
}),
{ createdByUserId: "board-user", responsibleUserId: "board-user", claudeLogin: { storedSessionId: null, ownerUserId: "board-user", applyExistingWithoutClaim: false } },
{ runnerResolved: true, aiConnectionInstall: undefined, createdByUserId: "board-user", responsibleUserId: "board-user", claudeLogin: { storedSessionId: null, ownerUserId: "board-user", applyExistingWithoutClaim: false } },
);
});
@@ -1763,17 +1811,18 @@ describe("agent permission routes", () => {
for (const adapterCase of sshCapableAdapterCases) {
it(`allows creating a ${adapterCase.adapterType} agent with an SSH default environment`, async () => {
const expectedAdapterType = adapterCase.adapterType === "codex_local" ? "paperclip_runner" : adapterCase.adapterType;
const environmentId = "33333333-3333-4333-8333-333333333333";
mockEnvironmentService.getById.mockResolvedValue({
id: environmentId,
companyId,
driver: "ssh",
config: {},
config: { host: "qa.example.invalid", username: "qa", remoteWorkspacePath: "/workspace" },
});
mockAgentService.create.mockResolvedValue({
...baseAgent,
name: adapterCase.name,
adapterType: adapterCase.adapterType,
adapterType: expectedAdapterType,
defaultEnvironmentId: environmentId,
});
@@ -1799,11 +1848,18 @@ describe("agent permission routes", () => {
expect(mockAgentService.create).toHaveBeenCalledWith(
companyId,
expect.objectContaining({
adapterType: adapterCase.adapterType,
adapterType: expectedAdapterType,
defaultEnvironmentId: environmentId,
}),
{ createdByUserId: "board-user", responsibleUserId: "board-user", claudeLogin: { storedSessionId: null, ownerUserId: "board-user", applyExistingWithoutClaim: false } },
{ 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" });
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 {
expect(mockRunSshCommand).not.toHaveBeenCalled();
}
});
}
@@ -3,6 +3,16 @@ import { readFile } from "node:fs/promises";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
const runnerTarget = vi.hoisted(() => ({ driver: "local", platform: "linux", architecture: "x64" }));
vi.mock("../services/agent-runner-selection.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../services/agent-runner-selection.js")>();
return {
...actual,
resolveNewAgentRunnerForCompany: vi.fn(async (...[_db, _companyId, input]: Parameters<typeof actual.resolveNewAgentRunnerForCompany>) =>
actual.resolveNewAgentRunner({ ...input, target: runnerTarget })),
};
});
const mockAgentService = vi.hoisted(() => ({
getById: vi.fn(),
update: vi.fn(),
@@ -50,6 +60,7 @@ const mockCompanySkillService = vi.hoisted(() => ({
}));
const mockInstanceSettingsService = vi.hoisted(() => ({
get: vi.fn(),
getExperimental: vi.fn(),
}));
@@ -262,6 +273,8 @@ function skillSnapshot(adapterType = "claude_local", mode = "ephemeral") {
describe("agent skill routes", () => {
beforeEach(async () => {
runnerTarget.platform = "linux";
runnerTarget.architecture = "x64";
vi.resetModules();
vi.doUnmock("../routes/agents.js");
vi.doUnmock("../routes/authz.js");
@@ -290,6 +303,7 @@ describe("agent skill routes", () => {
agent: makeAgent("claude_local"),
});
mockSecretService.resolveAdapterConfigForRuntime.mockResolvedValue({ config: { env: {} } });
mockInstanceSettingsService.get.mockResolvedValue({ defaultEnvironmentId: null, experimental: {}, general: {} });
mockInstanceSettingsService.getExperimental.mockResolvedValue({ enableBetaSkills: false });
mockSecretService.syncEnvBindingsForTarget.mockResolvedValue(undefined);
mockCompanySkillService.listRuntimeSkillEntries.mockResolvedValue([
@@ -931,7 +945,7 @@ describe("agent skill routes", () => {
}),
}),
}),
{ createdByUserId: "local-board", responsibleUserId: "local-board", claudeLogin: { storedSessionId: null, ownerUserId: "local-board", applyExistingWithoutClaim: false } },
{ runnerResolved: true, aiConnectionInstall: undefined, createdByUserId: "local-board", responsibleUserId: "local-board", claudeLogin: { storedSessionId: null, ownerUserId: "local-board", applyExistingWithoutClaim: false } },
);
expect(mockTrackAgentCreated).toHaveBeenCalledWith(
expect.anything(),
@@ -981,7 +995,7 @@ describe("agent skill routes", () => {
expect.objectContaining({
role: "security",
}),
{ createdByUserId: "local-board", responsibleUserId: "local-board", claudeLogin: { storedSessionId: null, ownerUserId: "local-board", applyExistingWithoutClaim: false } },
{ runnerResolved: true, aiConnectionInstall: undefined, createdByUserId: "local-board", responsibleUserId: "local-board", claudeLogin: { storedSessionId: null, ownerUserId: "local-board", applyExistingWithoutClaim: false } },
);
expect(mockTrackAgentCreated).toHaveBeenCalledWith(
expect.anything(),
@@ -1168,31 +1182,46 @@ describe("agent skill routes", () => {
.toContain("paperclipai/paperclip/first-task");
});
it.each(["codex_local", "claude_local"].flatMap((adapterType) => [
const codexRunnerCases = [
{ adapterType: "codex_local", runner: "auto", platform: "linux", expectedType: "paperclip_runner" },
{ adapterType: "codex_local", runner: "legacy", platform: "linux", expectedType: "codex_local" },
{ adapterType: "codex_local", runner: "auto", platform: "darwin", expectedType: "paperclip_runner" },
{ 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) => [
["agents", "paperclipai/paperclip/paperclip-create-agent"],
["agent-hires", "paperclipai/paperclip/paperclip-create-agent"],
["agents", "paperclip"],
["agent-hires", "paperclip"],
["agents", "paperclipai/paperclip/first-task"],
["agent-hires", "paperclipai/paperclip/first-task"],
].map(([route, skill]) => ({ adapterType, route, skill }))))("gives $adapterType onboarding agents core and first-task skills via $route, preserving $skill pins", async ({ adapterType, route, skill }) => {
].map(([route, skill]) => ({ ...runnerCase, route, skill }))))("assigns onboarding skills via $route for $adapterType/$runner on $platform with $skill pins", async ({ adapterType, runner, platform, expectedType, route, skill }) => {
runnerTarget.platform = platform;
runnerTarget.architecture = platform === "darwin" ? "arm64" : "x64";
mockInstanceSettingsService.getExperimental.mockResolvedValue({ enableBetaSkills: true });
const versionId = "22222222-2222-4222-8222-222222222222";
const res = await request(await createApp(createDb(route === "agent-hires")))
.post(`/api/companies/company-1/${route}`)
.send({
name: "Chiff", role: "general", adapterType,
name: "Chiff", role: "general", adapterType, ...(runner === "auto" ? {} : { runner }),
onboardingFirstAgent: true,
desiredSkills: [{ key: skill, versionId }],
});
expect(res.status, JSON.stringify(res.body)).toBe(201);
const input = mockAgentService.create.mock.calls[0][1];
expect(input.role).toBe("general");
expect(input.adapterType).toBe(expectedType);
const native = expectedType === "paperclip_runner";
if (native) expect(input.adapterConfig.provider).toBe("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")
.map((name) => ({ key: `paperclipai/paperclip/${name}`, versionId: `paperclipai/paperclip/${name}` === canonicalKey ? versionId : null }));
expect(input.adapterConfig.paperclipSkillSync.desiredSkills).toEqual(expect.arrayContaining(expected));
expect(input.adapterConfig.paperclipSkillSync.desiredSkills).toHaveLength(6);
const desiredSkills = input.adapterConfig.paperclipSkillSync.desiredSkills.map((entry: string | { key: string; versionId: string | null }) =>
typeof entry === "string" ? { key: entry, versionId: null } : entry);
expect(desiredSkills).toEqual(expect.arrayContaining(expected));
expect(desiredSkills).toHaveLength(expected.length);
});
it.each(["agents", "agent-hires"])("leaves ordinary general agents' defaults unchanged via %s", async (route) => {
@@ -1203,13 +1232,23 @@ describe("agent skill routes", () => {
expect(mockAgentService.create.mock.calls[0][1].adapterConfig.paperclipSkillSync).toBeUndefined();
});
it.each(["agents", "agent-hires"])("does not assign first-task to ordinary CEOs via %s", async (route) => {
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 }) => {
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" });
.send({ name: "CEO", role: "ceo", adapterType: "codex_local", ...(runner === "auto" ? {} : { runner }) });
expect(res.status, JSON.stringify(res.body)).toBe(201);
const desiredSkills = mockAgentService.create.mock.calls[0][1].adapterConfig.paperclipSkillSync.desiredSkills;
expect(desiredSkills).toContain("paperclipai/paperclip/paperclip");
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");
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")
.map((name) => `paperclipai/paperclip/${name}`);
expect(desiredSkills).toEqual(expect.arrayContaining(expected));
expect(desiredSkills).toHaveLength(expected.length);
expect(desiredSkills).not.toContain("paperclipai/paperclip/first-task");
});
@@ -1417,6 +1456,8 @@ describe("agent skill routes", () => {
}),
}),
{
runnerResolved: true,
aiConnectionInstall: undefined,
createdByUserId: "local-board", responsibleUserId: "local-board",
claudeLogin: {
storedSessionId: null,
@@ -47,7 +47,10 @@ const mockResolveEnvironmentExecutionTarget = vi.hoisted(() => vi.fn());
const mockInstanceSettingsService = vi.hoisted(() => ({
get: vi.fn(async () => ({ defaultEnvironmentId: null as string | null })),
getGeneral: vi.fn(async () => ({ censorUsernameInLogs: false })),
getExperimental: vi.fn(async () => ({ enableManagedSandboxOnly: false })),
getExperimental: vi.fn(async (): Promise<{
enableManagedSandboxOnly: boolean;
enableOpenAiDot?: boolean;
}> => ({ enableManagedSandboxOnly: false })),
}));
vi.mock("../services/index.js", () => ({
@@ -255,6 +258,10 @@ describe("agent test-environment route", () => {
it("accepts a managed Dot environment without allocating an idle sandbox", async () => {
vi.stubEnv("PAPERCLIP_PUBLIC_URL", "https://paperclip.example");
mockInstanceSettingsService.getExperimental.mockResolvedValue({
enableManagedSandboxOnly: false,
enableOpenAiDot: true,
});
mockInstanceSettingsService.get.mockResolvedValue({ defaultEnvironmentId: "11111111-1111-4111-8111-111111111111" });
const app = await createApp();
try {
@@ -270,6 +277,10 @@ describe("agent test-environment route", () => {
it("fails the Cloud Dot check without a managed sandbox instead of probing the host", async () => {
vi.stubEnv("PAPERCLIP_PUBLIC_URL", "https://paperclip.example");
vi.stubEnv("PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN", "test-cloud");
mockInstanceSettingsService.getExperimental.mockResolvedValue({
enableManagedSandboxOnly: false,
enableOpenAiDot: true,
});
const app = await createApp();
try {
const res = await request(app).post("/api/companies/company-1/adapters/paperclip_runner/test-environment")
@@ -17,6 +17,7 @@ import {
} from "./helpers/embedded-postgres.js";
import { agentService } from "../services/agents.ts";
import { approvalService } from "../services/approvals.ts";
import { paperclipRunnerSupportsPlatform } from "@paperclipai/shared";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
@@ -64,10 +65,34 @@ describeEmbeddedPostgres("pending approval agent config integrity", () => {
return companyId;
}
it("resolves internal Codex creation once and preserves legacy agents through ordinary edits", async () => {
const companyId = await seedCompany();
const service = agentService(db);
const lifecycle = createAgentLifecycle(db);
const native = await lifecycle.requestHire(companyId, { name: "New Codex", adapterType: "codex_local", adapterConfig: { modelReasoningEffort: "high" }, status: "pending_approval" });
const legacy = await lifecycle.requestHire(companyId, { name: "Legacy Codex", adapterType: "codex_local", runner: "legacy", adapterConfig: { extraArgs: ["--search"] }, status: "pending_approval" });
const reviewedLegacy = await lifecycle.requestHire(companyId, { name: "Historical approved hire", adapterType: "codex_local", adapterConfig: {}, status: "pending_approval" }, { runnerResolved: true });
if (paperclipRunnerSupportsPlatform("codex_local", process.platform, process.arch)) {
expect(native).toMatchObject({ adapterType: "paperclip_runner", adapterConfig: { provider: "codex", modelReasoningEffort: "high" } });
} else {
expect(native).toMatchObject({ adapterType: "codex_local", adapterConfig: { modelReasoningEffort: "high" } });
}
expect(legacy.adapterType).toBe("codex_local");
expect(reviewedLegacy.adapterType).toBe("codex_local");
for (const agent of [native, legacy, reviewedLegacy]) {
expect(agent).not.toHaveProperty("runner");
await lifecycle.approveHire(agent.id);
await service.update(agent.id, { title: "Updated title" });
const saved = await service.getById(agent.id);
expect(saved?.adapterType).toBe(agent.adapterType);
expect(saved?.adapterConfig).toEqual(agent.adapterConfig);
}
});
it("freezes generic pending hire config and reapplies the approval snapshot on activation", async () => {
const companyId = await seedCompany();
const agentSvc = agentService(db);
const agentSvcLifecycle = createAgentLifecycle(db);
const agentSvcLifecycle = createAgentLifecycle(db);
const approvalSvc = approvalService(db);
const pending = await agentSvcLifecycle.requestHire(companyId, {
name: "Pending Coder",
@@ -25,6 +25,9 @@ import { syncConnectionCredentialBindings } from "../services/connection-credent
import * as codexAdapter from "@paperclipai/adapter-codex-local/server";
import { WORKSPACE_RESTORE_LOCK_TIMEOUT_CODE } from "@paperclipai/adapter-utils/workspace-restore-merge";
import * as executionTarget from "@paperclipai/adapter-utils/execution-target";
import * as runnerProbe from "../vendor/paperclip-runner/index.js";
import * as nativeCodexRunner from "../services/native-runtime/native-codex-runner.js";
import { testNativeRunnerAuthentication } from "../services/native-runtime/setup-readiness.js";
import { prepareManagedAiRuntime, withManagedAiProbe, assertManagedAiProjectAuth, isAiConnectionBusy } from "../services/ai-connection-runtime.js";
import { execute as executeGemini, testEnvironment as testGeminiEnvironment } from "@paperclipai/adapter-gemini-local/server";
import { toolAccessService } from "../services/tool-access.js";
@@ -174,6 +177,42 @@ describe("managed AI connections", () => {
} finally { release.resolve(); await Promise.allSettled([probing, refreshing]); lock?.mockRestore(); request.mockRestore(); }
});
it("persists native Codex setup's refreshed selected login without changing existing agents", async () => {
const owner = "native-codex-setup-refresh";
await db.insert(companyMemberships).values({ companyId, principalId: owner, principalType: "user", status: "active", membershipRole: "member" });
const auth = (hour: string) => JSON.stringify({ tokens: { account_id: owner, id_token: "identity", access_token: `access-${hour}`, refresh_token: `refresh-${hour}` }, last_refresh: `2026-09-10T${hour}:00:00Z` });
await service.save(companyId, owner, { provider: "openai", method: "subscription", ownership: "personal", name: owner, loginSessionId: "fixture", agentIds: [], allAgents: true }, auth("10"));
const [row] = await service.quotaAccounts(companyId, owner);
const [before] = await db.select({ adapterType: agents.adapterType, adapterConfig: agents.adapterConfig }).from(agents).where(eq(agents.id, agentId));
const runnerBinary = path.join(home, "fixture-runnerd");
const resolveBinary = vi.spyOn(nativeCodexRunner, "resolvePaperclipRunnerBinary").mockReturnValue(runnerBinary);
const probe = vi.spyOn(runnerProbe, "probeNativeRunnerEnvironment").mockImplementation(async input => {
expect(input.transportOptions?.runnerBinary).toBe(runnerBinary);
expect(input.transportOptions?.sourceCodexHome).toContain("/provider");
expect(await readFile(path.join(input.transportOptions!.sourceCodexHome!, "auth.json"), "utf8")).toBe(auth("10"));
const providerHome = path.join(input.runtimeDirectory, "codex-home");
await fs.mkdir(providerHome, { mode: 0o700 });
const refreshedFile = path.join(providerHome, "auth.json");
await writeFile(refreshedFile, auth("11"), { mode: 0o600 });
await input.onCodexCredentialRefresh!(refreshedFile);
await input.onCleanupConfirmed!();
return { provider: "codex", providerDriver: "codex_app_server", effectiveModel: "gpt-5.6-sol", helloProbePassed: true };
});
let managedHome: string | undefined;
try {
const result = await withManagedAiProbe(db, { companyId, agentId, responsibleUserId: owner, adapterType: "paperclip_runner",
binding: { provider: "openai", method: "subscription", mode: "responsible_user" }, config: { provider: "codex" } }, async runtime => {
managedHome = runtime.home;
return testNativeRunnerAuthentication({ companyId, adapterType: "paperclip_runner", config: runtime.config, managedAiCredentialHome: path.join(runtime.home!, "provider") }, "codex", "gpt-5.6-sol");
});
expect(result.status, JSON.stringify(result)).toBe("pass"); expect(probe).toHaveBeenCalledOnce();
expect(await service.credential(row)).toBe(auth("11"));
await expect(access(managedHome!)).rejects.toMatchObject({ code: "ENOENT" });
const [after] = await db.select({ adapterType: agents.adapterType, adapterConfig: agents.adapterConfig }).from(agents).where(eq(agents.id, agentId));
expect(after).toEqual(before);
} finally { probe.mockRestore(); resolveBinary.mockRestore(); }
});
it("rejects preparation when reconnect replaces the selected credential reference", async () => {
const owner = "replaced-ref-owner";
await db.insert(companyMemberships).values({ companyId, principalId: owner, principalType: "user", status: "active", membershipRole: "member" });
@@ -141,6 +141,7 @@ describe("approvalService resolution idempotency", () => {
requestedByUserId,
payload: {
name: "New Agent",
adapterType: "codex_local",
adapterConfig: {
env: {
API_KEY: {
@@ -161,9 +162,10 @@ describe("approvalService resolution idempotency", () => {
expect(mockAgentService.create).toHaveBeenCalledWith(
"company-1",
expect.objectContaining({
adapterType: "codex_local",
adapterConfig: approved.payload.adapterConfig,
}),
{ createdByUserId: expectedCreator, responsibleUserId: requestedByUserId },
{ runnerResolved: true, createdByUserId: expectedCreator, responsibleUserId: requestedByUserId },
);
});
});
+65 -13
View File
@@ -6,7 +6,7 @@ import { randomUUID } from "node:crypto";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { and, eq, sql } from "drizzle-orm";
import {
activityLog,
@@ -140,6 +140,8 @@ describeEmbeddedPostgres("built-in agents", () => {
};
}
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
let runnerSelectionSpy: { mockRestore(): void } | undefined;
const runnerTarget = { driver: "local", platform: "linux", architecture: "x64" };
const instructionOperator = { type: "board", userId: "local-board", source: "local_implicit" } as const;
async function writeInstructionEntry(agent: { id: string; companyId: string }, entryFile: string, content: string) {
const service = agentInstructionRevisionService(db);
@@ -157,7 +159,16 @@ describeEmbeddedPostgres("built-in agents", () => {
lifecycleWorker = configureAgentLifecycle(db, { requiredPluginIds: async () => [], runPlugin: async () => "complete", runHost: async () => "complete" });
}, 20_000);
beforeEach(async () => {
runnerTarget.platform = "linux";
runnerTarget.architecture = "x64";
const selection = await import("../services/agent-runner-selection.js");
runnerSelectionSpy = vi.spyOn(selection, "resolveNewAgentRunnerForCompany").mockImplementation(async (_db, _companyId, input) =>
selection.resolveNewAgentRunner({ ...input, target: runnerTarget }));
});
afterEach(async () => {
runnerSelectionSpy?.mockRestore();
await lifecycleWorker.sweep();
await db.delete(routineTriggers);
await db.delete(routines);
@@ -255,7 +266,9 @@ describeEmbeddedPostgres("built-in agents", () => {
])).toThrow("defaultAdapterType must be allowed");
});
it("lazily provisions one agent per company/key and updates the same row on setup", async () => {
it.each([{ platform: "linux", architecture: "x64" }, { platform: "linux", architecture: "arm64" }, { platform: "darwin", architecture: "arm64" }, { platform: "darwin", architecture: "x64" }])("lazily provisions one agent per company/key and updates the same row on setup ($platform/$architecture)", async ({ platform, architecture }) => {
runnerTarget.platform = platform;
runnerTarget.architecture = architecture;
const companyId = await seedCompany();
const svc = builtInAgentService(db);
@@ -280,21 +293,60 @@ describeEmbeddedPostgres("built-in agents", () => {
expect(configured.status).toBe("ready");
expect(configured.agentId).toBe(created.agentId);
expect(configured.agent).toMatchObject({
adapterType: "codex_local",
adapterConfig: { model: "gpt-5.4" },
adapterType: "paperclip_runner",
adapterConfig: { provider: "codex", model: "gpt-5.4" },
});
const reconciled = await svc.ensure(companyId, "briefs");
expect(reconciled.status).toBe("ready");
expect(reconciled.agent).toMatchObject({
adapterType: "codex_local",
adapterConfig: { model: "gpt-5.4" },
adapterType: "paperclip_runner",
adapterConfig: { provider: "codex", model: "gpt-5.4" },
});
const rows = await db.select().from(agents).where(eq(agents.companyId, companyId));
expect(rows).toHaveLength(1);
});
it("keeps existing legacy and native built-in runners through unrelated edits and startup", async () => {
const companyId = await seedCompany({ requireApproval: false });
const svc = builtInAgentService(db);
const savedSettings = { model: "gpt-5.5", modelReasoningEffort: "high", timeoutSec: 123 };
const created = await svc.ensure(companyId, "briefs", { adapterType: "codex_local", adapterConfig: savedSettings, runner: "legacy" });
expect(created.agent?.adapterType).toBe("codex_local");
await svc.ensure(companyId, "briefs", { budgetMonthlyCents: 100 });
await reconcileBuiltInAgentsOnStartup(db);
const legacy = await svc.get(companyId, "briefs");
expect(legacy.agent?.adapterType).toBe("codex_local");
expect(legacy.agent?.adapterConfig).toEqual(created.agent?.adapterConfig);
const switched = await svc.ensure(companyId, "briefs", { runner: "paperclip" });
expect(switched.agent).toMatchObject({ adapterType: "paperclip_runner", adapterConfig: { provider: "codex", ...savedSettings } });
await svc.ensure(companyId, "briefs", { budgetMonthlyCents: 200 });
await reconcileBuiltInAgentsOnStartup(db);
expect((await svc.get(companyId, "briefs")).agent?.adapterConfig).toEqual(switched.agent?.adapterConfig);
const switchedBack = await svc.ensure(companyId, "briefs", { runner: "legacy" });
expect(switchedBack.agent).toMatchObject({ adapterType: "codex_local", adapterConfig: savedSettings });
const switchedAgain = await svc.ensure(companyId, "briefs", { runner: "paperclip" });
expect(switchedAgain.agent).toMatchObject({ adapterType: "paperclip_runner", adapterConfig: { provider: "codex", ...savedSettings } });
runnerTarget.platform = "win32";
runnerTarget.architecture = "x64";
const edited = await svc.ensure(companyId, "briefs", { adapterType: "codex_local", adapterConfig: { model: "gpt-5.4" } });
expect(edited.agent).toMatchObject({ adapterType: "paperclip_runner", adapterConfig: { provider: "codex", model: "gpt-5.4" } });
});
it("freezes Codex built-in execution in the reviewed hire through service restart", async () => {
const companyId = await seedCompany({ requireApproval: true });
const result = await builtInAgentService(db).provision(companyId, "briefs", { adapterType: "codex_local", adapterConfig: { model: "gpt-5.4" } }, { requestedByUserId: "board-user" });
expect(result.approval?.payload).toMatchObject({ adapterType: "paperclip_runner", adapterConfig: { provider: "codex", model: "gpt-5.4" } });
runnerTarget.platform = "win32";
runnerTarget.architecture = "x64";
await reconcileBuiltInAgentsOnStartup(db);
await approvalService(db).approve(result.approval!.id, "board-user");
const approved = await builtInAgentService(db).get(companyId, "briefs");
expect(approved.agent?.adapterType).toBe(result.approval!.payload.adapterType);
expect(approved.agent?.adapterConfig).toEqual(result.approval!.payload.adapterConfig);
});
it("records the human who enabled a built-in agent and leaves automatic creation unattributed", async () => {
const enabledCompanyId = await seedCompany({ requireApproval: false });
const automaticCompanyId = await seedCompany({ requireApproval: false });
@@ -418,7 +470,7 @@ describeEmbeddedPostgres("built-in agents", () => {
await expect(builtIns.get(companyId, "briefs")).resolves.toMatchObject({
status: "ready",
agentId: ready.agentId,
agent: { adapterType: "codex_local", adapterConfig: { model: "gpt-5.4" } },
agent: { adapterType: "paperclip_runner", adapterConfig: { provider: "codex", model: "gpt-5.4" } },
});
});
@@ -445,8 +497,8 @@ describeEmbeddedPostgres("built-in agents", () => {
agentId: seeded.agentId,
agent: {
status: "idle",
adapterType: "codex_local",
adapterConfig: { model: "gpt-5.4" },
adapterType: "paperclip_runner",
adapterConfig: { provider: "codex", model: "gpt-5.4" },
budgetMonthlyCents: 2500,
},
});
@@ -612,8 +664,8 @@ describeEmbeddedPostgres("built-in agents", () => {
role: "general",
title: null,
capabilities: "Prepares concise operational briefs for the board and agent company.",
adapterType: "codex_local",
adapterConfig: { model: "gpt-5.4" },
adapterType: "paperclip_runner",
adapterConfig: { provider: "codex", model: "gpt-5.4" },
},
});
});
@@ -659,7 +711,7 @@ describeEmbeddedPostgres("built-in agents", () => {
role: "general",
title: "Reflection Coach",
icon: "eye",
adapterType: "codex_local",
adapterType: "paperclip_runner",
permissions: {
canCreateAgents: false,
canCreateSkills: false,
@@ -1275,7 +1327,7 @@ describeEmbeddedPostgres("built-in agents", () => {
title: "Reflection Coach",
icon: "eye",
reportsTo: root.id,
adapterType: "codex_local",
adapterType: "paperclip_runner",
budgetMonthlyCents: 0,
});
expect(state.status).toBe("paused");
@@ -8,6 +8,14 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import type { CompanyPortabilityFileEntry } from "@paperclipai/shared";
import { runnerApiResponseReservations } from "@paperclipai/db";
const runnerTarget = vi.hoisted(() => ({ driver: "local", platform: "linux", architecture: "x64" }));
vi.mock("../services/agent-runner-selection.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../services/agent-runner-selection.js")>();
return { ...actual, resolveNewAgentRunnerForCompany: vi.fn(async (...[_db, _companyId, input]: Parameters<typeof actual.resolveNewAgentRunnerForCompany>) =>
actual.resolveNewAgentRunner({ ...input, target: runnerTarget })) };
});
const companySvc = {
getById: vi.fn(),
// Async-empty default (not a bare vi.fn()): every new-company import reads
@@ -20,6 +28,7 @@ const companySvc = {
const agentSvc = {
list: vi.fn(),
getById: vi.fn(),
create: vi.fn(),
update: vi.fn(),
};
@@ -104,6 +113,7 @@ const agentInstructionsSvc = {
};
const instanceSettingsSvc = {
get: vi.fn(async () => ({ general: {}, experimental: {}, defaultEnvironmentId: null })),
getExperimental: vi.fn(async (): Promise<{ enableNativeRunner: boolean; enableOpenAiDot?: boolean }> => ({ enableNativeRunner: false })),
};
@@ -214,6 +224,9 @@ describe("company portability", () => {
beforeEach(() => {
vi.clearAllMocks();
runnerTarget.platform = "linux";
runnerTarget.architecture = "x64";
agentSvc.getById.mockImplementation(async (id: string) => (await agentSvc.list()).find((agent: { id: string }) => agent.id === id) ?? null);
instanceSettingsSvc.getExperimental.mockResolvedValue({ enableNativeRunner: false });
managedAgentProfileSvc.requireQualified.mockResolvedValue({
id: "managed-primary",
@@ -1914,7 +1927,7 @@ describe("company portability", () => {
},
},
}),
{ strictMode: false, adapterType: "codex_local" },
{ strictMode: false, adapterType: "paperclip_runner" },
);
expect(agentSvc.create).toHaveBeenCalledWith("company-1", expect.objectContaining({
adapterConfig: expect.objectContaining({
@@ -3715,6 +3728,7 @@ describe("company portability", () => {
adapterOverrides: {
claudecoder: {
adapterType: "codex_local",
runner: "legacy",
adapterConfig: {
dangerouslyBypassApprovalsAndSandbox: true,
instructionsFilePath: "/tmp/should-not-survive.md",
@@ -3815,6 +3829,7 @@ describe("company portability", () => {
adapterOverrides: {
claudecoder: {
adapterType: "codex_local",
runner: "legacy",
adapterConfig: {
extraArgs: [],
args: ["--legacy-arg"],
@@ -5801,6 +5816,7 @@ describe("company portability", () => {
adapterOverrides: {
claudecoder: {
adapterType: "codex_local",
runner: "legacy",
adapterConfig: {
model: "gpt-5.4",
},
@@ -5989,6 +6005,80 @@ describe("company portability", () => {
expect(preview.plan.issuePlans).toHaveLength(0);
});
it.each([
{ runner: "legacy", platform: "linux" },
{ runner: "legacy", platform: "darwin" },
{ runner: "paperclip", platform: "linux" },
{ runner: "paperclip", platform: "darwin" },
] as const)("exports the resolved Codex runner and preserves it on import ($runner/$platform)", async ({ runner, platform }) => {
runnerTarget.platform = platform;
runnerTarget.architecture = platform === "darwin" ? "arm64" : "x64";
const [fixture] = await agentSvc.list();
agentSvc.list.mockResolvedValue([{ ...fixture, adapterType: runner === "legacy" ? "codex_local" : "paperclip_runner", adapterConfig: { ...(runner === "paperclip" ? { provider: "codex" } : {}), model: "gpt-5.4" } }]);
const portability = companyPortabilityService({} as any);
const exported = await portability.exportBundle("company-1", { include: { company: false, agents: true, projects: false, issues: false } });
expect(exported.manifest.agents[0]?.runner).toBe(runner);
expect(asTextFile(exported.files[".paperclip.yaml"])).toContain(`runner: "${runner}"`);
agentSvc.list.mockResolvedValue([]);
agentSvc.create.mockImplementation(async (_companyId: string, input: Record<string, unknown>) => ({ id: "agent-created", ...input }));
await portability.importBundle({ source: { type: "inline", rootPath: exported.rootPath, files: exported.files }, include: { company: false, agents: true, projects: false, issues: false }, target: { mode: "existing_company", companyId: "company-1" }, agents: "all", collisionStrategy: "rename" }, "user-1");
expect(agentSvc.create).toHaveBeenCalledWith("company-1", expect.objectContaining({ adapterType: runner === "legacy" ? "codex_local" : "paperclip_runner", runner, adapterConfig: expect.objectContaining(runner === "paperclip" ? { provider: "codex", model: "gpt-5.4" } : { model: "gpt-5.4" }) }), { createdByUserId: "user-1" });
await portability.importBundle({ source: { type: "inline", rootPath: exported.rootPath, files: exported.files }, include: { company: false, agents: true, projects: false, issues: false }, target: { mode: "existing_company", companyId: "company-1" }, agents: "all", collisionStrategy: "rename", adapterOverrides: { claudecoder: { adapterType: "codex_local", adapterConfig: { model: "gpt-5.5" } } } }, "user-1");
expect(agentSvc.create).toHaveBeenLastCalledWith("company-1", expect.objectContaining({ adapterType: runner === "legacy" ? "codex_local" : "paperclip_runner", runner, adapterConfig: expect.objectContaining({ model: "gpt-5.5" }) }), { createdByUserId: "user-1" });
});
it("rejects an invalid package runner without selecting a default or creating an agent", async () => {
const portability = companyPortabilityService({} as any);
const exported = await portability.exportBundle("company-1", { include: { company: false, agents: true, projects: false, issues: false } });
const files = { ...exported.files, ".paperclip.yaml": asTextFile(exported.files[".paperclip.yaml"]).replace('runner: "legacy"', 'runner: "typo"') };
agentSvc.list.mockResolvedValue([]);
await expect(portability.importBundle({ source: { type: "inline", rootPath: exported.rootPath, files }, include: { company: false, agents: true, projects: false, issues: false }, target: { mode: "existing_company", companyId: "company-1" }, agents: "all", collisionStrategy: "rename" }, "user-1")).rejects.toMatchObject({ status: 422, details: { code: "agent_runner_choice_invalid", field: "agents.claudecoder.adapter.runner" } });
expect(agentSvc.create).not.toHaveBeenCalled();
});
it.each([
{ action: "create", platform: "linux", expectedType: "paperclip_runner" },
{ action: "update", platform: "linux", expectedType: "codex_local" },
{ action: "create", platform: "darwin", expectedType: "paperclip_runner" },
{ action: "update", platform: "darwin", expectedType: "codex_local" },
{ action: "create", platform: "win32", expectedType: "codex_local" },
])("applies qualified Codex defaults only to new imports without a runner ($platform/$action)", async ({ action, platform, expectedType }) => {
runnerTarget.platform = platform;
runnerTarget.architecture = platform === "darwin" ? "arm64" : "x64";
const [fixture] = await agentSvc.list();
agentSvc.list.mockResolvedValue([{ ...fixture, adapterType: "codex_local", adapterConfig: { model: "gpt-5.4" } }]);
const portability = companyPortabilityService({} as any);
const exported = await portability.exportBundle("company-1", { include: { company: false, agents: true, projects: false, issues: false } });
const files = { ...exported.files, ".paperclip.yaml": asTextFile(exported.files[".paperclip.yaml"]).replace(/\n\s+runner: "legacy"/, "") };
if (action === "create") agentSvc.list.mockResolvedValue([]);
agentSvc.create.mockImplementation(async (_companyId: string, input: Record<string, unknown>) => ({ id: "agent-created", ...input }));
agentSvc.update.mockImplementation(async (id: string, input: Record<string, unknown>) => ({ id, ...fixture, ...input }));
await portability.importBundle({ source: { type: "inline", rootPath: exported.rootPath, files }, include: { company: false, agents: true, projects: false, issues: false }, target: { mode: "existing_company", companyId: "company-1" }, agents: "all", collisionStrategy: action === "update" ? "replace" : "rename" }, "user-1");
if (action === "create") expect(agentSvc.create).toHaveBeenCalledWith("company-1", expect.objectContaining({ adapterType: expectedType, adapterConfig: expect.objectContaining(expectedType === "paperclip_runner" ? { provider: "codex" } : { model: "gpt-5.4" }) }), { createdByUserId: "user-1" });
else expect(agentSvc.update).toHaveBeenCalledWith("agent-1", expect.objectContaining({ adapterType: "codex_local", adapterConfig: expect.objectContaining({ model: "gpt-5.4" }) }));
});
it.each([
{ existingRunner: "legacy", existingHarness: "codex_local", expectedType: "codex_local", platform: "linux" },
{ existingRunner: "legacy", existingHarness: "codex_local", expectedType: "codex_local", platform: "darwin" },
{ existingRunner: "paperclip", existingHarness: "codex_local", expectedType: "paperclip_runner", platform: "linux" },
{ existingRunner: "paperclip", existingHarness: "codex_local", expectedType: "paperclip_runner", platform: "darwin" },
{ existingRunner: "legacy", existingHarness: "claude_local", expectedType: "paperclip_runner", platform: "linux" },
])("keeps execution on same-harness replace-import edits and defaults genuine changes ($existingRunner/$existingHarness/$platform)", async ({ existingRunner, existingHarness, expectedType, platform }) => {
runnerTarget.platform = platform;
runnerTarget.architecture = platform === "darwin" ? "arm64" : "x64";
const [fixture] = await agentSvc.list();
const existing = { ...fixture, adapterType: existingRunner === "paperclip" ? "paperclip_runner" : existingHarness, adapterConfig: { ...(existingRunner === "paperclip" ? { provider: "codex" } : {}), model: existingHarness === "codex_local" ? "gpt-5.4" : "claude-sonnet-4-6" } };
agentSvc.list.mockResolvedValue([existing]);
const portability = companyPortabilityService({} as any);
const exported = await portability.exportBundle("company-1", { include: { company: false, agents: true, projects: false, issues: false } });
const files = { ...exported.files, ".paperclip.yaml": asTextFile(exported.files[".paperclip.yaml"]).replace(/\n\s+runner: "(?:legacy|paperclip)"/, "") };
agentSvc.update.mockImplementation(async (id: string, input: Record<string, unknown>) => ({ id, ...existing, ...input }));
await portability.importBundle({ source: { type: "inline", rootPath: exported.rootPath, files }, include: { company: false, agents: true, projects: false, issues: false }, target: { mode: "existing_company", companyId: "company-1" }, agents: "all", collisionStrategy: "replace", adapterOverrides: { claudecoder: { adapterType: "codex_local", adapterConfig: { model: "gpt-5.5" } } } }, "user-1");
expect(agentSvc.update).toHaveBeenCalledWith("agent-1", expect.objectContaining({ adapterType: expectedType, adapterConfig: expect.objectContaining({ model: "gpt-5.5", ...(expectedType === "paperclip_runner" ? { provider: "codex" } : {}) }) }));
expect(agentSvc.create).not.toHaveBeenCalled();
});
it("imports an unpaired Dot using its own option without enabling other Runner providers", async () => {
const portability = companyPortabilityService({} as any);
const exported = await portability.exportBundle("company-1", { include: { company: false, agents: true, projects: false, issues: false } });
@@ -6005,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: "codex" } } } }, "user-1"))
await expect(portability.importBundle({ ...input, adapterOverrides: { claudecoder: { adapterType: "paperclip_runner", adapterConfig: { provider: "acpx", acpxAgent: "claude", model: "claude-sonnet-4-6" } } } }, "user-1"))
.rejects.toMatchObject({ status: 422, details: { code: "paperclip_runner_rollout_disabled" } });
expect(agentSvc.create).not.toHaveBeenCalled();
await portability.importBundle(input, "user-1");
@@ -6039,7 +6129,7 @@ describe("company portability", () => {
adapterOverrides: {
claudecoder: {
adapterType: "paperclip_runner",
adapterConfig: { provider: "codex" },
adapterConfig: { provider: "acpx", acpxAgent: "claude", model: "claude-sonnet-4-6" },
},
},
};
@@ -6071,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: "codex" }),
adapterConfig: expect.objectContaining({ provider: "acpx", acpxAgent: "claude" }),
}), { createdByUserId: "user-1" });
await portability.importBundle({
@@ -2,6 +2,7 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { localPaperclipRunnerSupported } from "../lib/agent-runner-platform.js";
import {
paperclipRunnerBinaryNeedsBuild,
@@ -61,6 +62,14 @@ function nativeHeader(platform: string, architecture: string): Buffer {
}
describe("paperclip runner native dev prerequisite", () => {
it.each(["x64", "arm64"].flatMap(architecture => [[undefined, false], ["2.38", false], ["2.39", true], ["3.0", true]].map(([glibcVersionRuntime, native]) => ({ architecture, glibcVersionRuntime, native }))))("uses the measured libc prerequisite on Linux $architecture/$glibcVersionRuntime when no native run or flag requires it", ({ architecture, glibcVersionRuntime, native }) => {
Object.defineProperty(process, "platform", { ...initialPlatform, value: "linux" });
Object.defineProperty(process, "arch", { ...initialArchitecture, value: architecture });
vi.spyOn(process.report, "getReport").mockReturnValue({ header: { glibcVersionRuntime } } as never);
const nativeRunnerRequired = localPaperclipRunnerSupported();
expect(nativeRunnerRequired).toBe(native);
expect(paperclipRunnerBinaryNeedsBuild({ repoRoot: "/checkout/without/a/staged/binary", nativeRunnerRequired })).toBe(native);
});
it("uses an explicit status response and fails safe when status is unknown", () => {
expect(
resolveNativeRunnerRequirement({
+1 -1
View File
@@ -101,7 +101,7 @@ describe("durable Dot Runner integration", () => {
expect(() => resolveNativeRuntimeMode(input)).toThrow("experimental settings");
expect(resolveNativeRuntimeMode({ ...input, enabled: false, dotEnabled: true })).toMatchObject({ kind: "native", profile: { backend: "openai_dot_mcp" } });
expect(() => resolveNativeRuntimeMode({ ...input, enabled: false, dotEnabled: true,
adapterConfig: { provider: "codex" },
adapterConfig: { provider: "opencode", model: "openrouter/deepseek/deepseek-v4-flash-0731" },
})).toThrow("Paperclip Runner is experimental and disabled");
expect(resolveHeartbeatNativeRuntimeMode({ ...input, enabled: false, dotEnabled: false,
persisted: { runtimeMode: "native", runtimeModeReason: null, runtimeModeResolvedAt: new Date(), driverKind: "openai_dot_mcp" },
@@ -848,8 +848,12 @@ describe("P6-31 Section 18.13 executable status-authority corpus", () => {
completionState === "kill_switch_during_active_native_run"
&& (fixture.covers.migrationRows ?? []).includes("MIG-08")
) {
const experimentalAdapterConfig = {
provider: "opencode",
model: "openrouter/deepseek/deepseek-v4-flash-0731",
};
const runtimeConfig = {
nativeRunner: { mode: "native", backend: "codex_app_server", protocolVersion: 1 },
nativeRunner: { mode: "native", backend: "opencode_server", protocolVersion: 1 },
};
const enabled = options.disableLiveEntrypoint === "rollout";
const activeResolution = resolveHeartbeatNativeRuntimeMode({
@@ -860,7 +864,7 @@ describe("P6-31 Section 18.13 executable status-authority corpus", () => {
},
enabled,
runtimeConfig,
adapterConfig: { provider: "codex" },
adapterConfig: experimentalAdapterConfig,
agent: { id: agentId, status: "running", adapterType: "paperclip_runner" },
issue: { id: seeded.issueId, workMode: "standard" },
target: { kind: "local" },
@@ -873,7 +877,7 @@ describe("P6-31 Section 18.13 executable status-authority corpus", () => {
persisted: { runtimeMode: null, runtimeModeReason: null, runtimeModeResolvedAt: null },
enabled,
runtimeConfig,
adapterConfig: { provider: "codex" },
adapterConfig: experimentalAdapterConfig,
agent: { id: agentId, status: "running", adapterType: "paperclip_runner" },
issue: { id: seeded.issueId, workMode: "standard" },
target: { kind: "local" },
@@ -1,7 +1,7 @@
import { randomUUID } from "node:crypto";
import request from "supertest";
import { and, eq } from "drizzle-orm";
import { afterEach, expect, it, vi } from "vitest";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import {
activityLog,
agents,
@@ -38,8 +38,16 @@ const SEED = {
describeEmbeddedPostgres("POST /api/companies/:companyId/onboarding-seed", () => {
const ctx = useEmbeddedPostgres("onboarding-seed-route");
let runnerSelectionSpy: { mockRestore(): void } | undefined;
beforeEach(async () => {
const selection = await import("../services/agent-runner-selection.js");
runnerSelectionSpy = vi.spyOn(selection, "resolveNewAgentRunnerForCompany").mockImplementation(async (_db, _companyId, input) =>
selection.resolveNewAgentRunner({ ...input, target: { driver: "local", platform: "linux", architecture: "x64" } }));
});
afterEach(async () => {
runnerSelectionSpy?.mockRestore();
await ctx.db.delete(activityLog);
await ctx.db.delete(companyOnboardingSeeds);
await ctx.db.delete(issues);
@@ -143,6 +151,25 @@ describeEmbeddedPostgres("POST /api/companies/:companyId/onboarding-seed", () =>
}
});
it("uses the creation default for server-seeded Codex without changing the Claude fallback", async () => {
const previous = process.env.PAPERCLIP_ONBOARDING_SEED_ADAPTER_TYPE;
process.env.PAPERCLIP_ONBOARDING_SEED_ADAPTER_TYPE = "codex_local";
try {
const { companyId, app } = await seedCompany();
const response = await post(app, companyId, SEED);
expect(response.status).toBe(200);
const [agent] = await ctx.db.select().from(agents).where(eq(agents.companyId, companyId));
expect(agent).toMatchObject({ adapterType: "paperclip_runner", adapterConfig: { provider: "codex" } });
expect(agent?.adapterConfig).toMatchObject({ paperclipSkillSync: { desiredSkills: expect.arrayContaining(["paperclipai/paperclip/paperclip-create-agent"]) } });
expect((await post(app, companyId, SEED)).body.changed).toBe(false);
const [replayed] = await ctx.db.select().from(agents).where(eq(agents.companyId, companyId));
expect(replayed?.adapterConfig).toEqual(agent?.adapterConfig);
} finally {
if (previous === undefined) delete process.env.PAPERCLIP_ONBOARDING_SEED_ADAPTER_TYPE;
else process.env.PAPERCLIP_ONBOARDING_SEED_ADAPTER_TYPE = previous;
}
});
it("is idempotent per revision — a replay creates no second agent or task", async () => {
const { companyId, app } = await seedCompany();
@@ -4,7 +4,7 @@ import { promises as fs } from "node:fs";
import os from "node:os";
import path from "node:path";
import { eq } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import {
activityLog,
agentConfigRevisions,
@@ -96,13 +96,24 @@ if (!embeddedPostgresSupport.supported) {
describeEmbeddedPostgres("plugin-managed agents", () => {
let db!: ReturnType<typeof createDb>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
let runnerSelectionSpy: { mockRestore(): void } | undefined;
const runnerTarget = { driver: "local", platform: "linux", architecture: "x64" };
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-plugin-managed-agents-");
db = createDb(tempDb.connectionString);
}, 20_000);
beforeEach(async () => {
runnerTarget.platform = "linux";
runnerTarget.architecture = "x64";
const selection = await import("../services/agent-runner-selection.js");
runnerSelectionSpy = vi.spyOn(selection, "resolveNewAgentRunnerForCompany").mockImplementation(async (_db, _companyId, input) =>
selection.resolveNewAgentRunner({ ...input, target: runnerTarget }));
});
afterEach(async () => {
runnerSelectionSpy?.mockRestore();
await db.delete(agentConfigRevisions);
await db.delete(activityLog);
await db.delete(pluginEntities);
@@ -272,7 +283,9 @@ describeEmbeddedPostgres("plugin-managed agents", () => {
});
});
it("creates managed agents with the most-used compatible company adapter", async () => {
it.each([{ platform: "linux", architecture: "x64" }, { platform: "linux", architecture: "arm64" }, { platform: "darwin", architecture: "arm64" }, { platform: "darwin", architecture: "x64" }])("creates managed agents with the most-used compatible company adapter ($platform/$architecture)", async ({ platform, architecture }) => {
runnerTarget.platform = platform;
runnerTarget.architecture = architecture;
const pluginManifest = manifest();
pluginManifest.agents![0] = {
...pluginManifest.agents![0]!,
@@ -288,8 +301,8 @@ describeEmbeddedPostgres("plugin-managed agents", () => {
name: "Codex One",
role: "engineer",
status: "idle",
adapterType: "codex_local",
adapterConfig: {},
adapterType: "paperclip_runner",
adapterConfig: { provider: "codex", model: "gpt-5.4" },
runtimeConfig: {},
permissions: {},
},
@@ -320,7 +333,13 @@ describeEmbeddedPostgres("plugin-managed agents", () => {
const created = await services.agents.managedReconcile({ companyId, agentKey: "wiki-maintainer" });
expect(created.status).toBe("created");
expect(created.agent?.adapterType).toBe("codex_local");
expect(created.agent).toMatchObject({ adapterType: "paperclip_runner", adapterConfig: { provider: "codex" } });
const reconciled = await services.agents.managedReconcile({ companyId, agentKey: "wiki-maintainer" });
expect(reconciled.agent?.adapterConfig).toEqual(created.agent?.adapterConfig);
runnerTarget.platform = "win32";
runnerTarget.architecture = "x64";
const reset = await services.agents.managedReset({ companyId, agentKey: "wiki-maintainer" });
expect(reset.agent).toMatchObject({ adapterType: "paperclip_runner", adapterConfig: { provider: "codex" } });
});
it("materializes declared managed agent instructions with local folder paths", async () => {
@@ -249,6 +249,95 @@ describeEmbeddedPostgres("recovery sweepStaleIssueLocks", () => {
).resolves.toBe("running");
});
async function seedCompletedOnboardingReport(options: {
runtimeMode?: "native" | "legacy";
variant?: "ordinary" | "cancelled" | "wrong_child" | "wrong_reason" | "wrong_agent" | "unfinished_child" | "foreign_child";
} = {}) {
const { companyId, agentId, runningRunId } = await seed();
const issueId = randomUUID();
const childIssueId = randomUUID();
const variant = options.variant;
let childCompanyId = companyId;
if (variant === "foreign_child") {
childCompanyId = randomUUID();
await db.insert(companies).values({
id: childCompanyId,
name: "Other company",
issuePrefix: `T${childCompanyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
});
}
await db.insert(issues).values({
id: issueId,
companyId,
title: "Completed onboarding awaiting its child result",
originKind: variant === "ordinary" ? "manual" : "onboarding_first_task",
status: variant === "cancelled" ? "cancelled" : "done",
assigneeAgentId: variant === "wrong_agent" ? null : agentId,
checkoutRunId: runningRunId,
executionRunId: runningRunId,
executionLockedAt: new Date(),
});
await db.insert(issues).values({
id: childIssueId,
companyId: childCompanyId,
title: "Accepted child output",
status: variant === "unfinished_child" ? "in_progress" : "done",
parentId: variant === "foreign_child" ? null : issueId,
});
await db.update(heartbeatRuns).set({
runtimeMode: options.runtimeMode ?? "native",
nativeIssueId: options.runtimeMode === "legacy" ? null : issueId,
processPid: 2_000_000_000,
contextSnapshot: {
issueId,
wakeReason: variant === "wrong_reason" ? "issue_commented" : "issue_children_completed",
completedChildIssueId: variant === "wrong_child" ? randomUUID() : childIssueId,
onboardingCompletion: true,
},
}).where(eq(heartbeatRuns.id, runningRunId));
return { companyId, agentId, issueId, childIssueId, runningRunId };
}
it.each(["native", "legacy"] as const)("preserves a live %s completed-onboarding reporting turn during delayed startup", async runtimeMode => {
const { issueId, runningRunId } = await seedCompletedOnboardingReport({ runtimeMode });
const result = await recoveryService(db, {
enqueueWakeup: vi.fn(),
liveRunExecutions: new Set([runningRunId]),
}).sweepStaleIssueLocks();
expect(result).toEqual({ cleared: 0, issueIds: [], terminalizedRunIds: [] });
expect((await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runningRunId)))[0]?.status).toBe("running");
expect((await db.select().from(issues).where(eq(issues.id, issueId)))[0]).toMatchObject({
status: "done", checkoutRunId: runningRunId, executionRunId: runningRunId,
});
expect(mockTelemetryClient.track).not.toHaveBeenCalled();
});
it.each(["ordinary", "cancelled", "wrong_child", "wrong_reason", "wrong_agent", "unfinished_child", "foreign_child"] as const)("does not exempt an invalid completed-onboarding reporting turn (%s)", async variant => {
const { issueId, runningRunId } = await seedCompletedOnboardingReport({ variant });
const result = await recoveryService(db, {
enqueueWakeup: vi.fn(),
liveRunExecutions: new Set([runningRunId]),
}).sweepStaleIssueLocks();
expect(result.terminalizedRunIds).toEqual([runningRunId]);
expect(result.cleared).toBe(1);
expect((await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runningRunId)))[0]?.status).toBe(variant === "cancelled" ? "cancelled" : "succeeded");
expect((await db.select().from(issues).where(eq(issues.id, issueId)))[0]).toMatchObject({ checkoutRunId: null, executionRunId: null });
});
it("still interrupts a completed-onboarding reporting turn after its process and execution owner are gone", async () => {
const { issueId, runningRunId } = await seedCompletedOnboardingReport();
const result = await recoveryService(db, { enqueueWakeup: vi.fn() }).sweepStaleIssueLocks();
expect(result.terminalizedRunIds).toEqual([runningRunId]);
expect(result.cleared).toBe(1);
expect((await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runningRunId)))[0]).toMatchObject({
status: "interrupted", errorCode: "orphaned_running_run",
});
expect((await db.select().from(issues).where(eq(issues.id, issueId)))[0]).toMatchObject({ checkoutRunId: null, executionRunId: null });
});
it("does not clear when checkoutRunId is terminal but executionRunId is still running", async () => {
const { companyId, agentId, failedRunId, runningRunId } = await seed();
const issueId = randomUUID();
+11 -3
View File
@@ -488,9 +488,11 @@ const paperclipRunnerAdapter: ServerAdapterModule = {
}],
};
}
const result = profile.provider === "opencode"
? await openCodeTestEnvironment(context)
: await codexTestEnvironment(context);
if (profile.provider === "codex") {
const { testNativeRunnerAuthentication } = await import("../services/native-runtime/setup-readiness.js");
return testNativeRunnerAuthentication(context, "codex", profile.model);
}
const result = await openCodeTestEnvironment(context);
return { ...result, adapterType: "paperclip_runner" };
},
listSkills: listCodexSkills,
@@ -1182,6 +1184,12 @@ export function isOverridePaused(type: string): boolean {
return pausedOverrides.has(type);
}
export function hasActiveAdapterOverride(type: string): boolean {
const registered = adaptersByType.get(type);
return builtinFallbacks.has(type) && registered !== undefined
&& registered !== builtinFallbacks.get(type) && !pausedOverrides.has(type);
}
/** Get the set of types whose overrides are currently paused. */
export function getPausedOverrides(): Set<string> {
return pausedOverrides;
+2
View File
@@ -86,6 +86,7 @@ import { summarySlotRoutes } from "./routes/summary-slots.js";
import { statusCardRoutes } from "./routes/status-cards.js";
import { teamsCatalogRoutes } from "./routes/teams-catalog.js";
import { agentRoutes } from "./routes/agents.js";
import { resolveAgentRunnerTargetForCompany } from "./services/agent-runner-selection.js";
import type { SetupTokenSessionService } from "./services/setup-token-session.js";
import {
buildSetupTokenLoginTransport,
@@ -1043,6 +1044,7 @@ export async function createApp(
);
api.use(
adapterRoutes({
resolveRunnerTarget: (companyId, environmentId) => resolveAgentRunnerTargetForCompany(db, companyId, environmentId),
getNativeRunnerEnabled: async () =>
(await instanceSettingsService(db).getExperimental())
.enableNativeRunner === true,
+5 -2
View File
@@ -6,6 +6,7 @@ import {
nativeRunFinalizations,
} from "@paperclipai/db";
import { resolveMigrationConnection } from "@paperclipai/db/migration-runtime";
import { localPaperclipRunnerSupported } from "./lib/agent-runner-platform.js";
import { instanceSettingsService } from "./services/instance-settings.js";
@@ -48,8 +49,10 @@ async function main(): Promise<void> {
console.log(
JSON.stringify({
nativeRunnerRequired:
experimental.enableNativeRunner === true || experimental.enableOpenAiDot === true || persistedNativeRun,
nativeRunnerRequired: localPaperclipRunnerSupported()
|| experimental.enableNativeRunner === true
|| experimental.enableOpenAiDot === true
|| persistedNativeRun,
rolloutEnabled: experimental.enableNativeRunner === true,
dotRolloutEnabled: experimental.enableOpenAiDot === true,
persistedNativeRun,
+3 -2
View File
@@ -4,7 +4,7 @@ import { agents, costEvents, type Db } from "@paperclipai/db";
import {
agentAppearanceSchema, resolveAgentAppearance, agentAvatarUrl,
AGENT_DEFAULT_MAX_CONCURRENT_RUNS, agentRuntimeConfigSchema, getAgentWorkEligibility,
normalizeAgentUrlKey, type AgentEligibilityAgent,
normalizeAgentUrlKey, type AgentEligibilityAgent, type AgentRunnerChoice,
} from "@paperclipai/shared";
import { conflict, notFound, unprocessable } from "../errors.js";
import { REDACTED_EVENT_VALUE, sanitizeRecord } from "../redaction.js";
@@ -80,13 +80,14 @@ export interface UpdateAgentOptions {
claudeLogin?: ClaudeLoginContext;
}
export type CreateAgentData = Omit<typeof agents.$inferInsert, "companyId" | "lifecycleState" | "lifecycleRequiredPluginIds" | "lifecycleHolds" | "lifecycleVersion" | "lifecycleError" | "lifecycleOperation">;
export type CreateAgentData = Omit<typeof agents.$inferInsert, "companyId" | "lifecycleState" | "lifecycleRequiredPluginIds" | "lifecycleHolds" | "lifecycleVersion" | "lifecycleError" | "lifecycleOperation"> & { runner?: AgentRunnerChoice };
export type AgentConfigurationRecord = typeof agents.$inferSelect;
export const AGENT_CONFIGURATION_FIELDS = [...CONFIG_REVISION_FIELDS, "permissions"] as const;
export type AgentConfigurationPatch = Partial<Pick<typeof agents.$inferInsert, typeof AGENT_CONFIGURATION_FIELDS[number]>>;
export type AgentHireRecord = Pick<typeof agents.$inferSelect, "id" | "companyId" | "adapterType" | "adapterConfig" | "permissions" | "metadata" | "status">;
export interface CreateAgentOptions {
runnerResolved?: boolean;
responsibleUserId?: string | null;
createdByUserId?: string | null;
aiConnectionInstall?: { connectionId: string; memberConnectionIds?: string[]; createdByUserId: string | null };
+20
View File
@@ -0,0 +1,20 @@
import { paperclipRunnerSupportsPlatform } from "@paperclipai/shared";
export function localAgentRunnerTarget(): { driver: "local"; platform: NodeJS.Platform; architecture: string; libc?: "glibc" | "unknown"; glibcVersion?: string } {
if (process.platform !== "linux") return { driver: "local", platform: process.platform, architecture: process.arch };
let libc: "glibc" | "unknown" = "unknown";
let glibcVersion: string | undefined;
try {
const { header } = process.report.getReport() as { header?: { glibcVersionRuntime?: unknown } };
if (typeof header?.glibcVersionRuntime === "string" && header.glibcVersionRuntime.trim()) {
libc = "glibc";
glibcVersion = header.glibcVersionRuntime.trim();
}
} catch { /* An unidentified libc must not select a GNU/Linux binary. */ }
return { driver: "local", platform: process.platform, architecture: process.arch, libc, ...(glibcVersion === undefined ? {} : { glibcVersion }) };
}
export function localPaperclipRunnerSupported(): boolean {
const target = localAgentRunnerTarget();
return paperclipRunnerSupportsPlatform("codex_local", target.platform, target.architecture, target.libc, target.glibcVersion);
}
@@ -73,7 +73,7 @@ export function hireApprovalService(db: Db, effects: LifecycleEffects) {
spentMonthlyCents: 0,
permissions: undefined,
lastHeartbeatAt: null,
}, { createdByUserId: approval!.requestedByAgentId ? null : approval!.requestedByUserId, responsibleUserId: approval!.requestedByUserId });
}, { runnerResolved: true, createdByUserId: approval!.requestedByAgentId ? null : approval!.requestedByUserId, responsibleUserId: approval!.requestedByUserId });
agentId = created.id;
}
if (approval && agentId && typeof payload.budgetMonthlyCents === "number" && payload.budgetMonthlyCents > 0) {
@@ -48,5 +48,8 @@ Work in this order.
4. Carry out the accepted scope.
- For a plan-only request, retain the accepted plan on this task. Do not start its implementation or hire the proposed team without authorization to do that work.
- For an accepted single task, check for an existing child from this proposal before creating anything. Create exactly one child linked to this onboarding task, assign it to yourself, and execute it. On later wakes, continue that same child instead of creating another.
- Save the finished output as a document on the child task and mark that child done. Link it from the onboarding conversation. Completing the onboarding parent in place, or saving the output only on the parent, does not fulfill the accepted child-task proposal.
- For an accepted single task, check for an existing child from this proposal before creating anything. Create exactly one child linked to this onboarding task and assign it to yourself. On later wakes, continue that same child instead of creating another.
- If the accepted child work requires a file deliverable, include the approved scope, document requirements, and requested filenames in a self-contained child description. Delegate the work to the child's own Paperclip execution, even when the child is assigned to yourself. That execution must save the document and publish the requested file on the child before marking it done. Do not create or register that file from the onboarding run, or mark the child done before its execution delivers the file. Native `register_deliverable` is bound to its current task.
- While waiting for that file-producing child, preserve existing blocker IDs and add the child as a dependency. Native runners use `set_dependencies`, then `paperclip_block` with the child agent as owner and child completion as the unblock action; legacy runners use the normal issue API with `blockedByIssueIds` and a blocked disposition. End the turn so the child can execute. Do not poll for its result while holding the onboarding run.
- When child completion resumes the onboarding task, verify the saved child document and retrievable file, then reply with the actual child task and download links. Reuse the recorded result instead of producing another file on the parent or creating another child.
- For document-only child work, you may execute it directly. Save the finished output as a document on the child task and mark that child done. Link it from the onboarding conversation. Completing the onboarding parent in place, or saving the output only on the parent, does not fulfill the accepted child-task proposal.
@@ -8,15 +8,46 @@ import {
WsJsonWireConnection,
} from "./runner-prp-outbound.js";
const sockets = vi.hoisted(() => ({ create: null as (() => unknown) | null }));
vi.mock("ws", async (importOriginal) => {
const actual = await importOriginal<typeof import("ws")>();
return {
...actual,
default: Object.assign(vi.fn(function () { return sockets.create!(); }), {
CONNECTING: actual.default.CONNECTING,
OPEN: actual.default.OPEN,
CLOSING: actual.default.CLOSING,
CLOSED: actual.default.CLOSED,
}),
};
});
class FakeSocket extends EventEmitter {
readyState: number = WebSocket.OPEN;
send = vi.fn();
terminate = vi.fn(() => {
this.readyState = WebSocket.CLOSED;
this.emit("close", 1006, Buffer.alloc(0));
});
close = vi.fn((code?: number) => {
this.readyState = WebSocket.CLOSED;
this.emit("close", code ?? 1000, Buffer.alloc(0));
});
}
function openingSocket(socket = new FakeSocket()): FakeSocket {
socket.readyState = WebSocket.CONNECTING;
queueMicrotask(() => {
if (socket.readyState !== WebSocket.CONNECTING) return;
socket.readyState = WebSocket.OPEN;
socket.emit("open");
});
return socket;
}
sockets.create = () => openingSocket();
describe("runner provider-ingress WebSocket wire", () => {
it("bounds credential refresh by the fixed deadline and cancellation", async () => {
const signal = new AbortController().signal;
@@ -140,6 +171,200 @@ describe("runner provider-ingress WebSocket wire", () => {
}
});
it.each(["delayed_ack", "no_ack", "unconfirmed_termination", "error_before_close"] as const)("retires authenticated %s ingress within the native setup close budget", async (mode) => {
vi.useFakeTimers();
const socket = new FakeSocket();
let activeConnections = 0;
const closeFrameOwners: number[] = [];
socket.close = vi.fn(() => {
closeFrameOwners.push(activeConnections);
socket.readyState = WebSocket.CLOSING;
if (mode === "delayed_ack") {
setTimeout(() => {
socket.readyState = WebSocket.CLOSED;
socket.emit("close", 1001, Buffer.alloc(0));
}, 250);
}
});
const finishClose = () => {
socket.readyState = WebSocket.CLOSED;
socket.emit("close", 1006, Buffer.alloc(0));
};
const terminate = vi.fn(() => {
if (mode === "error_before_close") socket.emit("error", new Error("fixture termination pending"));
else if (mode !== "unconfirmed_termination") finishClose();
});
Object.assign(socket, { terminate });
const create = sockets.create;
sockets.create = () => openingSocket(socket);
const endpoint = {
kind: "authenticated_websocket" as const,
websocketUrl: "wss://preview.invalid/api/runner/v1/connect/run-authenticated",
secretHeaders: [{ name: "Authorization", value: "fixture-only" }],
generation: "generation-authenticated",
refresh: vi.fn(async () => endpoint),
close: vi.fn(async () => { expect(activeConnections).toBe(0); }),
};
const attachWireConnection = vi.fn((wire: WsJsonWireConnection) => {
expect(activeConnections).toBe(0);
activeConnections += 1;
wire.onClose(() => { activeConnections -= 1; });
return { isAuthenticated: () => true };
});
const handle = connectRunnerPrpIngress({
authority: { attachWireConnection, activeRunnerConnectionCount: () => activeConnections } as unknown as DurablePrpControlPlane,
endpoint,
startupDeadlineMs: 60_000,
recoveryGraceMs: 60_000,
});
const failureObserver = vi.fn();
void handle.failure.catch(failureObserver);
let closing: Promise<void> | undefined;
let deadline: ReturnType<typeof setTimeout> | undefined;
try {
await vi.advanceTimersByTimeAsync(0);
await handle.ready;
expect(activeConnections).toBe(1);
closing = handle.close();
expect(closeFrameOwners).toEqual([1]);
if (socket.readyState !== WebSocket.CLOSED) {
expect(activeConnections).toBe(mode === "error_before_close" ? 0 : 1);
expect(endpoint.close).not.toHaveBeenCalled();
}
const outcome = Promise.race([
closing.then(() => "closed"),
new Promise<string>((resolve) => { deadline = setTimeout(() => resolve("setup_cleanup_deadline"), 10_000); }),
]);
await vi.advanceTimersByTimeAsync(10_000);
expect(terminate).toHaveBeenCalledOnce();
expect({ outcome: await outcome, activeConnections, endpointReleased: endpoint.close.mock.calls.length }).toEqual(["unconfirmed_termination", "error_before_close"].includes(mode)
? { outcome: "setup_cleanup_deadline", activeConnections: mode === "error_before_close" ? 0 : 1, endpointReleased: 0 }
: { outcome: "closed", activeConnections: 0, endpointReleased: 1 });
expect(attachWireConnection).toHaveBeenCalledOnce();
expect(endpoint.refresh).not.toHaveBeenCalled();
expect(failureObserver).not.toHaveBeenCalled();
if (["unconfirmed_termination", "error_before_close"].includes(mode)) return;
sockets.create = () => openingSocket();
const successor = connectRunnerPrpIngress({
authority: { attachWireConnection, activeRunnerConnectionCount: () => activeConnections } as unknown as DurablePrpControlPlane,
endpoint,
});
await vi.advanceTimersByTimeAsync(0);
await successor.ready;
expect(activeConnections).toBe(1);
await successor.close();
expect(activeConnections).toBe(0);
expect(attachWireConnection).toHaveBeenCalledTimes(2);
} finally {
if (deadline) clearTimeout(deadline);
finishClose();
await (closing ?? handle.close());
sockets.create = create;
vi.useRealTimers();
}
});
it("retains an errored prior socket until physical close during reconnect shutdown", async () => {
const prior = new FakeSocket();
prior.close = vi.fn(() => { prior.readyState = WebSocket.CLOSING; });
const pending = new FakeSocket();
const create = sockets.create;
let activeConnections = 0;
let opens = 0;
sockets.create = () => {
opens += 1;
if (opens === 1) return openingSocket(prior);
pending.readyState = WebSocket.CONNECTING;
return pending;
};
const endpointCloseStates: number[][] = [];
const endpoint = {
kind: "authenticated_websocket" as const,
websocketUrl: "wss://preview.invalid/api/runner/v1/connect/run-reconnecting",
secretHeaders: [],
generation: "generation-reconnecting",
refresh: vi.fn(async () => endpoint),
close: vi.fn(async () => { endpointCloseStates.push([prior.readyState, pending.readyState]); }),
};
const attachWireConnection = vi.fn((wire: WsJsonWireConnection) => {
activeConnections += 1;
wire.onClose(() => { activeConnections -= 1; });
return { isAuthenticated: () => true };
});
const handle = connectRunnerPrpIngress({
authority: { attachWireConnection, activeRunnerConnectionCount: () => activeConnections } as unknown as DurablePrpControlPlane,
endpoint,
});
const failureObserver = vi.fn();
void handle.failure.catch(failureObserver);
let closing: Promise<void> | undefined;
try {
await handle.ready;
prior.readyState = WebSocket.CLOSING;
prior.emit("error", new Error("fixture prior socket error"));
await new Promise(resolve => setImmediate(resolve));
expect(opens).toBe(2);
expect(activeConnections).toBe(0);
expect(prior.readyState).toBe(WebSocket.CLOSING);
closing = handle.close();
await closing;
expect({ priorTerminations: prior.terminate.mock.calls.length, endpointCloseStates: endpointCloseStates.map(states => [...states]) }).toEqual({
priorTerminations: 1, endpointCloseStates: [[WebSocket.CLOSED, WebSocket.CLOSED]],
});
expect(attachWireConnection).toHaveBeenCalledOnce();
expect(failureObserver).not.toHaveBeenCalled();
expect(endpoint.refresh).not.toHaveBeenCalled();
} finally {
prior.terminate();
pending.terminate();
await (closing ?? handle.close());
sockets.create = create;
}
});
it("joins a just-opened socket without attaching authority after intentional close", async () => {
const socket = new FakeSocket();
const create = sockets.create;
let closing: Promise<void> | undefined;
sockets.create = () => {
socket.readyState = WebSocket.CONNECTING;
queueMicrotask(() => {
socket.readyState = WebSocket.OPEN;
socket.emit("open");
closing = handle.close();
});
return socket;
};
const endpoint = {
kind: "authenticated_websocket" as const,
websocketUrl: "wss://preview.invalid/api/runner/v1/connect/run-opening",
secretHeaders: [],
generation: "generation-opening",
refresh: vi.fn(async () => endpoint),
close: vi.fn(async () => { expect(socket.readyState).toBe(WebSocket.CLOSED); }),
};
const attachWireConnection = vi.fn(() => ({ isAuthenticated: () => true }));
const handle = connectRunnerPrpIngress({
authority: { attachWireConnection, activeRunnerConnectionCount: () => 0 } as unknown as DurablePrpControlPlane,
endpoint,
});
const failureObserver = vi.fn();
void handle.failure.catch(failureObserver);
try {
await expect(handle.ready).rejects.toThrow("closed before it became ready");
await closing;
expect(socket.close).toHaveBeenCalledExactlyOnceWith(1001);
expect(socket.terminate).toHaveBeenCalledOnce();
expect(attachWireConnection).not.toHaveBeenCalled();
expect(endpoint.close).toHaveBeenCalledOnce();
expect(endpoint.refresh).not.toHaveBeenCalled();
expect(failureObserver).not.toHaveBeenCalled();
} finally {
await (closing ?? handle.close());
sockets.create = create;
}
});
it("does not report intentional close as an active-run ingress failure", async () => {
const endpoint = {
kind: "authenticated_websocket" as const,
+23 -6
View File
@@ -167,9 +167,19 @@ async function awaitWithinDeadline<T>(input: {
}
}
async function closeOwnedWire(wire: WsJsonWireConnection): Promise<void> {
const closed = wire.socket.readyState === WebSocket.CLOSED
? Promise.resolve()
: new Promise<void>((resolve) => wire.socket.once("close", () => resolve()));
wire.close(1001);
wire.socket.terminate();
await closed;
}
async function openEndpoint(input: {
endpoint: RunnerIngressEndpoint;
signal: AbortSignal;
onWire: (wire: WsJsonWireConnection) => void;
}): Promise<{ wire: WsJsonWireConnection; statusCode: number | null }> {
validateEndpoint(input.endpoint);
return await new Promise((resolve, reject) => {
@@ -211,6 +221,7 @@ async function openEndpoint(input: {
input.signal.removeEventListener("abort", onAbort);
reject(Object.assign(error, { statusCode }));
});
input.onWire(wire);
});
}
@@ -258,7 +269,7 @@ export function connectRunnerPrpIngress(input: {
) => void;
}): RunnerPrpOutboundHandle {
const abort = new AbortController();
let activeWire: WsJsonWireConnection | null = null;
const ownedWires = new Set<WsJsonWireConnection>();
let endpoint = input.endpoint;
const startupDeadline =
Date.now() + (input.startupDeadlineMs ?? DEFAULT_STARTUP_DEADLINE_MS);
@@ -288,8 +299,16 @@ export function connectRunnerPrpIngress(input: {
while (!abort.signal.aborted) {
try {
input.onStateChange?.(attempt === 0 ? "connecting" : "reconnecting");
const opened = await openEndpoint({ endpoint, signal: abort.signal });
activeWire = opened.wire;
if (abort.signal.aborted) return;
const opened = await openEndpoint({
endpoint,
signal: abort.signal,
onWire: (wire) => {
ownedWires.add(wire);
wire.socket.once("close", () => ownedWires.delete(wire));
},
});
if (abort.signal.aborted) return;
const attachment = input.authority.attachWireConnection(opened.wire);
const authenticated = await waitForPrpReady({
isAuthenticated: attachment.isAuthenticated,
@@ -311,10 +330,8 @@ export function connectRunnerPrpIngress(input: {
input.onStateChange?.("authenticated");
attempt = 0;
await socketClosed(opened.wire);
activeWire = null;
recoveryDeadline = Date.now() + recoveryGraceMs;
} catch (error) {
activeWire = null;
// close() owns intentional shutdown. openEndpoint rejects its pending
// handshake when the abort signal fires, but that cancellation is not
// an active-run transport failure and must not reject the long-lived
@@ -380,7 +397,7 @@ export function connectRunnerPrpIngress(input: {
failure,
async close() {
abort.abort();
activeWire?.close(1001);
await Promise.all([...ownedWires].map(closeOwnedWire));
await loop;
await endpoint.close();
input.onStateChange?.("closed");
+25 -8
View File
@@ -29,6 +29,7 @@ import {
unregisterServerAdapter,
isOverridePaused,
setOverridePaused,
waitForExternalAdapters,
} from "../adapters/registry.js";
import {
listAdapterPlugins,
@@ -47,11 +48,13 @@ import type {
} from "@paperclipai/adapter-utils";
import { loadExternalAdapterPackage, getUiParserSource, getOrExtractUiParserSource, reloadExternalAdapter } from "../adapters/plugin-loader.js";
import { logger } from "../middleware/logger.js";
import { forbidden } from "../errors.js";
import { badRequest, forbidden } from "../errors.js";
import { isCloudManagedInstance } from "../services/cloud-instance.js";
import { getHiddenSettings } from "../services/settings-visibility.js";
import { assertBoardOrgAccess, assertInstanceAdmin } from "./authz.js";
import { assertBoardOrgAccess, assertCompanyAccess, assertInstanceAdmin } from "./authz.js";
import { BUILTIN_ADAPTER_TYPES } from "../adapters/builtin-adapter-types.js";
import { agentRunnerAvailability, type RunnerTarget } from "../services/agent-runner-selection.js";
import type { AgentRunnerAvailability } from "@paperclipai/shared";
const execFileAsync = promisify(execFile);
@@ -123,7 +126,7 @@ interface AdapterCapabilities {
login?: AdapterLoginProjection;
}
interface AdapterInfo {
interface AdapterInfo extends AgentRunnerAvailability {
type: string;
label: string;
source: "builtin" | "external";
@@ -193,7 +196,7 @@ export function buildAdapterCapabilities(adapter: ServerAdapterModule): AdapterC
};
}
function buildAdapterInfo(adapter: ServerAdapterModule, externalRecord: AdapterPluginRecord | undefined, disabledSet: Set<string>): AdapterInfo {
function buildAdapterInfo(adapter: ServerAdapterModule, externalRecord: AdapterPluginRecord | undefined, disabledSet: Set<string>, target?: RunnerTarget): AdapterInfo {
const fromDisk = externalRecord ? readAdapterPackageVersionFromDisk(externalRecord) : undefined;
return {
type: adapter.type,
@@ -203,6 +206,7 @@ function buildAdapterInfo(adapter: ServerAdapterModule, externalRecord: AdapterP
loaded: true, // If it's in the registry, it's loaded
disabled: disabledSet.has(adapter.type),
capabilities: buildAdapterCapabilities(adapter),
...agentRunnerAvailability(adapter.type, target),
...(adapter.acp ? { acp: adapter.acp } : {}),
overriddenBuiltin: externalRecord ? BUILTIN_ADAPTER_TYPES.has(adapter.type) : undefined,
overridePaused: BUILTIN_ADAPTER_TYPES.has(adapter.type) ? isOverridePaused(adapter.type) : undefined,
@@ -260,6 +264,7 @@ function registerWithSessionManagement(adapter: ServerAdapterModule): void {
export function adapterRoutes(options: {
getNativeRunnerEnabled?: () => Promise<boolean>;
getOpenAiDotEnabled?: () => Promise<boolean>;
resolveRunnerTarget?: (companyId: string, environmentId?: string) => Promise<RunnerTarget>;
} = {}) {
const router = Router();
@@ -276,19 +281,31 @@ export function adapterRoutes(options: {
// instance-admin only because they affect the whole server runtime.
assertBoardOrgAccess(_req);
const companyId = _req.query.companyId;
const environmentId = _req.query.environmentId;
if ((companyId !== undefined && (typeof companyId !== "string" || !companyId.trim()))
|| (environmentId !== undefined && (typeof environmentId !== "string" || !environmentId.trim()))) {
throw badRequest("companyId and environmentId must be nonempty strings.");
}
if (environmentId !== undefined && companyId === undefined) throw badRequest("companyId is required when selecting an environment.");
let target: RunnerTarget | undefined;
if (typeof companyId === "string") {
assertCompanyAccess(_req, companyId);
if (!options.resolveRunnerTarget) throw badRequest("Scoped adapter discovery is unavailable.");
target = await options.resolveRunnerTarget(companyId, typeof environmentId === "string" ? environmentId : undefined);
}
await waitForExternalAdapters();
const registeredAdapters = listServerAdapters();
const externalRecords = new Map(
listAdapterPlugins().map((r) => [r.type, r]),
);
const disabledSet = new Set(getDisabledAdapterTypes());
const nativeRunnerEnabled = await options.getNativeRunnerEnabled?.().catch(() => false) ?? false;
const openAiDotEnabled = await options.getOpenAiDotEnabled?.().catch(() => false) ?? false;
// One shared implementation, with independent provider rollouts. Explicit
// adapter-admin disabling still applies to both choices.
if (!nativeRunnerEnabled && !openAiDotEnabled) disabledSet.add("paperclip_runner");
const result: AdapterInfo[] = registeredAdapters.map((adapter) =>
buildAdapterInfo(adapter, externalRecords.get(adapter.type), disabledSet),
buildAdapterInfo(adapter, externalRecords.get(adapter.type), disabledSet, target),
).sort((a, b) => a.type.localeCompare(b.type));
res.json(result);
+67 -17
View File
@@ -1,4 +1,6 @@
import { cancellationRequestId } from "../services/native-runtime/native-cancellation-request.js";
import { agentHarnessType, type AgentRunnerChoice } from "@paperclipai/shared";
import { resolveNewAgentRunnerForCompany } from "../services/agent-runner-selection.js";
import { agentEnvironmentTestService } from "../services/agent-environment-test.js";
import { createAgentLifecycle } from "../services/agent-lifecycle.js";
@@ -1845,7 +1847,7 @@ export function agentRoutes(
);
} 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 (experimental.enableNativeRunner !== true) {
} else if (!([undefined, "codex"].includes(asRecord(config)?.provider as string | undefined)) && experimental.enableNativeRunner !== true) {
throw unprocessable(
"Paperclip Runner is experimental and disabled on this instance.",
{ code: "paperclip_runner_rollout_disabled" },
@@ -2200,7 +2202,7 @@ export function agentRoutes(
adapterType: string | null | undefined,
adapterConfig: Record<string, unknown>,
): Record<string, unknown> {
if (adapterType !== "codex_local") return adapterConfig;
if (agentHarnessType(adapterType ?? "", adapterConfig) !== "codex_local") return adapterConfig;
const existingEnv = asRecord(adapterConfig.env);
if (!existingEnv) return adapterConfig;
if (!codexLocalEnvKeyConfigured(existingEnv.OPENAI_API_KEY)) return adapterConfig;
@@ -2251,11 +2253,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 credentialKeys = adapterType ? INHERITABLE_AGENT_CREDENTIAL_ENV_KEYS[adapterType] : undefined;
const harness = agentHarnessType(adapterType ?? "", adapterConfig) === "codex_local" ? "codex_local" : adapterType ?? "";
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 || parent.adapterType !== adapterType) return noInheritance;
if (!parent || parent.companyId !== companyId || (harness === "codex_local" ? agentHarnessType(parent.adapterType, parent.adapterConfig) !== harness : parent.adapterType !== adapterType)) 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);
@@ -3069,7 +3072,9 @@ export function agentRoutes(
try {
if (!target.executionTarget && target.fallbackChecks.length > 0) throw unprocessable("The agent environment is not available for adoption");
await withManagedAiProbe(db, { companyId, agentId, responsibleUserId: userId, adapterType, binding, config, allowUninstalledPersonal: newAgent, allowUninstalledShared, allowLegacyValidation: true }, async managed => {
const result = await testManagedEnvironment(adapterType, { companyId, adapterType, config: managed.config, executionTarget: target.executionTarget, environmentName: target.environmentName }, binding, managed, agentId);
const managedAiCredentialHome = adapterType === "paperclip_runner" && asRecord(managed.config)?.provider === "codex" && managed.home
? path.join(managed.home, "provider") : undefined;
const result = await testManagedEnvironment(adapterType, { companyId, adapterType, config: managed.config, managedAiCredentialHome, executionTarget: target.executionTarget, environmentName: target.environmentName }, binding, managed, agentId);
if (result.status === "fail" || result.checks.some(check => check.code === ADAPTER_AUTH_MISSING_CHECK_CODE)) throw unprocessable("The selected AI connection failed validation in this agent’s environment. Run the agent test to see the failing checks.", {
code: "ai_connection_validation_failed",
checks: result.checks.filter(check => check.level === "error" || check.code === ADAPTER_AUTH_MISSING_CHECK_CODE).map(check => ({ code: check.code, level: check.level })),
@@ -3086,11 +3091,9 @@ export function agentRoutes(
validate(testAdapterEnvironmentSchema),
async (req, res) => {
const companyId = req.params.companyId as string;
const type = assertKnownAdapterType(req.params.type as string);
let type = assertKnownAdapterType(req.params.type as string);
await assertCanCreateAgentsForCompany(req, companyId);
const adapter = requireServerAdapter(type);
const requestedBinding = req.body.aiConnection ? aiRuntimeConnectionBindingSchema.parse(req.body.aiConnection) : undefined;
const aiBinding = requestedBinding?.mode === "router" ? undefined : requestedBinding;
if (aiBinding && req.body.testCredentials && Object.keys(req.body.testCredentials).length) throw unprocessable("A managed connection test cannot override its credentials");
@@ -3105,11 +3108,6 @@ export function agentRoutes(
await assertCanUpdateAgent(req, savedAgent);
}
const poolBinding = requestedBinding ?? (savedAgent?.runtimeConfig?.aiConnection ? aiRuntimeConnectionBindingSchema.parse(savedAgent.runtimeConfig.aiConnection) : undefined);
if (poolBinding?.mode === "router") {
await validateManagedAgentBinding(req, companyId, savedAgentId ?? "", type, req.body.adapterConfig ?? {}, poolBinding, req.body.environmentId, false, !savedAgentId);
res.json({ adapterType: type, status: "warn", testedAt: new Date().toISOString(), checks: [{ code: "ai_connection_pool_task_test_required", level: "warn", message: "Pool configuration is available. Run a task to verify the selected account and harness; this preview does not allocate a task or contact a provider." }] });
return;
}
const requestedEnvironmentId = await resolveAdapterTestEnvironmentId(
companyId,
// Omission tests the saved selection. Explicit null tests a prospective
@@ -3149,6 +3147,25 @@ export function agentRoutes(
? restoreRedactedAgentEnv(inputAdapterConfig, savedAgent.adapterConfig)
: inputAdapterConfig;
}
const retainsSavedExecution = savedAgent && req.body.runner === undefined
&& savedAgent.adapterType === type
&& agentHarnessType(type, adapterConfigForTest) === agentHarnessType(savedAgent.adapterType, savedAgent.adapterConfig);
const selection = retainsSavedExecution
? { adapterType: type, adapterConfig: adapterConfigForTest }
: await resolveNewAgentRunnerForCompany(db, companyId, {
adapterType: type, adapterConfig: adapterConfigForTest,
runner: req.body.runner,
defaultEnvironmentId: requestedEnvironmentId,
});
type = selection.adapterType;
adapterConfigForTest = selection.adapterConfig;
const adapter = requireServerAdapter(type);
if (!retainsSavedExecution) await assertSelectableAdapterType(type, adapterConfigForTest);
if (poolBinding?.mode === "router") {
await validateManagedAgentBinding(req, companyId, savedAgentId ?? "", type, adapterConfigForTest, poolBinding, requestedEnvironmentId, false, !savedAgentId);
res.json({ adapterType: type, status: "warn", testedAt: new Date().toISOString(), checks: [{ code: "ai_connection_pool_task_test_required", level: "warn", message: "Pool configuration is available. Run a task to verify the selected account and harness; this preview does not allocate a task or contact a provider." }] });
return;
}
if (type === "paperclip_runner" && inputAdapterConfig.provider === "openai_dot") {
const dotEnabled = await dotRunnerBroker(db).enabled();
const binding = savedAgentId ? await dotRunnerBroker(db).bindingForAgent(companyId, savedAgentId) : null;
@@ -3295,7 +3312,9 @@ export function agentRoutes(
}
}
const result = aiBinding ? await withManagedAiProbe(db, { companyId, agentId: req.body.agentId ?? "", responsibleUserId: responsibleUserForAiRequest(req), adapterType: type, binding: aiBinding, config: effectiveAdapterConfig, allowUninstalledPersonal: !req.body.agentId, allowUninstalledShared: !req.body.agentId && await canInstallSharedAiConnectionForNewAgent(db, req, companyId, aiBinding) }, async managed => {
const tested = await testManagedEnvironment(type, { companyId, adapterType: type, config: managed.config, executionTarget, environmentName }, aiBinding, managed, savedAgentId ?? undefined);
const managedAiCredentialHome = type === "paperclip_runner" && asRecord(managed.config)?.provider === "codex" && managed.home
? path.join(managed.home, "provider") : undefined;
const tested = await testManagedEnvironment(type, { companyId, adapterType: type, config: managed.config, managedAiCredentialHome, executionTarget, environmentName }, aiBinding, managed, savedAgentId ?? undefined);
tested.checks.unshift({ code: "ai_connection_tested", level: "info", message: `Tested ${managed.accountName} — ${managed.accountOwnerUserId ? managed.accountOwnerUserId === responsibleUserForAiRequest(req) ? "your personal account" : "the owner’s account authorized for this agent" : "company-shared account"}. Responsible user: ${req.actor.type === "agent" ? responsibleUserForAiRequest(req) ?? "unavailable" : "the signed-in user"}.` });
return tested;
}) : await adapter.testEnvironment({ companyId, adapterType: type, config: effectiveAdapterConfig, executionTarget, environmentName });
@@ -4209,9 +4228,18 @@ export function agentRoutes(
onboardingFirstAgent: hireOnboardingFirstAgent,
// This intent flag is consumed below and must never reach agent create.
inheritRuntimeFrom,
runner: hireRunner,
...hireInput
} = req.body;
const hiredAgentId = randomUUID();
const requestedHireConfig = (hireInput.adapterConfig ?? {}) as Record<string, unknown>;
assertProviderTraceSettingTransition(req, hireInput.runtimeConfig);
assertNoAgentAdapterConfigMutation(req, requestedHireConfig);
assertNoAgentLocalAdapterHostCommandMutation(req, hireInput.adapterType ?? "process", requestedHireConfig, await callerUsesAiConnectionPool(req, companyId));
assertNoAgentProcessAdapterMutation(req, hireInput.adapterType ?? "process", Object.keys(requestedHireConfig).length > 0);
assertExternalInstructionsAdmin(req, { id: hiredAgentId, companyId, name: hireInput.name, adapterConfig: requestedHireConfig });
if (inheritRuntimeFrom === "caller") {
if (req.actor.type !== "agent" || !req.actor.agentId) {
throw forbidden("Only an agent can inherit native runtime settings from the caller");
@@ -4234,6 +4262,8 @@ export function agentRoutes(
hireInput.adapterConfig = inheritNativeRunnerAdapterConfig(caller.adapterConfig);
hireInput.defaultEnvironmentId = caller.defaultEnvironmentId ?? null;
}
if (inheritRuntimeFrom === "caller" && hireRunner !== undefined) throw unprocessable("inheritRuntimeFrom=caller cannot be combined with runner");
Object.assign(hireInput, await resolveNewAgentRunnerForCompany(db, companyId, { ...hireInput, runner: hireRunner }));
hireInput.adapterType = await assertSelectableAdapterType(hireInput.adapterType, hireInput.adapterConfig);
const rawHireAdapterConfig = (hireInput.adapterConfig ?? {}) as Record<string, unknown>;
assertProviderTraceSettingTransition(req, hireInput.runtimeConfig);
@@ -4249,7 +4279,6 @@ export function agentRoutes(
assertNoAgentAdapterConfigMutation(req, rawHireAdapterConfig);
assertNoAgentLocalAdapterHostCommandMutation(req, hireInput.adapterType, rawHireAdapterConfig, await callerUsesAiConnectionPool(req, companyId));
assertNoAgentProcessAdapterMutation(req, hireInput.adapterType, Object.keys(rawHireAdapterConfig).length > 0);
const hiredAgentId = randomUUID();
const authInheritance = await applyHiringAgentAuthInheritance(
req,
companyId,
@@ -4364,7 +4393,8 @@ export function agentRoutes(
},
{
createdByUserId: req.actor.type === "board" ? req.actor.userId : null,
responsibleUserId: responsibleUserForAiRequest(req),
runnerResolved: true,
responsibleUserId: responsibleUserForAiRequest(req),
aiConnectionInstall: managedHireConnection ? { ...managedHireConnection, createdByUserId: responsibleUserForAiRequest(req) } : undefined,
claudeLogin: {
storedSessionId: hireStoredSessionId ?? null,
@@ -4546,8 +4576,17 @@ export function agentRoutes(
// The onboarding marker is not an agent column. The server consumes it to
// seed the chief-of-staff persona; it never reaches the insert values.
onboardingFirstAgent: createOnboardingFirstAgent,
runner: createRunner,
...createInput
} = req.body;
const agentId = randomUUID();
const requestedCreateConfig = (createInput.adapterConfig ?? {}) as Record<string, unknown>;
assertProviderTraceSettingTransition(req, createInput.runtimeConfig);
assertNoAgentAdapterConfigMutation(req, requestedCreateConfig);
assertNoAgentLocalAdapterHostCommandMutation(req, createInput.adapterType ?? "process", requestedCreateConfig, await callerUsesAiConnectionPool(req, companyId));
assertNoAgentProcessAdapterMutation(req, createInput.adapterType ?? "process", Object.keys(requestedCreateConfig).length > 0);
assertExternalInstructionsAdmin(req, { id: agentId, companyId, name: createInput.name, adapterConfig: requestedCreateConfig });
Object.assign(createInput, await resolveNewAgentRunnerForCompany(db, companyId, { ...createInput, runner: createRunner }));
createInput.adapterType = await assertSelectableAdapterType(createInput.adapterType, createInput.adapterConfig);
const rawCreateAdapterConfig = (createInput.adapterConfig ?? {}) as Record<string, unknown>;
assertProviderTraceSettingTransition(req, createInput.runtimeConfig);
@@ -4563,7 +4602,6 @@ export function agentRoutes(
assertNoAgentAdapterConfigMutation(req, rawCreateAdapterConfig);
assertNoAgentLocalAdapterHostCommandMutation(req, createInput.adapterType, rawCreateAdapterConfig, await callerUsesAiConnectionPool(req, companyId));
assertNoAgentProcessAdapterMutation(req, createInput.adapterType, Object.keys(rawCreateAdapterConfig).length > 0);
const agentId = randomUUID();
const requestedAdapterConfig = applyCodexLocalKeyIsolation(
companyId,
agentId,
@@ -4621,6 +4659,7 @@ export function agentRoutes(
},
{
createdByUserId: req.actor.type === "board" ? req.actor.userId : null,
runnerResolved: true,
responsibleUserId: responsibleUserForAiRequest(req),
aiConnectionInstall: managedConnection ? { ...managedConnection, createdByUserId: responsibleUserForAiRequest(req) } : undefined,
claudeLogin: {
@@ -5164,6 +5203,8 @@ export function agentRoutes(
}
const patchData = { ...(req.body as Record<string, unknown>) };
const runner = patchData.runner as AgentRunnerChoice | undefined;
delete patchData.runner;
const requestedStatus = patchData.status;
delete patchData.status;
if (requestedStatus !== undefined && !["paused", "idle", "terminated"].includes(String(requestedStatus))) throw badRequest("Use an agent lifecycle command to change status");
@@ -5192,6 +5233,15 @@ export function agentRoutes(
// it gets the selectable check; keeping the agent's current adapter (even
// one since disabled) stays allowed, so a disabled harness does not make an
// existing agent uneditable.
if (runner !== undefined) {
await assertCanUpdateAgent(req, existing);
const config = hasOwn(patchData, "adapterConfig") ? { ...(replaceAdapterConfig ? {} : existing.adapterConfig), ...asRecord(patchData.adapterConfig) } : existing.adapterConfig;
Object.assign(patchData, await resolveNewAgentRunnerForCompany(db, existing.companyId, {
adapterType: typeof patchData.adapterType === "string" ? patchData.adapterType : existing.adapterType,
adapterConfig: config, runner,
defaultEnvironmentId: hasOwn(patchData, "defaultEnvironmentId") ? patchData.defaultEnvironmentId as string | null : existing.defaultEnvironmentId,
}));
}
const nextAdapterType = hasOwn(patchData, "adapterType")
? assertKnownAdapterType(patchData.adapterType as string | null | undefined)
: existing.adapterType;
+10 -1
View File
@@ -12,8 +12,17 @@ import { secretService, assertClaudeOAuthBindingInvariant } from "./secrets.js";
import { agentCredentialService } from "./agent-credentials.js";
import { agentIdentityService } from "./agent-identity.js";
import { initializePrimaryAgent } from "./primary-agent.js";
import { resolveNewAgentRunnerForCompany } from "./agent-runner-selection.js";
export async function prepareAgentHire(db: Db, companyId: string, data: CreateAgentData, options?: CreateAgentOptions): Promise<CreateAgentData> {
export async function prepareAgentHire(db: Db, companyId: string, input: CreateAgentData, options?: CreateAgentOptions): Promise<CreateAgentData> {
const { runner, ...data } = input;
const selection = options?.runnerResolved
? { adapterType: data.adapterType ?? "process", adapterConfig: isPlainRecord(data.adapterConfig) ? data.adapterConfig : {} }
: await resolveNewAgentRunnerForCompany(db, companyId, {
...data, adapterConfig: isPlainRecord(data.adapterConfig) ? data.adapterConfig : {}, runner,
});
data.adapterType = selection.adapterType;
data.adapterConfig = selection.adapterConfig;
const { ensureManager, assertBuiltInAgentMetadataMutationAllowed } = agentRecordQueries(db);
if (data.appearance?.customAvatarAssetId) throw unprocessable("Create the agent before uploading its avatar");
assertBuiltInAgentMetadataMutationAllowed(null, data.metadata, options);
@@ -0,0 +1,406 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { agentHarnessType, agentRunner, paperclipRunnerProfileForHarness, paperclipRunnerSupportsPlatform, PAPERCLIP_RUNNER_MINIMUM_GLIBC_VERSION, createAgentSchema, updateAgentSchema, testAdapterEnvironmentSchema } from "@paperclipai/shared";
const state = vi.hoisted(() => ({ disabled: [] as string[], overridden: new Set<string>(), settings: {} as Record<string, unknown>, getEnvironment: vi.fn(), bindings: vi.fn(), managedEnvironment: vi.fn(), kubernetesEnvironment: vi.fn(), ensureKubernetesEnvironment: vi.fn(), ssh: vi.fn(), resolveEnvironment: vi.fn(), sandboxQualification: vi.fn() }));
vi.mock("../adapters/registry.js", () => ({ findActiveServerAdapter: (type: string) => ({ type }), hasActiveAdapterOverride: (type: string) => state.overridden.has(type), waitForExternalAdapters: async () => {} }));
vi.mock("./adapter-plugin-store.js", () => ({ getDisabledAdapterTypes: () => state.disabled }));
vi.mock("./instance-settings.js", () => ({ instanceSettingsService: () => ({ get: async () => state.settings }) }));
vi.mock("./environments.js", () => ({ environmentService: () => ({ getById: state.getEnvironment, listBoundCompanyIds: state.bindings, findManagedSandboxEnvironment: state.managedEnvironment, findKubernetesEnvironment: state.kubernetesEnvironment, ensureKubernetesEnvironment: state.ensureKubernetesEnvironment }) }));
vi.mock("./environment-probe.js", () => ({ readSandboxPlatformQualification: state.sandboxQualification }));
vi.mock("./environment-config.js", () => ({ resolveEnvironmentDriverConfigForRuntime: state.resolveEnvironment }));
vi.mock("@paperclipai/adapter-utils/ssh", () => ({ runSshCommand: state.ssh }));
import { AGENT_RUNNER_SSH_PLATFORM_COMMAND, agentRunnerAvailability, resolveAgentRunnerTargetForCompany, resolveNewAgentRunner, resolveNewAgentRunnerForCompany } from "./agent-runner-selection.js";
import { buildCodexAcpConfig, resolveCodexExecutionEngine } from "@paperclipai/adapter-codex-local/server";
import { secretService } from "./secrets.js";
import { localAgentRunnerTarget } from "../lib/agent-runner-platform.js";
const target = { driver: "local", platform: "linux", architecture: "x64" };
const qualifiedTargets = [target, { driver: "local", platform: "linux", architecture: "arm64" }, { driver: "local", platform: "darwin", architecture: "arm64" }, { driver: "local", platform: "darwin", architecture: "x64" }];
const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform")!;
const originalArchitecture = Object.getOwnPropertyDescriptor(process, "arch")!;
afterEach(() => { vi.restoreAllMocks(); Object.defineProperty(process, "platform", originalPlatform); Object.defineProperty(process, "arch", originalArchitecture); });
describe("server-owned Codex runner selection", () => {
beforeEach(() => { state.disabled = []; state.overridden.clear(); state.settings = {}; state.getEnvironment.mockReset(); state.bindings.mockReset().mockResolvedValue([]); state.managedEnvironment.mockReset(); state.kubernetesEnvironment.mockReset().mockResolvedValue(null); state.ensureKubernetesEnvironment.mockReset(); state.ssh.mockReset().mockResolvedValue({ stdout: "Linux\nx86_64\n" }); state.sandboxQualification.mockReset().mockResolvedValue(null); state.resolveEnvironment.mockReset().mockResolvedValue({ driver: "ssh", config: {} }); });
it.each(["x64", "arm64"])("keeps detected musl/unknown Linux %s legacy and preserves saved execution", architecture => {
for (const libc of ["musl", "unknown"] as const) {
const target = { driver: "local", platform: "linux", architecture, libc };
expect(agentRunnerAvailability("codex_local", target)).toEqual({ supportedRunners: ["legacy"], defaultRunner: "legacy" });
const input = { adapterType: "codex_local", adapterConfig: { extraArgs: ["--sandbox", "read-only"] }, target };
expect(resolveNewAgentRunner(input)).toEqual({ adapterType: input.adapterType, adapterConfig: input.adapterConfig });
expect(() => resolveNewAgentRunner({ ...input, runner: "paperclip" })).toThrow("unavailable");
const saved = { adapterType: "paperclip_runner", adapterConfig: { provider: "codex", model: "gpt-5.6-sol" }, target };
expect(resolveNewAgentRunner(saved)).toEqual({ adapterType: saved.adapterType, adapterConfig: saved.adapterConfig });
expect(resolveNewAgentRunner({ ...saved, runner: "paperclip" })).toEqual({ adapterType: saved.adapterType, adapterConfig: saved.adapterConfig });
expect(agentRunnerAvailability("codex_local", { ...target, libc: "glibc" }).defaultRunner).toBe("paperclip");
}
});
it.each([[undefined, false], ["2.38", false], ["2.39", true], ["3.0", true], [" 2.39 ", true], ["malformed", false], ["", false]] as const)("uses the measured local Node libc version (%s) for creation", async (glibcVersionRuntime, native) => {
Object.defineProperty(process, "platform", { ...originalPlatform, value: "linux" });
Object.defineProperty(process, "arch", { ...originalArchitecture, value: "arm64" });
vi.spyOn(process.report, "getReport").mockReturnValue({ header: { glibcVersionRuntime }, environmentVariables: { PRIVATE_SENTINEL: "do-not-retain" } } as never);
const localTarget = localAgentRunnerTarget();
expect(localTarget).toEqual({ driver: "local", platform: "linux", architecture: "arm64", libc: glibcVersionRuntime ? "glibc" : "unknown", ...(glibcVersionRuntime ? { glibcVersion: glibcVersionRuntime.trim() } : {}) });
expect(JSON.stringify(localTarget)).not.toContain("do-not-retain");
expect(agentRunnerAvailability("codex_local").defaultRunner).toBe(native ? "paperclip" : "legacy");
const saved = await resolveNewAgentRunnerForCompany({} as never, "company", { adapterType: "codex_local" });
expect(saved.adapterType).toBe(native ? "paperclip_runner" : "codex_local");
});
it.each([
["2.38", false], ["2.39", true], ["2.40", true], ["3.0", true], ["2.9", false],
["glibc 2.39", true], [" 2.39 ", true], ["2.39.1", true], ["02.039", true],
["2.39ubuntu1", false], ["unknown", false], ["", false], ["2", false], ["2.-39", false],
["999999999999999999999999.0", false],
])("qualifies the measured GNU/Linux daemon ABI for glibc %s (%s)", (glibcVersion, native) => {
expect(PAPERCLIP_RUNNER_MINIMUM_GLIBC_VERSION).toBe("2.39");
for (const architecture of ["x64", "arm64"]) {
const target = { driver: "local", platform: "linux", architecture, libc: "glibc" as const, glibcVersion: String(glibcVersion) };
expect(paperclipRunnerSupportsPlatform("codex_local", target.platform, architecture, target.libc, target.glibcVersion)).toBe(native);
expect(agentRunnerAvailability("codex_local", target).defaultRunner).toBe(native ? "paperclip" : "legacy");
expect(resolveNewAgentRunner({ adapterType: "codex_local", target }).adapterType).toBe(native ? "paperclip_runner" : "codex_local");
if (!native) expect(() => resolveNewAgentRunner({ adapterType: "codex_local", runner: "paperclip", target })).toThrow("unavailable");
const saved = { adapterType: "paperclip_runner", adapterConfig: { provider: "codex", model: "gpt-5.6-sol" }, target, runner: "paperclip" as const };
expect(resolveNewAgentRunner(saved)).toEqual({ adapterType: saved.adapterType, adapterConfig: saved.adapterConfig });
}
});
it("keeps an unidentified local Linux libc legacy if Node's report is unavailable", () => {
Object.defineProperty(process, "platform", { ...originalPlatform, value: "linux" });
vi.spyOn(process.report, "getReport").mockImplementation(() => { throw new Error("private-report-details"); });
expect(localAgentRunnerTarget()).toMatchObject({ libc: "unknown" });
expect(agentRunnerAvailability("codex_local").defaultRunner).toBe("legacy");
});
it.each(qualifiedTargets)("uses native Codex automatically with compatible configuration and is idempotent on $platform/$architecture", target => {
const input = { adapterType: "codex_local", target, adapterConfig: { model: "gpt-5.6-sol", modelReasoningEffort: "high", env: { OPENAI_API_KEY: { type: "secret_ref", secretId: "secret" } }, cwd: "/workspace", instructionsFilePath: "/workspace/AGENTS.md", workspaceStrategy: { type: "git_worktree" }, timeoutSec: 60, search: false, fastMode: false, dangerouslyBypassApprovalsAndSandbox: true } };
const resolved = resolveNewAgentRunner(input);
expect(resolved).toMatchObject({ adapterType: "paperclip_runner", adapterConfig: { provider: "codex", codexPermissionMode: "never", modelReasoningEffort: "high", env: input.adapterConfig.env, workspaceStrategy: input.adapterConfig.workspaceStrategy, timeoutSec: 60 } });
expect(resolved.adapterConfig).not.toHaveProperty("search");
expect(resolveNewAgentRunner(resolved)).toEqual(resolved);
expect(input.adapterConfig).toHaveProperty("search", false);
});
it.each(["claude_local", "opencode_local", "grok_local", "cursor", "pi_local", "process", "external"])("keeps %s default unchanged", adapterType => {
expect(resolveNewAgentRunner({ adapterType, target, adapterConfig: { custom: "preserved" } })).toEqual({ adapterType, adapterConfig: { custom: "preserved" } });
});
it.each(qualifiedTargets)("allows an explicit legacy runner and native to legacy round trip on $platform/$architecture", target => {
expect(resolveNewAgentRunner({ adapterType: "codex_local", target, runner: "legacy", adapterConfig: { extraArgs: ["--search"] } }).adapterType).toBe("codex_local");
expect(resolveNewAgentRunner({ adapterType: "paperclip_runner", runner: "legacy", adapterConfig: { provider: "codex", model: "gpt-5.6-sol", lifecycleMode: "per_turn", codexPermissionMode: "never", modelReasoningEffort: "high" } })).toEqual({ adapterType: "codex_local", adapterConfig: { model: "gpt-5.6-sol", modelReasoningEffort: "high", dangerouslyBypassApprovalsAndSandbox: true } });
});
it("preserves explicit legacy permission policy alongside compatible native lifecycle defaults", () => {
expect(resolveNewAgentRunner({ adapterType: "codex_local", runner: "legacy", adapterConfig: { dangerouslyBypassApprovalsAndSandbox: false, lifecycleMode: "per_turn" } }))
.toEqual({ adapterType: "codex_local", adapterConfig: { dangerouslyBypassApprovalsAndSandbox: false } });
expect(resolveNewAgentRunner({ adapterType: "paperclip_runner", runner: "legacy", adapterConfig: { provider: "codex", codexPermissionMode: "never", lifecycleMode: "per_turn", dangerouslyBypassApprovalsAndSandbox: false } }))
.toEqual({ adapterType: "codex_local", adapterConfig: { dangerouslyBypassApprovalsAndSandbox: false } });
});
it.each([{ platform: "win32", architecture: "x64" }, { platform: "linux", architecture: "arm" }, { platform: "darwin", architecture: "ia32" }])("keeps unqualified $platform/$architecture targets legacy while preserving explicit native profiles", ({ platform, architecture }) => {
const input = { adapterType: "codex_local", target: { driver: "local", platform, architecture } };
expect(resolveNewAgentRunner(input).adapterType).toBe("codex_local");
expect(() => resolveNewAgentRunner({ ...input, runner: "paperclip" })).toThrow("unavailable");
const saved = { adapterType: "paperclip_runner", adapterConfig: { provider: "codex", model: "gpt-5.6-sol" }, target: input.target };
expect(resolveNewAgentRunner(saved)).toEqual({ adapterType: saved.adapterType, adapterConfig: saved.adapterConfig });
expect(resolveNewAgentRunner({ ...saved, runner: "paperclip" })).toEqual({ adapterType: saved.adapterType, adapterConfig: saved.adapterConfig });
});
it.each(qualifiedTargets)("automatically keeps unsupported targets and active external overrides legacy on $platform/$architecture", target => {
const input = { adapterType: "codex_local", target: { driver: "local", platform: "win32", architecture: "x64" } };
expect(resolveNewAgentRunner(input).adapterType).toBe("codex_local");
expect(() => resolveNewAgentRunner({ ...input, runner: "paperclip" })).toThrow("unavailable");
state.overridden.add("codex_local");
expect(agentRunnerAvailability("codex_local", target).defaultRunner).toBe("legacy");
expect(resolveNewAgentRunner({ adapterType: "codex_local", target }).adapterType).toBe("codex_local");
state.overridden.clear();
state.overridden.add("paperclip_runner");
expect(resolveNewAgentRunner({ adapterType: "codex_local", target }).adapterType).toBe("codex_local");
});
it.each(qualifiedTargets)("does not select a disabled runner on $platform/$architecture", target => {
state.disabled = ["paperclip_runner"];
expect(resolveNewAgentRunner({ adapterType: "codex_local", target }).adapterType).toBe("codex_local");
expect(() => resolveNewAgentRunner({ adapterType: "codex_local", target, runner: "paperclip" })).toThrow("unavailable");
state.disabled = ["codex_local"];
expect(() => resolveNewAgentRunner({ adapterType: "codex_local", target })).toThrow("Codex is disabled");
});
it.each(["extraArgs", "agentCommand", "networkScope", "filesystemScope", "search"])("rejects incompatible %s without changing runner", field => {
expect(() => resolveNewAgentRunner({ adapterType: "codex_local", target, adapterConfig: { [field]: true } })).toThrow("Legacy runner");
try { resolveNewAgentRunner({ adapterType: "codex_local", target, adapterConfig: { [field]: true } }); }
catch (error) { expect(error).toMatchObject({ status: 422, details: { fields: [field] } }); }
});
it.each([
["agentCommand", "acpAgentCommand", "custom-codex-acp"],
["stateDir", "acpStateDir", "/custom/acp-state"],
["mode", "acpMode", "oneshot"],
["permissionMode", "acpPermissionMode", "deny-all"],
["nonInteractivePermissions", "acpNonInteractivePermissions", "fail"],
["warmHandleIdleMs", "acpWarmHandleIdleMs", 30_000],
])("rejects effective Legacy ACP alias %s/%s before automatic native translation", (canonical, alias, value) => {
const adapterConfig = { engine: "acp", [alias]: value };
expect(buildCodexAcpConfig(adapterConfig)).toHaveProperty(canonical, value);
for (const runner of ["auto", "paperclip"] as const) {
expect(() => resolveNewAgentRunner({ adapterType: "codex_local", adapterConfig, target, runner }))
.toThrow(expect.objectContaining({ status: 422, details: { code: "agent_runner_config_incompatible", fields: [alias] } }));
}
expect(resolveNewAgentRunner({ adapterType: "codex_local", adapterConfig, target, runner: "legacy" }))
.toEqual({ adapterType: "codex_local", adapterConfig });
});
it.each([
["agentCommand", "acpAgentCommand", "canonical-command", "alias-command"],
["stateDir", "acpStateDir", "/canonical-state", "/alias-state"],
["permissionMode", "acpPermissionMode", "approve-all", "deny-all"],
])("checks the effective canonical ACP %s ahead of its alias", (canonical, alias, value, aliasedValue) => {
const adapterConfig = { engine: "acp", [canonical]: value, [alias]: aliasedValue };
expect(buildCodexAcpConfig(adapterConfig)).toHaveProperty(canonical, value);
expect(() => resolveNewAgentRunner({ adapterType: "codex_local", adapterConfig, target }))
.toThrow(expect.objectContaining({ status: 422, details: { code: "agent_runner_config_incompatible", fields: [canonical] } }));
});
it("uses Legacy ACP canonical precedence and removes compatible or shadowed aliases idempotently", () => {
const adapterConfig = { engine: "acp", mode: "persistent", acpMode: "oneshot",
nonInteractivePermissions: "deny", acpNonInteractivePermissions: "fail",
warmHandleIdleMs: 0, acpWarmHandleIdleMs: 30_000 };
expect(buildCodexAcpConfig(adapterConfig)).toMatchObject({ mode: "persistent", nonInteractivePermissions: "deny", warmHandleIdleMs: 0 });
const resolved = resolveNewAgentRunner({ adapterType: "codex_local", adapterConfig, target });
expect(resolved).toMatchObject({ adapterType: "paperclip_runner", adapterConfig: { codexPermissionMode: "never" } });
for (const field of Object.keys(adapterConfig)) expect(resolved.adapterConfig).not.toHaveProperty(field);
expect(resolveNewAgentRunner(resolved)).toEqual(resolved);
expect(resolveNewAgentRunner({ adapterType: "codex_local", adapterConfig, target, runner: "legacy" }))
.toEqual({ adapterType: "codex_local", adapterConfig });
});
it("accepts equivalent ACP default aliases without persisting legacy-only controls", () => {
const adapterConfig = { engine: "acp", acpMode: " persistent ", acpNonInteractivePermissions: " deny ", acpWarmHandleIdleMs: 0 };
expect(buildCodexAcpConfig(adapterConfig)).toMatchObject({ mode: "persistent", nonInteractivePermissions: "deny", warmHandleIdleMs: 0 });
const resolved = resolveNewAgentRunner({ adapterType: "codex_local", adapterConfig, target });
expect(resolved.adapterType).toBe("paperclip_runner");
for (const field of Object.keys(adapterConfig)) expect(resolved.adapterConfig).not.toHaveProperty(field);
expect(resolveNewAgentRunner(resolved)).toEqual(resolved);
});
it("uses nullish ACP idle precedence instead of treating a canonical zero as absent", () => {
const adapterConfig = { engine: "acp", warmHandleIdleMs: null, acpWarmHandleIdleMs: 30_000 };
expect(buildCodexAcpConfig(adapterConfig)).toHaveProperty("warmHandleIdleMs", 30_000);
expect(() => resolveNewAgentRunner({ adapterType: "codex_local", adapterConfig, target }))
.toThrow(expect.objectContaining({ status: 422, details: { code: "agent_runner_config_incompatible", fields: ["acpWarmHandleIdleMs"] } }));
});
it("uses a nonempty ACP alias when its canonical string is blank", () => {
const adapterConfig = { engine: "acp", permissionMode: " ", acpPermissionMode: " deny-all " };
expect(buildCodexAcpConfig(adapterConfig)).toHaveProperty("permissionMode", "deny-all");
expect(() => resolveNewAgentRunner({ adapterType: "codex_local", adapterConfig, target }))
.toThrow(expect.objectContaining({ status: 422, details: { code: "agent_runner_config_incompatible", fields: ["acpPermissionMode"] } }));
});
it.each([undefined, "acp"])("rejects effective Legacy ACP network denial for engine=%s before native selection", async engine => {
const adapterConfig = { ...(engine ? { engine } : {}), env: { PAPERCLIP_CODEX_ACP_NETWORK_ACCESS: "false" } };
expect(resolveCodexExecutionEngine(adapterConfig).engine).toBe("acp");
expect(buildCodexAcpConfig(adapterConfig)).toMatchObject({ env: { PAPERCLIP_CODEX_ACP_NETWORK_ACCESS: "false" } });
const { codexNetworkAccess } = await import(new URL(
"../../../packages/paperclip-runner/src/drivers/codex/codex-security-config.ts", import.meta.url,
).href);
expect(codexNetworkAccess({ PAPERCLIP_RUNNER_NETWORK_ACCESS: "enabled", PAPERCLIP_CODEX_ACP_NETWORK_ACCESS: "false" })).toBe(true);
for (const runner of [undefined, "auto", "paperclip"] as const) {
expect(() => resolveNewAgentRunner({ adapterType: "codex_local", adapterConfig, target, runner }))
.toThrow(expect.objectContaining({ status: 422, details: { code: "agent_runner_config_incompatible", fields: ["env.PAPERCLIP_CODEX_ACP_NETWORK_ACCESS"] } }));
}
expect(resolveNewAgentRunner({ adapterType: "codex_local", adapterConfig, target, runner: "legacy" }))
.toEqual({ adapterType: "codex_local", adapterConfig });
expect(adapterConfig.env).toEqual({ PAPERCLIP_CODEX_ACP_NETWORK_ACCESS: "false" });
});
it.each([
{ type: "plain", value: "false" },
{ type: "secret_ref", secretId: "fixture-policy", version: "latest" },
{ type: "user_secret_ref", key: "fixture-policy", version: "latest" },
])("does not discard a bound ACP network restriction or guess its value (%j)", async binding => {
const adapterConfig = { engine: "acp", env: { PAPERCLIP_CODEX_ACP_NETWORK_ACCESS: binding } };
if (binding.type === "plain") {
const resolved = await secretService({} as never).resolveEnvBindings("fixture-company", adapterConfig.env);
expect(buildCodexAcpConfig({ ...adapterConfig, env: resolved.env }))
.toMatchObject({ env: { PAPERCLIP_CODEX_ACP_NETWORK_ACCESS: "false" } });
}
expect(() => resolveNewAgentRunner({ adapterType: "codex_local", adapterConfig, target }))
.toThrow(expect.objectContaining({ status: 422, details: { code: "agent_runner_config_incompatible", fields: ["env.PAPERCLIP_CODEX_ACP_NETWORK_ACCESS"] } }));
expect(resolveNewAgentRunner({ adapterType: "codex_local", adapterConfig, target, runner: "legacy" }))
.toEqual({ adapterType: "codex_local", adapterConfig });
});
it.each([
{ extraArgs: ["-c", "sandbox_workspace_write.network_access=true"] },
{ extraArgs: [], args: ["-c", "sandbox_workspace_write.network_access=true"] },
])("retains actionable argument errors when Legacy arguments override a network flag (%j)", args => {
const adapterConfig = { engine: "acp", ...args, env: { PAPERCLIP_CODEX_ACP_NETWORK_ACCESS: "false" } };
expect(buildCodexAcpConfig(adapterConfig)).toMatchObject({ env: { PAPERCLIP_CODEX_ACP_NETWORK_ACCESS: "true" } });
expect(() => resolveNewAgentRunner({ adapterType: "codex_local", adapterConfig, target }))
.toThrow(expect.objectContaining({ status: 422, details: { code: "agent_runner_config_incompatible", fields: [args.extraArgs.length ? "extraArgs" : "args"] } }));
});
it.each(["true", "False", " false ", { type: "plain", value: "true" }])("keeps compatible ACP environment settings (%j) and native idempotence", value => {
const adapterConfig = { engine: "acp", env: { PAPERCLIP_CODEX_ACP_NETWORK_ACCESS: value, OPENAI_API_KEY: { type: "secret_ref", secretId: "fixture-api-key" } } };
const literal = typeof value === "string" ? value : value.value;
expect(buildCodexAcpConfig({ ...adapterConfig, env: { PAPERCLIP_CODEX_ACP_NETWORK_ACCESS: literal } }))
.toMatchObject({ env: { PAPERCLIP_CODEX_ACP_NETWORK_ACCESS: "true" } });
const resolved = resolveNewAgentRunner({ adapterType: "codex_local", adapterConfig, target });
expect(resolved.adapterType).toBe("paperclip_runner");
expect(resolved.adapterConfig.env).toEqual(adapterConfig.env);
expect(resolveNewAgentRunner(resolved)).toEqual(resolved);
});
it("keeps a saved native profile and an explicitly selected Legacy CLI's unused ACP flag unchanged", () => {
const adapterConfig = { engine: "cli", env: { PAPERCLIP_CODEX_ACP_NETWORK_ACCESS: "false" } };
expect(resolveCodexExecutionEngine(adapterConfig).engine).toBe("cli");
expect(resolveNewAgentRunner({ adapterType: "codex_local", adapterConfig, target }).adapterType).toBe("paperclip_runner");
const saved = { adapterType: "paperclip_runner", adapterConfig: { provider: "codex", env: adapterConfig.env } };
expect(resolveNewAgentRunner(saved)).toEqual(saved);
});
it("preserves explicit historical native profiles, including managed configurations", () => {
for (const config of [{ provider: "acpx", acpxAgent: "claude", model: "claude-sonnet-5" }, { provider: "claude_managed", managedProfileId: "profile", managedAgentsRetentionAcknowledged: true }, { provider: "aws_agentcore", agentCoreProfileId: "profile" }]) expect(resolveNewAgentRunner({ adapterType: "paperclip_runner", adapterConfig: config })).toEqual({ adapterType: "paperclip_runner", adapterConfig: config });
});
it("advertises Legacy for a forced Kubernetes target before lazy environment creation", async () => {
state.settings = { general: { executionMode: "kubernetes" } };
const selected = await resolveAgentRunnerTargetForCompany({} as never, "company");
expect(selected).toEqual({ driver: "sandbox" });
expect(agentRunnerAvailability("codex_local", selected)).toEqual({ supportedRunners: ["legacy"], defaultRunner: "legacy" });
expect(state.kubernetesEnvironment).toHaveBeenCalledWith("company");
expect(state.ensureKubernetesEnvironment).not.toHaveBeenCalled();
expect(state.resolveEnvironment).not.toHaveBeenCalled();
expect(state.sandboxQualification).not.toHaveBeenCalled();
expect(state.ssh).not.toHaveBeenCalled();
});
it.each([undefined, "auto"] as const)("preserves Codex %s creation before a forced Kubernetes environment exists", async runner => {
state.settings = { general: { executionMode: "kubernetes" } };
const adapterConfig = { model: "gpt-5.6-sol", extraArgs: ["--search"], env: { OPENAI_API_KEY: { type: "secret_ref", secretId: "synthetic-secret" } } };
const resolved = await resolveNewAgentRunnerForCompany({} as never, "company", { adapterType: "codex_local", adapterConfig, runner });
expect(resolved).toEqual({ adapterType: "codex_local", adapterConfig });
expect(state.ensureKubernetesEnvironment).not.toHaveBeenCalled();
expect(state.resolveEnvironment).not.toHaveBeenCalled();
expect(state.sandboxQualification).not.toHaveBeenCalled();
expect(state.ssh).not.toHaveBeenCalled();
});
it("rejects explicit Paperclip execution on an unqualified forced Kubernetes target without provisioning", async () => {
state.settings = { general: { executionMode: "kubernetes" } };
await expect(resolveNewAgentRunnerForCompany({} as never, "company", { adapterType: "codex_local", runner: "paperclip" }))
.rejects.toMatchObject({ status: 422, message: expect.stringContaining("Legacy runner"), details: { code: "agent_runner_unavailable" } });
expect(state.ensureKubernetesEnvironment).not.toHaveBeenCalled();
expect(state.resolveEnvironment).not.toHaveBeenCalled();
expect(state.sandboxQualification).not.toHaveBeenCalled();
expect(state.ssh).not.toHaveBeenCalled();
});
it("preserves explicit Legacy and recorded native execution without reading the forced Kubernetes target", async () => {
state.settings = { general: { executionMode: "kubernetes" } };
for (const input of [
{ adapterType: "codex_local", runner: "legacy" as const, adapterConfig: { extraArgs: ["--search"] } },
{ adapterType: "paperclip_runner", adapterConfig: { provider: "codex", model: "gpt-5.6-sol" } },
]) {
expect(await resolveNewAgentRunnerForCompany({} as never, "company", input))
.toEqual({ adapterType: input.adapterType, adapterConfig: input.adapterConfig });
}
expect(state.kubernetesEnvironment).not.toHaveBeenCalled();
expect(state.ensureKubernetesEnvironment).not.toHaveBeenCalled();
});
it("retains company checks for an existing forced Kubernetes environment", async () => {
state.settings = { general: { executionMode: "kubernetes" } };
state.kubernetesEnvironment.mockResolvedValue({ id: "kubernetes", driver: "sandbox", status: "active", config: { provider: "kubernetes" } });
state.bindings.mockResolvedValue(["another-company"]);
await expect(resolveNewAgentRunnerForCompany({} as never, "company", { adapterType: "codex_local" }))
.rejects.toMatchObject({ status: 403, details: { code: "environment_company_mismatch" } });
expect(state.ensureKubernetesEnvironment).not.toHaveBeenCalled();
expect(state.sandboxQualification).not.toHaveBeenCalled();
expect(state.resolveEnvironment).not.toHaveBeenCalled();
});
it("rejects a foreign environment before resolving credentials or probing its platform", async () => {
state.getEnvironment.mockResolvedValue({ id: "foreign", driver: "ssh", status: "active", config: {} });
state.bindings.mockResolvedValue(["another-company"]);
await expect(resolveNewAgentRunnerForCompany({} as never, "company", { adapterType: "codex_local", defaultEnvironmentId: "foreign" })).rejects.toMatchObject({ status: 403, details: { code: "environment_company_mismatch" } });
expect(state.resolveEnvironment).not.toHaveBeenCalled(); expect(state.ssh).not.toHaveBeenCalled();
});
it("uses dispatch's instance default for a null agent override and checks the selected SSH platform", async () => {
state.settings = { defaultEnvironmentId: "default" };
state.getEnvironment.mockResolvedValue({ id: "default", driver: "ssh", status: "active", config: {} });
state.ssh.mockResolvedValue({ stdout: "Linux\naarch64\n" });
const result = await resolveNewAgentRunnerForCompany({} as never, "company", { adapterType: "codex_local", defaultEnvironmentId: null });
expect(state.getEnvironment).toHaveBeenCalledWith("default"); expect(state.bindings).toHaveBeenCalledWith("default");
expect(result.adapterType).toBe("paperclip_runner");
});
it.each([
["Linux\nx86_64\n", "paperclip", "paperclip_runner"],
["Darwin\narm64\n", "paperclip", "paperclip_runner"],
["Darwin\nx86_64\n", "paperclip", "paperclip_runner"],
["Linux\naarch64\n", "paperclip", "paperclip_runner"],
["Linux\narm64\n", "paperclip", "paperclip_runner"],
["Linux\nx86_64\nglibc\n", "paperclip", "paperclip_runner"],
["Linux\naarch64\nglibc\n", "paperclip", "paperclip_runner"],
["Linux\nx86_64\nglibc\nglibc 2.38\n", "legacy", "codex_local"],
["Linux\naarch64\nglibc\nglibc 2.38\n", "legacy", "codex_local"],
["Linux\nx86_64\nglibc\nglibc 2.39\n", "paperclip", "paperclip_runner"],
["Linux\naarch64\nglibc\nglibc 3.0\n", "paperclip", "paperclip_runner"],
["Linux\nx86_64\nglibc\nmalformed\n", "legacy", "codex_local"],
["Linux\nx86_64\nmusl\n", "legacy", "codex_local"],
["Linux\naarch64\nmusl\n", "legacy", "codex_local"],
["Linux\nx86_64\nunknown\n", "legacy", "codex_local"],
["Linux\narmv7l\n", "legacy", "codex_local"],
])("uses the same selected SSH target for discovery and saving (%s)", async (stdout, expectedRunner, expectedAdapter) => {
state.getEnvironment.mockResolvedValue({ id: "selected", driver: "ssh", status: "active", config: {} });
state.ssh.mockResolvedValue({ stdout });
const discoveredTarget = await resolveAgentRunnerTargetForCompany({} as never, "company", "selected");
expect(agentRunnerAvailability("codex_local", discoveredTarget).defaultRunner).toBe(expectedRunner);
const saved = await resolveNewAgentRunnerForCompany({} as never, "company", { adapterType: "codex_local", defaultEnvironmentId: "selected" });
expect(saved.adapterType).toBe(expectedAdapter);
expect(state.ssh).toHaveBeenCalledTimes(2);
expect(state.ssh).toHaveBeenCalledWith({}, AGENT_RUNNER_SSH_PLATFORM_COMMAND, { timeoutMs: 10_000 });
});
it("preserves recorded explicit native execution without rechecking creation defaults", async () => {
const recorded = { adapterType: "paperclip_runner", adapterConfig: { provider: "codex", model: "gpt-5.6-sol" }, runner: "paperclip" as const };
expect(await resolveNewAgentRunnerForCompany({} as never, "company", recorded)).toEqual({ adapterType: recorded.adapterType, adapterConfig: recorded.adapterConfig });
expect(state.getEnvironment).not.toHaveBeenCalled();
expect(state.ssh).not.toHaveBeenCalled();
});
it.each([
[{ platform: "linux", architecture: "x64", libc: "glibc", glibcVersion: "2.39" }, "paperclip_runner"],
[{ platform: "linux", architecture: "arm64", libc: "glibc", glibcVersion: "3.0" }, "paperclip_runner"],
[{ platform: "linux", architecture: "arm64", libc: "glibc", glibcVersion: "2.38" }, "codex_local"],
[{ platform: "linux", architecture: "x64", libc: "glibc" }, "codex_local"],
[{ platform: "linux", architecture: "arm64", libc: "musl" }, "codex_local"],
[{ platform: "linux", architecture: "unknown", libc: "glibc", glibcVersion: "2.39" }, "codex_local"],
])("uses cached selected-image measurements for sandbox creation/discovery (%j)", async (facts, expected) => {
state.getEnvironment.mockResolvedValue({ id: "sandbox", driver: "sandbox", status: "active", config: { provider: "daytona" } });
state.sandboxQualification.mockResolvedValue(facts);
const selectedTarget = await resolveAgentRunnerTargetForCompany({} as never, "company", "sandbox");
expect(agentRunnerAvailability("codex_local", selectedTarget).defaultRunner).toBe(expected === "paperclip_runner" ? "paperclip" : "legacy");
const result = await resolveNewAgentRunnerForCompany({} as never, "company", { adapterType: "codex_local", defaultEnvironmentId: "sandbox" });
expect(result.adapterType).toBe(expected);
expect(state.ssh).not.toHaveBeenCalled();
expect(state.resolveEnvironment).not.toHaveBeenCalled();
if (expected === "codex_local") await expect(resolveNewAgentRunnerForCompany({} as never, "company", { adapterType: "codex_local", runner: "paperclip", defaultEnvironmentId: "sandbox" })).rejects.toMatchObject({ status: 422 });
});
it("does not infer a sandbox image architecture from its driver", async () => {
state.getEnvironment.mockResolvedValue({ id: "sandbox", driver: "sandbox", status: "active", config: { provider: "daytona" } });
const result = await resolveNewAgentRunnerForCompany({} as never, "company", { adapterType: "codex_local", defaultEnvironmentId: "sandbox" });
expect(result.adapterType).toBe("codex_local"); expect(state.ssh).not.toHaveBeenCalled();
expect(state.sandboxQualification).toHaveBeenCalledWith({}, expect.objectContaining({ id: "sandbox" }), "company");
await expect(resolveNewAgentRunnerForCompany({} as never, "company", { adapterType: "codex_local", runner: "paperclip", defaultEnvironmentId: "sandbox" })).rejects.toMatchObject({ status: 422, details: { code: "agent_runner_unavailable" } });
});
});
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(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");
expect(agentHarnessType("paperclip_runner", { provider: "acpx", acpxAgent: "unknown" })).toBe("acpx:unknown");
expect(agentRunner("codex_local")).toBe("legacy");
expect(agentRunner("paperclip_runner")).toBe("paperclip");
});
it("qualifies only the Codex daemon targets shipped in public server packages", () => {
for (const { platform, architecture } of qualifiedTargets) expect(paperclipRunnerSupportsPlatform("codex_local", platform, architecture)).toBe(true);
for (const architecture of ["x64", "arm64"]) {
expect(paperclipRunnerSupportsPlatform("codex_local", "linux", architecture, "glibc")).toBe(true);
expect(paperclipRunnerSupportsPlatform("codex_local", "linux", architecture, "musl")).toBe(false);
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);
});
it("keeps auto out of saved update defaults and validates request intent", () => {
expect(createAgentSchema.parse({ name: "Codex", adapterType: "codex_local" }).runner).toBeUndefined();
expect(updateAgentSchema.parse({ title: "same execution" })).not.toHaveProperty("runner");
expect(testAdapterEnvironmentSchema.parse({ runner: "legacy" }).runner).toBe("legacy");
expect(createAgentSchema.safeParse({ name: "Codex", adapterType: "codex_local", runner: "other" }).success).toBe(false);
});
});
@@ -0,0 +1,164 @@
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 { findActiveServerAdapter, hasActiveAdapterOverride, waitForExternalAdapters } from "../adapters/registry.js";
import { getDisabledAdapterTypes } from "./adapter-plugin-store.js";
import { 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";
import { localAgentRunnerTarget } from "../lib/agent-runner-platform.js";
export type RunnerTarget = { driver: string; platform?: string; architecture?: string; libc?: "glibc" | "musl" | "unknown"; glibcVersion?: string };
export const AGENT_RUNNER_SSH_PLATFORM_COMMAND = "uname -s; uname -m; if paperclip_glibc_version=$(getconf GNU_LIBC_VERSION 2>/dev/null) && [ -n \"$paperclip_glibc_version\" ]; then printf 'glibc\\n%s\\n' \"$paperclip_glibc_version\"; else case \"$(ldd --version 2>&1)\" in *musl*) printf 'musl\\n' ;; *) printf 'unknown\\n' ;; esac; fi";
export interface AgentRunnerSelection {
adapterType?: string | null;
adapterConfig?: Record<string, unknown>;
runner?: AgentRunnerChoice;
target?: RunnerTarget;
}
export function agentRunnerAvailability(harness: string, target: RunnerTarget = localAgentRunnerTarget()): AgentRunnerAvailability {
const disabled = new Set(getDisabledAdapterTypes());
const supported = harness === "codex_local"
&& !disabled.has(harness) && !disabled.has("paperclip_runner")
&& !!findActiveServerAdapter(harness) && !!findActiveServerAdapter("paperclip_runner")
&& !hasActiveAdapterOverride(harness) && !hasActiveAdapterOverride("paperclip_runner")
&& isEnvironmentDriverSupportedForAdapter("paperclip_runner", 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" };
}
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 });
}
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 (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 (!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" });
if (config.codexPermissionMode !== undefined && config.codexPermissionMode !== "never") incompatible(["codexPermissionMode"]);
if (config.lifecycleMode !== undefined && config.lifecycleMode !== "per_turn") incompatible(["lifecycleMode"]);
for (const key of ["provider", "codexPermissionMode", "opencodePermissionMode", "acpxPermissionMode", "lifecycleMode", "idleTimeoutMs"]) delete config[key];
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"
? "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) };
const aliasSources = new Map<string, string>();
for (const [canonical, alias] of [
["agentCommand", "acpAgentCommand"], ["stateDir", "acpStateDir"],
["mode", "acpMode"], ["permissionMode", "acpPermissionMode"],
["nonInteractivePermissions", "acpNonInteractivePermissions"],
]) {
if (config[alias] === undefined) continue;
const canonicalValue = typeof config[canonical] === "string" ? config[canonical].trim() : "";
const aliasValue = typeof config[alias] === "string" ? config[alias].trim() : "";
if (canonicalValue || aliasValue) {
config[canonical] = canonicalValue || aliasValue;
if (!canonicalValue) aliasSources.set(canonical, alias);
} else if (config[alias] !== null && typeof config[alias] !== "string") incompatible([alias]);
else if (typeof config[canonical] === "string") delete config[canonical];
delete config[alias];
}
if (config.acpWarmHandleIdleMs !== undefined) {
if (config.warmHandleIdleMs == null && config.acpWarmHandleIdleMs != null) {
config.warmHandleIdleMs = config.acpWarmHandleIdleMs;
aliasSources.set("warmHandleIdleMs", "acpWarmHandleIdleMs");
}
delete config.acpWarmHandleIdleMs;
}
const unsupported = ["agentCommand", "stateDir", "warmHandleIdleMs", "extraArgs", "args", "search", "fastMode", "variant", "filesystemScope", "filesystemExtraPaths", "filesystemSandboxCommand", "networkScope", "networkAllowlist", "permissionMode", "maxTurns", "disableWebSearch", "outputInactivityTimeoutMs", "terminalResultCleanupGraceMs"]
.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 !== "codex") unsupported.push("command");
if (config.mode && config.mode !== "persistent") unsupported.push("mode");
if (config.engine && config.engine !== "cli" && config.engine !== "acp") unsupported.push("engine");
if (config.nonInteractivePermissions && config.nonInteractivePermissions !== "deny") unsupported.push("nonInteractivePermissions");
if (config.dangerouslyBypassApprovalsAndSandbox === false) unsupported.push("dangerouslyBypassApprovalsAndSandbox");
if (config.dangerouslyBypassSandbox === false) unsupported.push("dangerouslyBypassSandbox");
if (unsupported.length) incompatible(unsupported.map(field => aliasSources.get(field) ?? field));
const env = config.env && typeof config.env === "object" && !Array.isArray(config.env)
? config.env as Record<string, unknown> : {};
const network = env.PAPERCLIP_CODEX_ACP_NETWORK_ACCESS;
const binding = network && typeof network === "object" && !Array.isArray(network)
? network as Record<string, unknown> : null;
if (config.engine !== "cli" && (network === "false"
|| (binding?.type === "plain" && binding.value === "false")
|| binding?.type === "secret_ref" || binding?.type === "user_secret_ref")) {
incompatible(["env.PAPERCLIP_CODEX_ACP_NETWORK_ACCESS"]);
}
const effort = config.modelReasoningEffort ?? config.reasoningEffort ?? config.effort;
for (const key of ["engine", "command", "extraArgs", "args", "agentCommand", "mode", "nonInteractivePermissions", "stateDir", "warmHandleIdleMs", "dangerouslyBypassApprovalsAndSandbox", "dangerouslyBypassSandbox", "search", "fastMode", "reasoningEffort", "effort"]) delete config[key];
if (config.model !== undefined && typeof config.model !== "string") incompatible(["model"]);
config = { ...config, provider: "codex", model: typeof config.model === "string" && config.model.trim() ? config.model.trim() : PAPERCLIP_RUNNER_DEFAULT_MODELS.codex, codexPermissionMode: config.codexPermissionMode ?? "never", lifecycleMode: config.lifecycleMode ?? "per_turn", ...(effort === undefined ? {} : { modelReasoningEffort: effort }) };
try { resolvePaperclipRunnerNativeProviderInput({ backend: "codex_app_server", adapterConfig: config }); }
catch (error) {
if (error instanceof PaperclipRunnerProviderProfileError) throw unprocessable(error.message, { code: error.code });
throw error;
}
return { adapterType: "paperclip_runner", adapterConfig: config };
}
export async function resolveNewAgentRunnerForCompany(db: Db, companyId: string, input: AgentRunnerSelection & { defaultEnvironmentId?: string | null }) {
if (agentHarnessType(input.adapterType ?? "process", input.adapterConfig) !== "codex_local"
|| 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 });
}
export async function resolveAgentRunnerTargetForCompany(db: Db, companyId: string, defaultEnvironmentId?: string | null): Promise<RunnerTarget> {
await waitForExternalAdapters();
const { instanceSettingsService } = await import("./instance-settings.js");
const { environmentService } = await import("./environments.js");
const settings = await instanceSettingsService(db).get();
const envs = environmentService(db);
const environmentId = defaultEnvironmentId ?? settings.defaultEnvironmentId;
let environment = environmentId ? await envs.getById(environmentId) : null;
if (settings.experimental?.enableManagedSandboxOnly && (!environment || environment.driver === "local")) {
environment = await envs.findManagedSandboxEnvironment(companyId);
if (!environment) throw unprocessable("The managed sandbox is unavailable. Restore Paperclip Computer and retry.", { code: "managed_sandbox_unavailable" });
}
if (settings.general?.executionMode === "kubernetes") {
environment = await envs.findKubernetesEnvironment(companyId);
if (!environment) return { driver: "sandbox" };
}
if (environmentId && !environment) throw unprocessable("Environment not found.");
if (environment) {
const boundCompanyIds = await envs.listBoundCompanyIds(environment.id);
if (boundCompanyIds.length > 0 && !boundCompanyIds.includes(companyId)) throw forbidden("The selected environment belongs to another company.", { code: "environment_company_mismatch" });
if (environment.status === "archived") throw unprocessable("Environment is archived.");
if (environment.driver === "sandbox" && environment.config?.provider === "fake") throw unprocessable("The fake sandbox provider cannot execute runs.");
}
const driver = environment?.driver ?? "local";
let target: RunnerTarget = driver === "local" ? localAgentRunnerTarget()
: { driver };
if (driver === "sandbox" && environment) {
const { readSandboxPlatformQualification } = await import("./environment-probe.js");
const qualified = await readSandboxPlatformQualification(db, environment, companyId);
if (qualified) target = { driver, ...qualified };
}
if (driver === "ssh" && environment && agentRunnerAvailability("codex_local", target).defaultRunner === "paperclip") {
const parsed = await resolveEnvironmentDriverConfigForRuntime(db, companyId, environment);
if (parsed.driver !== "ssh") throw unprocessable("The selected SSH environment is unavailable.");
let output: string;
try { output = (await runSshCommand(parsed.config, AGENT_RUNNER_SSH_PLATFORM_COMMAND, { timeoutMs: 10_000 })).stdout; }
catch { throw unprocessable("Could not check the SSH host's platform. Check its connection and retry, or select Legacy runner in Advanced.", { code: "agent_runner_platform_probe_failed" }); }
const [os = "", arch = "", libc, glibcVersion] = output.trim().split(/\r?\n/);
target = { driver, platform: os === "Linux" ? "linux" : os === "Darwin" ? "darwin" : "unknown", architecture: arch === "x86_64" ? "x64" : ["aarch64", "arm64"].includes(arch) ? "arm64" : arch, ...(os === "Linux" && libc !== undefined ? { libc: libc === "glibc" || libc === "musl" ? libc : "unknown", ...(libc === "glibc" && glibcVersion !== undefined ? { glibcVersion } : {}) } : {}) };
}
return target;
}
+57 -20
View File
@@ -8,7 +8,7 @@ import { readPaperclipSkillSyncPreference, writePaperclipSkillSyncPreference } f
import { and, desc, eq, ne } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import { activityLog, agents, builtInManagedResources, companies, issueThreadInteractions, issues, routines, routineTriggers } from "@paperclipai/db";
import { syncRoutineVariablesWithTemplate } from "@paperclipai/shared";
import { agentHarnessType, agentRunner, syncRoutineVariablesWithTemplate, type AgentRunnerChoice } from "@paperclipai/shared";
import type { Agent, Approval, CompanySkill, PermissionKey, Routine, RoutineTrigger, RoutineVariable } from "@paperclipai/shared";
import { conflict, HttpError, notFound, unprocessable } from "../errors.js";
import { logActivity } from "./activity-log.js";
@@ -18,6 +18,7 @@ import { agentInstructionRevisionService } from "./agent-instruction-revisions.j
import type { AuthorizationActor } from "./authorization.js";
import { agentInstructionsBundleMode, agentInstructionsService } from "./agent-instructions.js";
import { agentService } from "./agents.js";
import { resolveNewAgentRunnerForCompany } from "./agent-runner-selection.js";
import { approvalService } from "./approvals.js";
import {
readBuiltInAgentMarker,
@@ -67,6 +68,7 @@ export interface BuiltInAgentState {
}
export interface BuiltInAgentProvisionInput {
runner?: AgentRunnerChoice;
adapterType?: string;
adapterConfig?: Record<string, unknown>;
budgetMonthlyCents?: number;
@@ -690,8 +692,8 @@ function selectPreferredAdapterType(
return selected?.adapterType ?? fallback;
}
function assertAdapterAllowed(definition: BuiltInAgentDefinition, adapterType: string) {
if (definition.allowedAdapterTypes && !definition.allowedAdapterTypes.includes(adapterType)) {
function assertAdapterAllowed(definition: BuiltInAgentDefinition, adapterType: string, adapterConfig: Record<string, unknown> = {}) {
if (definition.allowedAdapterTypes && !definition.allowedAdapterTypes.includes(agentHarnessType(adapterType, adapterConfig))) {
throw unprocessable(`Adapter type ${adapterType} is not allowed for built-in agent ${definition.key}`, {
code: "built_in_agent_adapter_not_allowed",
key: definition.key,
@@ -714,11 +716,11 @@ function hasCompleteAdapterConfig(adapterType: string, adapterConfig: unknown) {
return nonEmptyString(adapterConfig.model);
}
export function deriveBuiltInAgentStatus(agent: Pick<Agent, "adapterType" | "adapterConfig" | "status" | "pausedAt"> | null): BuiltInAgentStatus {
export function deriveBuiltInAgentStatus(agent: Pick<Agent, "adapterType" | "adapterConfig" | "status" | "pausedAt"> & { metadata?: Agent["metadata"] } | null): BuiltInAgentStatus {
if (!agent) return "not_provisioned";
if (agent.status === "pending_approval") return "pending_approval";
if (agent.status === "paused" || agent.pausedAt) return "paused";
return hasCompleteAdapterConfig(agent.adapterType, agent.adapterConfig) ? "ready" : "needs_setup";
return agent.metadata?.paperclipBuiltInSetupRequired !== true && hasCompleteAdapterConfig(agent.adapterType, agent.adapterConfig) ? "ready" : "needs_setup";
}
function builtInMetadata(definition: BuiltInAgentDefinition, existing?: Record<string, unknown> | null) {
@@ -730,8 +732,9 @@ function builtInMetadata(definition: BuiltInAgentDefinition, existing?: Record<s
function definitionPatch(definition: BuiltInAgentDefinition, input: BuiltInAgentProvisionInput = {}) {
const adapterType = input.adapterType ?? defaultAdapterType(definition);
assertAdapterAllowed(definition, adapterType);
assertAdapterAllowed(definition, adapterType, input.adapterConfig);
return {
runner: input.runner,
name: definition.displayName,
role: definition.defaultRole,
title: definition.defaultTitle ?? null,
@@ -753,7 +756,7 @@ async function assertKnownBuiltInAgentModel(
const model = typeof adapterConfig.model === "string" ? adapterConfig.model.trim() : "";
if (!model || !hasCompleteAdapterConfig(adapterType, adapterConfig)) return;
const models = await listAdapterModels(adapterType);
const models = await listAdapterModels(agentHarnessType(adapterType, adapterConfig));
if (models.length === 0 || models.some((candidate) => candidate.id === model)) return;
throw unprocessable(`Model "${model}" is not available for adapter ${adapterType}.`, {
@@ -776,7 +779,7 @@ function builtInAgentNotConfiguredError(state: BuiltInAgentState) {
}
function hasProvisionSetupInput(input: BuiltInAgentProvisionInput) {
return input.adapterType !== undefined || input.adapterConfig !== undefined || input.budgetMonthlyCents !== undefined;
return input.adapterType !== undefined || input.adapterConfig !== undefined || input.runner !== undefined || input.budgetMonthlyCents !== undefined;
}
function rowIsBuiltInAgent(row: typeof agents.$inferSelect, key: string) {
@@ -885,13 +888,13 @@ export function builtInAgentService(db: Db) {
.from(agents)
.where(and(eq(agents.companyId, companyId), ne(agents.status, "terminated")));
const candidate = rows.find((row) =>
definition.allowedAdapterTypes?.includes(row.adapterType)
definition.allowedAdapterTypes?.includes(agentHarnessType(row.adapterType, row.adapterConfig))
&& hasCompleteAdapterConfig(row.adapterType, row.adapterConfig)
);
if (!candidate) return input;
return {
...input,
adapterType: candidate.adapterType,
adapterType: agentHarnessType(candidate.adapterType, candidate.adapterConfig),
adapterConfig: {},
};
}
@@ -1737,11 +1740,13 @@ export function builtInAgentService(db: Db) {
&& !existingPendingApproval
&& input.adapterType === undefined
&& input.adapterConfig === undefined
&& hasCompleteAdapterConfig(existing.adapterType, existing.adapterConfig),
&& input.runner === undefined,
);
const resolvedInput = existingPendingApproval || preserveExistingAdapter
? input
: await defaultProvisionInput(companyId, definition, input);
: existing && input.runner !== undefined && input.adapterType === undefined && input.adapterConfig === undefined
? { ...input, adapterType: existing.adapterType, adapterConfig: existing.adapterConfig }
: await defaultProvisionInput(companyId, definition, input);
if (!existingPendingApproval && !preserveExistingAdapter) {
await assertKnownBuiltInAgentModel(definition, resolvedInput);
}
@@ -1751,12 +1756,32 @@ export function builtInAgentService(db: Db) {
};
if (
!existingPendingApproval
&& (resolvedInput.adapterType !== undefined || resolvedInput.adapterConfig !== undefined)
&& (resolvedInput.adapterType !== undefined || resolvedInput.adapterConfig !== undefined || resolvedInput.runner !== undefined)
) {
const adapterType = resolvedInput.adapterType ?? existing.adapterType;
assertAdapterAllowed(definition, adapterType);
patch.adapterType = adapterType;
patch.adapterConfig = resolvedInput.adapterConfig ?? existing.adapterConfig;
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
? agentRunner(existing.adapterType) : undefined);
const retainedNative = sameCodexHarness && runner === "paperclip" && existing.adapterType === "paperclip_runner";
const resolvedAdapterConfig = { ...adapterConfig };
if (retainedNative) {
for (const field of ["provider", "acpxAgent", "acpxMode"]) {
if (existing.adapterConfig[field] !== undefined) resolvedAdapterConfig[field] = existing.adapterConfig[field];
}
}
Object.assign(patch, runner !== undefined || existing.metadata?.paperclipBuiltInSetupRequired === true
? await resolveNewAgentRunnerForCompany(db, companyId, {
adapterType: retainedNative ? existing.adapterType : adapterType,
adapterConfig: resolvedAdapterConfig, runner,
defaultEnvironmentId: existing.defaultEnvironmentId,
})
: { adapterType, adapterConfig });
if (hasCompleteAdapterConfig(patch.adapterType ?? existing.adapterType, patch.adapterConfig ?? existing.adapterConfig)) {
patch.metadata = { ...patch.metadata, paperclipBuiltInSetupRequired: false };
}
}
if (!existingPendingApproval && resolvedInput.budgetMonthlyCents !== undefined) {
patch.budgetMonthlyCents = resolvedInput.budgetMonthlyCents;
@@ -1795,7 +1820,13 @@ export function builtInAgentService(db: Db) {
: null,
pausedAt: definition.defaultStatus === "paused" ? new Date() : null,
reportsTo,
metadata: builtInMetadata(definition),
metadata: {
...builtInMetadata(definition),
paperclipBuiltInSetupRequired: !hasCompleteAdapterConfig(
definitionPatch(definition, resolvedInput).adapterType,
definitionPatch(definition, resolvedInput).adapterConfig,
),
},
runtimeConfig: definition.defaultRuntimeConfig ?? {},
permissions: definition.defaultPermissions ?? {},
spentMonthlyCents: 0,
@@ -1860,7 +1891,7 @@ export function builtInAgentService(db: Db) {
};
}
const providesAdapterSetup = input.adapterType !== undefined || input.adapterConfig !== undefined;
const providesAdapterSetup = input.adapterType !== undefined || input.adapterConfig !== undefined || input.runner !== undefined;
// A built-in row that has never completed adapter setup (incomplete
// config, i.e. `needs_setup`) is still first-time configuration, not a
@@ -1870,7 +1901,7 @@ export function builtInAgentService(db: Db) {
// adapterConfig is still empty. Completing that setup applies directly, as
// it does when board approval is not required, instead of dead-ending on a
// fresh board-approval requirement the operator can never satisfy.
if (providesAdapterSetup && !hasCompleteAdapterConfig(existing.adapterType, existing.adapterConfig)) {
if (providesAdapterSetup && (existing.metadata?.paperclipBuiltInSetupRequired === true || !hasCompleteAdapterConfig(existing.adapterType, existing.adapterConfig))) {
return { state: await ensure(companyId, key, input), approval: null };
}
@@ -1896,7 +1927,13 @@ export function builtInAgentService(db: Db) {
...definitionPatch(definition, input),
status: "pending_approval",
reportsTo,
metadata: builtInMetadata(definition),
metadata: {
...builtInMetadata(definition),
paperclipBuiltInSetupRequired: !hasCompleteAdapterConfig(
definitionPatch(definition, input).adapterType,
definitionPatch(definition, input).adapterConfig,
),
},
runtimeConfig: definition.defaultRuntimeConfig ?? {},
permissions: definition.defaultPermissions ?? {},
spentMonthlyCents: 0,
+46 -4
View File
@@ -1,5 +1,6 @@
import { createAgentLifecycle } from "./agent-lifecycle.js";
import { agentAppearanceSchema } from "@paperclipai/shared";
import { agentAppearanceSchema, agentHarnessType, agentRunner, type AgentRunnerChoice } from "@paperclipai/shared";
import { resolveNewAgentRunnerForCompany } from "./agent-runner-selection.js";
import { createHash, randomUUID } from "node:crypto";
import { promises as fs } from "node:fs";
import { execFile } from "node:child_process";
@@ -3235,6 +3236,12 @@ function buildManifestFromPackageFiles(
const slug = asString(frontmatter.slug) ?? fallbackSlug;
const extension = isPlainRecord(paperclipAgents[slug]) ? paperclipAgents[slug] : {};
const extensionAdapter = isPlainRecord(extension.adapter) ? extension.adapter : null;
const runner = extensionAdapter?.runner;
if (runner !== undefined && (typeof runner !== "string" || !["auto", "paperclip", "legacy"].includes(runner))) {
throw unprocessable(`Invalid runner choice for agent ${slug}. Use auto, paperclip, or legacy.`, {
code: "agent_runner_choice_invalid", field: `agents.${slug}.adapter.runner`,
});
}
const extensionRuntime = isPlainRecord(extension.runtime) ? extension.runtime : null;
const extensionPermissions = isPlainRecord(extension.permissions) ? extension.permissions : null;
const extensionPermissionGrants = normalizePortablePermissionGrants(extension.permissionGrants);
@@ -3258,6 +3265,7 @@ function buildManifestFromPackageFiles(
reportsToSlug: asString(frontmatter.reportsTo) ?? asString(extension.reportsTo),
reportsToExistingAgentId: asString(extension.reportsToExistingAgentId),
reportsToExistingAgentSlug: asString(extension.reportsToExistingAgentSlug),
runner: runner as AgentRunnerChoice | undefined,
adapterType: asString(extensionAdapter?.type) ?? "process",
adapterConfig,
runtimeConfig,
@@ -3654,8 +3662,14 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
adapterConfig: Record<string, unknown>,
desiredSkills: string[],
mode: ImportMode,
runner?: AgentRunnerChoice,
defaultEnvironmentId?: string | null,
) {
const effectiveAdapterType = assertKnownImportAdapterType(adapterType);
const resolved = await resolveNewAgentRunnerForCompany(db, companyId, {
adapterType, adapterConfig, runner, defaultEnvironmentId,
});
adapterConfig = resolved.adapterConfig;
const effectiveAdapterType = assertKnownImportAdapterType(resolved.adapterType);
if (mode === "agent_safe" && IMPORT_FORBIDDEN_ADAPTER_TYPES.has(effectiveAdapterType)) {
throw forbidden(`Adapter type "${effectiveAdapterType}" is not allowed in safe imports`);
}
@@ -4284,6 +4298,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
capabilities: agent.capabilities ?? null,
adapter: {
type: agent.adapterType,
runner: agentRunner(agent.adapterType),
config: portableAdapterConfig,
},
runtime: portableRuntimeConfig,
@@ -5285,7 +5300,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
"OpenAI Dot is experimental and disabled on this instance.",
{ code: "paperclip_runner_dot_disabled" },
);
} else if (experimental.enableNativeRunner !== true) {
} else if (config.provider !== "codex" && config.provider !== undefined && experimental.enableNativeRunner !== true) {
throw unprocessable(
"Paperclip Runner is experimental and disabled on this instance.",
{ code: "paperclip_runner_rollout_disabled" },
@@ -5615,12 +5630,38 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
: { ...manifestAgent.adapterConfig } as Record<string, unknown>;
const desiredSkills = (manifestAgent.skills ?? []).map((skillRef) => desiredSkillRefMap.get(skillRef) ?? skillRef);
const existingImportAgent = planAgent.action === "update" && planAgent.existingAgentId
? await agents.getById(planAgent.existingAgentId) : null;
let selectedAdapterType = adapterOverride?.adapterType ?? manifestAgent.adapterType;
const selectedHarness = agentHarnessType(selectedAdapterType, baseAdapterConfig);
let runner = adapterOverride?.runner;
if (runner === undefined && adapterOverride?.adapterType !== "paperclip_runner") {
if (selectedHarness === agentHarnessType(manifestAgent.adapterType, manifestAgent.adapterConfig)) {
runner = manifestAgent.runner;
}
if (runner === undefined && existingImportAgent
&& selectedHarness === agentHarnessType(existingImportAgent.adapterType, existingImportAgent.adapterConfig)) {
runner = agentRunner(existingImportAgent.adapterType);
}
}
const retainedNative = runner === "paperclip" && selectedHarness === "codex_local"
? [manifestAgent, existingImportAgent].find((agent) => agent?.adapterType === "paperclip_runner"
&& agentHarnessType(agent.adapterType, agent.adapterConfig) === selectedHarness)
: undefined;
if (retainedNative) {
selectedAdapterType = "paperclip_runner";
for (const field of ["provider", "acpxAgent", "acpxMode"]) {
if (retainedNative.adapterConfig[field] !== undefined) baseAdapterConfig[field] = retainedNative.adapterConfig[field];
}
}
const normalizedAdapter = await prepareImportedAgentAdapter(
targetCompany.id,
adapterOverride?.adapterType ?? manifestAgent.adapterType,
selectedAdapterType,
baseAdapterConfig,
desiredSkills,
mode,
runner,
existingImportAgent?.defaultEnvironmentId,
);
const patch = {
name: planAgent.plannedName,
@@ -5700,6 +5741,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
let created = await agentsLifecycle.requestHire(targetCompany.id, {
...patch,
runner: agentRunner(patch.adapterType),
...automationPausePatch,
status: pauseAutomations ? "paused" : "idle",
}, { createdByUserId: actorUserId });
@@ -36,7 +36,7 @@ const support = await getEmbeddedPostgresTestSupport();
const companyId = randomUUID(), agentId = randomUUID(), issueId = randomUUID();
const sourceRunId = randomUUID(), successorRunId = randomUUID();
const commentId: string = randomUUID();
await db.insert(companies).values({ id: companyId, name: "Explicit turn", defaultResponsibleUserId: "board", issuePrefix: `E${companyId.slice(0, 6)}` });
await db.insert(companies).values({ id: companyId, name: "Explicit turn", defaultResponsibleUserId: "board", issuePrefix: `E${companyId.replaceAll("-", "")}` });
await db.insert(agents).values({ id: agentId, companyId, name: "Native", role: "engineer", adapterType: "paperclip_runner", status: "idle", runtimeConfig: { heartbeat: { maxConcurrentRuns: 1 } } });
await db.insert(issues).values({ id: issueId, companyId, title: "Deploy", status: "blocked", assigneeAgentId: agentId });
await db.insert(heartbeatRuns).values({ id: sourceRunId, companyId, agentId,
@@ -43,10 +43,47 @@ describe("resolveNativeRuntimeMode", () => {
}
});
it("rejects a fresh Paperclip Runner start while the rollout flag is disabled", () => {
it("admits fresh native Codex when the experimental rollout is disabled", () => {
expect(resolveNativeRuntimeMode({
...eligible,
enabled: false,
})).toMatchObject({ kind: "native", profile: { backend: "codex_app_server" } });
});
it.each([
[false, false],
[false, true],
[true, false],
])("retains the Grok Bot gate with runner enabled=%s and Grok enabled=%s", (enabled, grokEnabled) => {
expect(() => resolveNativeRuntimeMode({
...eligible,
enabled,
grokEnabled,
adapterConfig: {
provider: "grok_bot",
externalBindingId: "00000000-0000-4000-8000-000000000001",
allowUnmeteredProvider: true,
},
})).toThrow(expect.objectContaining({ code: "paperclip_runner_grok_disabled" }));
});
it("admits a bound Grok Bot when its existing experimental gates are enabled", () => {
expect(resolveNativeRuntimeMode({
...eligible,
grokEnabled: true,
adapterConfig: {
provider: "grok_bot",
externalBindingId: "00000000-0000-4000-8000-000000000001",
allowUnmeteredProvider: true,
},
})).toMatchObject({ kind: "native", profile: { backend: "grok_bot_mcp" } });
});
it("still rejects experimental providers while the rollout flag is disabled", () => {
expect(() => resolveNativeRuntimeMode({
...eligible,
enabled: false,
adapterConfig: { provider: "opencode", model: "openrouter/deepseek/deepseek-v4-flash-0731" },
})).toThrow(expect.objectContaining({
code: "paperclip_runner_rollout_disabled",
}));
@@ -187,14 +224,17 @@ describe("resolveNativeRuntimeMode", () => {
}));
});
it("keeps a persisted active run native while the global flag rejects a fresh runner start", () => {
const disabled = { ...eligible, enabled: false };
it("keeps a persisted experimental run native while the flag rejects a fresh start", () => {
const disabled = { ...eligible, enabled: false,
adapterConfig: { provider: "opencode", model: "openrouter/deepseek/deepseek-v4-flash-0731" },
};
expect(resolveHeartbeatNativeRuntimeMode({
...disabled,
persisted: {
runtimeMode: "native",
runtimeModeReason: "eligible_opt_in",
runtimeModeResolvedAt: new Date(),
driverKind: "opencode_server",
},
})).toEqual(expect.objectContaining({
kind: "native",
@@ -458,6 +498,7 @@ describe("resolveHeartbeatRuntimeMode compatibility", () => {
...compatibilityInput,
enabled: false,
adapterType: "paperclip_runner",
adapterConfig: { provider: "opencode", model: "opencode/gpt-5" },
})).toThrow(NativeRunnerSelectionError);
});
});
@@ -127,12 +127,11 @@ export function resolveNativeRuntimeMode(input: {
}
throw error;
}
// Dot has its own rollout; enabling it does not opt in other Runner providers.
if (runnerProfile.provider === "openai_dot") {
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 (!input.enabled) {
} else if (runnerProfile.provider !== "codex" && !input.enabled) {
throw ineligible(
"paperclip_runner_rollout_disabled",
"Paperclip Runner is experimental and disabled on this instance.",
@@ -0,0 +1,528 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { chmod, lstat, mkdir, mkdtemp, readFile, readlink, readdir, realpath, rm, stat, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, posix } from "node:path";
import { ADAPTER_AUTH_MISSING_CHECK_CODE } from "@paperclipai/shared";
import * as localCredentialFile from "../local-ai-credential-file.js";
import * as codexCredentials from "@paperclipai/adapter-codex-local/server";
const { prepareIsolatedCodexHome } = await import(new URL("../../../../packages/paperclip-runner/src/drivers/runtime-context-materializer.ts", import.meta.url).href);
const { execute, nativeProbe, selectedRunner, selectedRemoteRunner, sshExecute, sshRunner, nativeArtifacts, remoteLauncher, registerPrp, ingressConnect } = vi.hoisted(() => ({ execute: vi.fn(), nativeProbe: vi.fn(), selectedRunner: vi.fn(), selectedRemoteRunner: vi.fn(), sshExecute: vi.fn(), sshRunner: vi.fn(), nativeArtifacts: vi.fn(), remoteLauncher: vi.fn(), registerPrp: vi.fn(), ingressConnect: vi.fn() }));
const nativeProbeRuntimeDirectories = vi.hoisted(() => [] as string[]);
vi.mock("node:fs/promises", async original => {
const filesystem = await original<typeof import("node:fs/promises")>();
return { ...filesystem, mkdtemp: async (...args: Parameters<typeof filesystem.mkdtemp>) => {
const directory = await filesystem.mkdtemp(...args);
if (typeof args[0] === "string" && args[0].endsWith("paperclip-native-setup-") && typeof directory === "string") nativeProbeRuntimeDirectories.push(directory);
return directory;
} };
});
vi.mock("@paperclipai/adapter-utils/execution-target", () => ({ runAdapterExecutionTargetShellCommand: execute }));
vi.mock("./native-ssh-command-runner.js", () => ({ createNativeSshCommandRunner: sshRunner }));
vi.mock("../../realtime/runner-prp-ws.js", () => ({ registerRunnerPrpAuthority: registerPrp }));
vi.mock("../../realtime/runner-prp-outbound.js", () => ({ connectRunnerPrpIngress: ingressConnect }));
vi.mock("./native-codex-runner.js", () => ({ resolvePaperclipRunnerBinary: selectedRunner, resolveRemotePaperclipRunnerBinary: selectedRemoteRunner }));
vi.mock("../../vendor/paperclip-runner/index.js", async original => ({ ...await original<typeof import("../../vendor/paperclip-runner/index.js")>(), probeNativeRunnerEnvironment: nativeProbe }));
vi.mock("./native-session-executor.js", async original => ({ ...await original<typeof import("./native-session-executor.js")>(), createRemoteNativeArtifactPreparation: nativeArtifacts, createRemoteRunnerProcessLauncher: remoteLauncher }));
import { testNativeRunnerAuthentication } from "./setup-readiness.js";
const context = {
companyId: "company", adapterType: "paperclip_runner", config: { externallySandboxed: false, env: { PAPERCLIP_RUNNER_EXTERNAL_SANDBOX: "false" } },
executionTarget: { kind: "remote" as const, transport: "sandbox" as const, providerKey: "test", remoteCwd: "/workspace", runner: { execute: vi.fn() } },
};
describe("Codex selected native account verification", () => {
const receipt = (provider: "codex", model: string | null) => ({ provider, effectiveModel: model, providerDriver: "codex_app_server", helloProbePassed: true, cleanupConfirmed: true });
beforeEach(() => {
execute.mockReset(); nativeProbe.mockReset(); nativeProbeRuntimeDirectories.splice(0);
sshExecute.mockReset().mockResolvedValue({ exitCode: 0, timedOut: false, stdout: "", stderr: "" });
sshRunner.mockReset().mockReturnValue({ execute: sshExecute });
nativeArtifacts.mockReset().mockReturnValue({ prepare: vi.fn().mockResolvedValue(undefined) });
remoteLauncher.mockReset().mockReturnValue(vi.fn());
registerPrp.mockReset().mockImplementation(async () => ({ release: vi.fn() }));
vi.stubEnv("PAPERCLIP_RUNNER_PUBLIC_URL", "wss://paperclip.example.test");
vi.stubEnv("PAPERCLIP_RUNNER_CA_BUNDLE_PATH", "");
vi.stubEnv("PAPERCLIP_RUNNER_REMOTE_CODEX_PATH", "");
vi.stubEnv("PAPERCLIP_RUNNER_REMOTE_CODEX_NPM_SPEC", "");
selectedRunner.mockReset().mockReturnValue("/fixture/bin/paperclip-runnerd");
selectedRemoteRunner.mockReset().mockResolvedValue("/fixture/bin/paperclip-runnerd");
vi.stubEnv("PAPERCLIP_RUNNER_REMOTE_PROVIDER_PACK_PATH", "");
vi.stubEnv("PAPERCLIP_RUNNER_REMOTE_BINARY_PATH", "");
});
afterEach(async () => {
vi.unstubAllGlobals();
vi.unstubAllEnvs();
for (const root of nativeProbeRuntimeDirectories) await rm(root, { recursive: true, force: true });
});
it.each([["codex", "gpt-6.1-sol", "OPENAI_API_KEY"]] as const)("requires the native %s host and only the selected credential", async (provider, model, key) => {
vi.stubEnv(key, "ambient-account");
vi.stubEnv("ANTHROPIC_API_KEY", "ambient-anthropic");
nativeProbe.mockImplementation(async input => { await input.onCleanupConfirmed(); return receipt(provider, model); });
const result = await testNativeRunnerAuthentication({ companyId: "company", adapterType: "paperclip_runner", config: { externallySandboxed: true, env: { [key]: "bound-account", PAPERCLIP_RUNNER_EXTERNAL_SANDBOX: "true" }, modelReasoningEffort: "low" } }, provider, model);
expect(result.status).toBe("pass");
const input = nativeProbe.mock.calls[0][0];
expect(input).toMatchObject({ provider, model, timeoutMs: 45_000, environment: { [key]: "bound-account" } });
expect(input.transportOptions.runnerBinary).toBe("/fixture/bin/paperclip-runnerd");
expect(input.transportOptions.externallySandboxed).toBe(false);
expect(selectedRunner).toHaveBeenCalledOnce();
expect(input.environment.ANTHROPIC_API_KEY).toBeUndefined();
if (provider === "codex") expect(input.reasoningEffort).toBe("low");
else expect(input.reasoningEffort).toBeUndefined();
await expect(stat(input.runtimeDirectory)).rejects.toThrow();
expect(execute).not.toHaveBeenCalled();
});
it("rejects a missing local daemon before creating private credential staging", async () => {
const home = await realpath(await mkdtemp(join(tmpdir(), "native-preflight-daemon-")));
const auth = '{"tokens":{"account_id":"fixture-account","access_token":"fixture-access"}}';
const readCredential = vi.spyOn(localCredentialFile, "readLocalAiCredentialFile");
try {
await writeFile(join(home, "account-store.json"), auth, { mode: 0o600 });
await symlink(join(home, "account-store.json"), join(home, "auth.json"));
selectedRunner.mockImplementation(() => { throw new Error("native executable is missing"); });
const result = await testNativeRunnerAuthentication({ companyId: "company", adapterType: "paperclip_runner", managedAiCredentialHome: home, config: {} }, "codex", "gpt-6.1-sol");
expect(result.status).toBe("fail");
expect(result.checks[0].message).toBe("native executable is missing");
expect(readCredential).toHaveBeenCalledOnce();
expect(nativeProbeRuntimeDirectories).toEqual([]);
expect(nativeProbe).not.toHaveBeenCalled();
expect(await readFile(join(home, "account-store.json"), "utf8")).toBe(auth);
expect(await readlink(join(home, "auth.json"))).toBe(join(home, "account-store.json"));
expect((await readdir(home)).sort()).toEqual(["account-store.json", "auth.json"]);
} finally { readCredential.mockRestore(); await rm(home, { recursive: true, force: true }); }
});
it.each(["local", "remote"])("cleans its own private root when %s credential staging fails before probe handoff", async transport => {
const home = await realpath(await mkdtemp(join(tmpdir(), "native-preflight-staging-")));
const auth = '{"tokens":{"account_id":"fixture-account","access_token":"fixture-access"}}';
try {
await writeFile(join(home, "account-store.json"), auth, { mode: 0o600 });
await symlink(join(home, "account-store.json"), join(home, "auth.json"));
await writeFile(join(home, "config.toml"), "x".repeat(1024 * 1024 + 1), { mode: 0o600 });
const selectedContext = transport === "remote" ? sshContext : { companyId: "company", adapterType: "paperclip_runner", config: {} };
const result = await testNativeRunnerAuthentication({ ...selectedContext, managedAiCredentialHome: home }, "codex", "gpt-6.1-sol");
expect(result.status).toBe("fail");
expect(result.checks[0].message).toContain("configuration exceeds its size limit");
expect(nativeProbe).not.toHaveBeenCalled();
expect(sshExecute).not.toHaveBeenCalled();
expect(nativeProbeRuntimeDirectories).toHaveLength(1);
await expect(stat(nativeProbeRuntimeDirectories[0]!)).rejects.toMatchObject({ code: "ENOENT" });
expect(await readFile(join(home, "account-store.json"), "utf8")).toBe(auth);
expect((await stat(join(home, "config.toml"))).size).toBe(1024 * 1024 + 1);
expect(await readlink(join(home, "auth.json"))).toBe(join(home, "account-store.json"));
} finally { await rm(home, { recursive: true, force: true }); }
});
it.each(["host-home", "host-codex-home", "configured-home", "managed-home"] as const)("reuses the task's authorized local Codex home before isolation (%s)", async mode => {
vi.stubEnv("HOME", "/host-home");
vi.stubEnv("CODEX_HOME", mode === "host-home" ? "" : "/host-codex");
vi.stubEnv("OPENAI_API_KEY", "ambient-key-is-not-authorized");
nativeProbe.mockResolvedValue(receipt("codex", "observed-model"));
const config = mode === "configured-home" || mode === "managed-home" ? { env: { CODEX_HOME: "/configured-codex" } } : {};
expect((await testNativeRunnerAuthentication({ companyId: "company", adapterType: "paperclip_runner", config,
...(mode === "managed-home" ? { managedAiCredentialHome: "/managed-codex" } : {}) }, "codex", null)).status).toBe("pass");
const input = nativeProbe.mock.calls[0][0];
expect(input.transportOptions.sourceCodexHome).toBe({ "host-home": "/host-home/.codex", "host-codex-home": "/host-codex", "configured-home": "/configured-codex", "managed-home": "/managed-codex" }[mode]);
expect(input.environment.OPENAI_API_KEY).toBeUndefined();
expect(input.environment.HOME).toBeUndefined();
expect(input.runtimeDirectory).not.toContain("host-home");
});
it.each(["same-account", "another-account", "bound-key", "invalid-handoff"] as const)("preserves the server-selected login refresh before local cleanup (%s)", async mode => {
const home = await realpath(await mkdtemp(join(tmpdir(), "native-local-login-")));
const auth = (account: string, marker: string, age: number) => JSON.stringify({ tokens: { account_id: account, id_token: `id-${marker}`, access_token: `access-${marker}`, refresh_token: `refresh-${marker}` }, last_refresh: new Date(Date.now() - age).toISOString() });
const store = join(home, "account.json");
try {
await writeFile(store, auth("qa-account", "old", 120_000), { mode: 0o600 });
await symlink(store, join(home, "auth.json"));
nativeProbe.mockImplementation(async input => {
const privateHome = join(input.runtimeDirectory, "codex-home");
await mkdir(privateHome, { mode: 0o700 });
const filename = join(privateHome, "auth.json");
await writeFile(filename, auth(mode === "another-account" ? "other-account" : "qa-account", "new", 60_000), { mode: 0o600 });
if (mode === "bound-key") expect(input.onCodexCredentialRefresh).toBeUndefined();
else await input.onCodexCredentialRefresh(mode === "invalid-handoff" ? store : filename);
await input.onCleanupConfirmed();
return receipt("codex", "gpt-6.1-sol");
});
const result = await testNativeRunnerAuthentication({ companyId: "company", adapterType: "paperclip_runner", managedAiCredentialHome: home, config: { env: { CODEX_HOME: home, ...(mode === "bound-key" ? { OPENAI_API_KEY: "selected-key" } : {}) } } }, "codex", "gpt-6.1-sol");
expect(result.status).toBe(mode === "invalid-handoff" ? "fail" : "pass");
expect(JSON.parse(await readFile(store, "utf8")).tokens.access_token).toBe(mode === "same-account" ? "access-new" : "access-old");
expect((await stat(store)).mode & 0o777).toBe(0o600);
expect(await realpath(join(home, "auth.json"))).toBe(store);
expect(JSON.stringify(result)).not.toContain("access-new");
if (mode !== "invalid-handoff") await expect(stat(nativeProbe.mock.calls[0][0].runtimeDirectory)).rejects.toMatchObject({ code: "ENOENT" });
} finally { await rm(home, { recursive: true, force: true }); }
});
const sshContext = { companyId: "company", adapterType: "paperclip_runner", config: {}, executionTarget: {
kind: "remote" as const, transport: "ssh" as const, remoteCwd: "/qa-workspace", spec: { host: "qa-host", username: "qa-user", port: 22, remoteCwd: "/qa-workspace", remoteWorkspacePath: "/qa-workspace", privateKey: null, knownHosts: null, strictHostKeyChecking: true },
} };
it.each(["local", "remote"].flatMap(transport => ["configured", "operator"].flatMap(source => [false, true].map(linked => ({ transport, source, linked })))))
("matches actual task auth seeding before $transport setup (source=$source, linked=$linked)", async ({ transport, source, linked }) => {
const home = await realpath(await mkdtemp(join(tmpdir(), "native-unbound-auth-")));
const auth = '{"tokens":{"account_id":"fixture-unbound-account","access_token":"fixture-unbound-access","refresh_token":"fixture-unbound-refresh","id_token":"fixture-unbound-id"}}';
try {
const stored = join(home, linked ? "account-store.json" : "auth.json");
await writeFile(stored, auth, { mode: 0o600 });
if (linked) await symlink(stored, join(home, "auth.json"));
const taskHome = join(home, "actual-task-home");
await prepareIsolatedCodexHome({ context: null, codexHome: taskHome, sourceCodexHome: home });
const taskAuth = await readFile(join(taskHome, "auth.json"), "utf8").catch(() => null);
expect(taskAuth).toBe(linked ? null : auth);
vi.stubEnv("CODEX_HOME", source === "operator" ? home : "");
nativeProbe.mockImplementation(async input => {
const probeHome = join(input.runtimeDirectory, "codex-home");
await prepareIsolatedCodexHome({ context: null, codexHome: probeHome, sourceCodexHome: input.transportOptions.sourceCodexHome });
expect(await readFile(join(probeHome, "auth.json"), "utf8")).toBe(auth);
await input.onCleanupConfirmed();
return receipt("codex", "gpt-6.1-sol");
});
const testContext = transport === "remote" ? sshContext : { companyId: "company", adapterType: "paperclip_runner", config: {} };
const result = await testNativeRunnerAuthentication({ ...testContext, config: {
managedAiCredentialHome: home,
env: source === "configured" ? { CODEX_HOME: home } : {},
} }, "codex", "gpt-6.1-sol");
expect(result.status).toBe(linked ? "fail" : "pass");
if (linked) {
expect(nativeProbe).not.toHaveBeenCalled();
expect(sshExecute).not.toHaveBeenCalled();
expect(result.checks[0].message).toContain(source === "configured" ? "env.CODEX_HOME" : "operator CODEX_HOME");
expect(result.checks[0].message).toContain("Legacy runner");
expect(result.checks[0].message).toContain("managed Codex Connection");
} else expect(nativeProbe).toHaveBeenCalledOnce();
expect(await readFile(stored, "utf8")).toBe(auth);
expect(JSON.stringify(result)).not.toContain("fixture-unbound-access");
} finally { await rm(home, { recursive: true, force: true }); }
});
it.each(["local", "remote"].flatMap(transport => ["OPENAI_API_KEY", "CODEX_API_KEY"].flatMap(key => ["valid", "dangling", "invalid-store", "probe-error"].map(authState => ({ transport, key, authState })))))
("keeps the configured $key task credential authoritative over $authState linked auth during $transport setup", async ({ transport, key, authState }) => {
const home = await realpath(await mkdtemp(join(tmpdir(), "native-key-linked-auth-")));
const auth = '{"tokens":{"account_id":"unused-subscription","access_token":"unused-subscription-access"}}';
const stored = join(home, "account-store.json");
const readCredential = vi.spyOn(localCredentialFile, "readLocalAiCredentialFile");
const copyBack = vi.spyOn(codexCredentials, "copyBackCodexAuth");
try {
if (authState === "valid" || authState === "probe-error") await writeFile(stored, auth, { mode: 0o600 });
else if (authState === "invalid-store") await mkdir(stored, { mode: 0o700 });
await symlink(stored, join(home, "auth.json"));
const taskHome = join(home, "actual-key-task-home");
await prepareIsolatedCodexHome({ context: null, codexHome: taskHome, sourceCodexHome: home, apiKey: "configured-fixture-key" });
expect(JSON.parse(await readFile(join(taskHome, "auth.json"), "utf8"))).toEqual({ OPENAI_API_KEY: "configured-fixture-key" });
nativeProbe.mockImplementation(async input => {
expect(input.onCodexCredentialRefresh).toBeUndefined();
expect(input.transportOptions.sourceCodexHome).toBe(home);
const probeHome = join(input.runtimeDirectory, "codex-home");
await prepareIsolatedCodexHome({ context: null, codexHome: probeHome, sourceCodexHome: input.transportOptions.sourceCodexHome,
apiKey: input.environment.CODEX_API_KEY ?? input.environment.OPENAI_API_KEY });
expect(JSON.parse(await readFile(join(probeHome, "auth.json"), "utf8"))).toEqual({ OPENAI_API_KEY: "configured-fixture-key" });
await input.onCleanupConfirmed();
if (authState === "probe-error") throw new Error("authentication failed: configured-fixture-key");
return receipt("codex", "gpt-6.1-sol");
});
const testContext = transport === "remote" ? sshContext : { companyId: "company", adapterType: "paperclip_runner", config: {} };
const result = await testNativeRunnerAuthentication({ ...testContext, config: { env: { CODEX_HOME: home, [key]: "configured-fixture-key" } } }, "codex", "gpt-6.1-sol");
expect(result.status).toBe(authState === "probe-error" ? "fail" : "pass");
if (authState === "probe-error") expect(result.checks[0]).toMatchObject({ code: "codex_hello_probe_auth_required", message: "authentication failed: [REDACTED]" });
expect(nativeProbe).toHaveBeenCalledOnce();
expect(readCredential).not.toHaveBeenCalled();
expect(copyBack).not.toHaveBeenCalled();
expect((await lstat(join(home, "auth.json"))).isSymbolicLink()).toBe(true);
expect(await readlink(join(home, "auth.json"))).toBe(stored);
if (authState === "valid" || authState === "probe-error") expect(await readFile(stored, "utf8")).toBe(auth);
else if (authState === "invalid-store") expect(await readdir(stored)).toEqual([]);
else await expect(lstat(stored)).rejects.toMatchObject({ code: "ENOENT" });
expect(JSON.stringify(result)).not.toContain("configured-fixture-key");
} finally { readCredential.mockRestore(); copyBack.mockRestore(); await rm(home, { recursive: true, force: true }); }
});
it.each(["local", "remote"].flatMap(transport => [false, true].map(linked => ({ transport, linked }))))
("seeds the selected subscription before isolated $transport setup (linked=$linked)", async ({ transport, linked }) => {
const home = await realpath(await mkdtemp(join(tmpdir(), "native-selected-auth-")));
const auth = JSON.stringify({ tokens: { account_id: "fixture-account", id_token: "fixture-id", access_token: "fixture-access", refresh_token: "fixture-refresh" }, last_refresh: "2026-01-01T00:00:00.000Z" });
const stored = join(home, linked ? "account-store.json" : "auth.json");
let seeded = false;
try {
await writeFile(stored, auth, { mode: 0o600 });
if (linked) await symlink(stored, join(home, "auth.json"));
await writeFile(join(home, "config.toml"), 'cli_auth_credentials_store = "file"\n[mcp_servers.host_only]\ncommand = "untrusted-host-tool"\n', { mode: 0o600 });
await writeFile(join(home, "unassigned-private-file"), "must-not-copy");
nativeProbe.mockImplementation(async input => {
expect(input.environment._PAPERCLIP_NATIVE_SETUP_CODEX_AUTH_JSON_SECRET).toBeUndefined();
const codexHome = join(input.runtimeDirectory, "codex-home");
await prepareIsolatedCodexHome({ context: null, codexHome, sourceCodexHome: input.transportOptions.sourceCodexHome,
apiKey: input.environment.CODEX_API_KEY ?? input.environment.OPENAI_API_KEY });
const privateAuth = await readFile(join(codexHome, "auth.json"), "utf8").catch(() => null);
seeded = privateAuth === auth;
expect((await readFile(join(codexHome, "config.toml"), "utf8"))).not.toContain("untrusted-host-tool");
if (!seeded) throw new Error("authentication required");
if (linked) {
const source = join(input.runtimeDirectory, "codex-credential-source");
expect(input.transportOptions.sourceCodexHome).toBe(source);
expect((await stat(source)).mode & 0o777).toBe(0o700);
expect((await stat(join(source, "auth.json"))).mode & 0o777).toBe(0o600);
expect((await readdir(source)).sort()).toEqual(["auth.json", "config.toml"]);
}
if (transport === "remote") {
await remoteLauncher.mock.calls[0][0].ensureArtifact();
const upload = sshExecute.mock.calls.find(([value]) => value.args?.[1]?.includes("/codex-home/auth.json"))?.[0];
expect(upload).toBeDefined();
expect(Buffer.from(upload!.stdin, "base64").toString("utf8")).toBe(auth);
expect(upload!.args[1]).toContain("chmod 600");
}
await input.onCleanupConfirmed();
return receipt("codex", "gpt-6.1-sol");
});
const testContext = transport === "remote" ? sshContext : { companyId: "company", adapterType: "paperclip_runner", config: {} };
const result = await testNativeRunnerAuthentication({ ...testContext, config: { env: { CODEX_HOME: "/untrusted-configured-home", _PAPERCLIP_NATIVE_SETUP_CODEX_AUTH_JSON_SECRET: "spoofed-auth" } }, managedAiCredentialHome: home }, "codex", "gpt-6.1-sol");
expect(result.status).toBe("pass");
expect(seeded).toBe(true);
expect(await readFile(stored, "utf8")).toBe(auth);
expect(JSON.stringify(result)).not.toContain("fixture-access");
await expect(stat(nativeProbe.mock.calls[0][0].runtimeDirectory)).rejects.toMatchObject({ code: "ENOENT" });
expect(execute).not.toHaveBeenCalled();
} finally { await rm(home, { recursive: true, force: true }); }
});
it.each(["local", "remote"].flatMap(transport => ["dangling", "foreign-owner", "unsafe-mode", "oversize"].map(mode => ({ transport, mode }))))
("rejects $mode linked subscription before $transport provider admission", async ({ transport, mode }) => {
const home = await realpath(await mkdtemp(join(tmpdir(), "native-invalid-link-")));
const stored = join(home, "account-store.json");
let owner: ReturnType<typeof vi.spyOn> | undefined;
try {
if (mode !== "dangling") await writeFile(stored, mode === "oversize" ? "x".repeat(65_537) : '{"tokens":{"account_id":"fixture-account"}}', { mode: 0o600 });
await symlink(stored, join(home, "auth.json"));
if (mode === "unsafe-mode") await chmod(stored, 0o644);
if (mode === "foreign-owner") owner = vi.spyOn(process, "getuid").mockReturnValue(process.getuid!() + 1);
nativeProbe.mockResolvedValue(receipt("codex", "gpt-6.1-sol"));
const testContext = transport === "remote" ? sshContext : { companyId: "company", adapterType: "paperclip_runner", config: {} };
const result = await testNativeRunnerAuthentication({ ...testContext, managedAiCredentialHome: home }, "codex", "gpt-6.1-sol");
expect(result.status).toBe("fail");
expect(nativeProbe).not.toHaveBeenCalled();
expect(sshExecute).not.toHaveBeenCalled();
if (mode === "dangling") expect(result.checks.some(check => check.code === ADAPTER_AUTH_MISSING_CHECK_CODE)).toBe(true);
} finally { owner?.mockRestore(); await rm(home, { recursive: true, force: true }); }
});
it.each(["local", "remote"])("redacts checked file credentials from %s native probe errors without exporting them to env", async transport => {
const home = await realpath(await mkdtemp(join(tmpdir(), "native-file-redaction-")));
const auth = JSON.stringify({ tokens: { account_id: "fixture-account", id_token: "fixture-id", access_token: "fixture-access", refresh_token: "fixture-refresh" } });
try {
await writeFile(join(home, "account-store.json"), auth, { mode: 0o600 });
await symlink(join(home, "account-store.json"), join(home, "auth.json"));
nativeProbe.mockImplementation(async input => {
expect(input.environment._PAPERCLIP_NATIVE_SETUP_CODEX_AUTH_JSON_SECRET).toBeUndefined();
throw new Error("authentication failed: " + auth + " fixture-access fixture-refresh");
});
const testContext = transport === "remote" ? sshContext : { companyId: "company", adapterType: "paperclip_runner", config: {} };
const result = await testNativeRunnerAuthentication({ ...testContext, managedAiCredentialHome: home }, "codex", "gpt-6.1-sol");
expect(result.status).toBe("fail");
expect(JSON.stringify(result)).not.toMatch(/fixture-account|fixture-access|fixture-refresh|fixture-id/);
expect(result.checks[0].message).toContain("[REDACTED]");
} finally { await rm(home, { recursive: true, force: true }); }
});
it.each(["local", "remote"].flatMap(transport => [false, true].map(confirmed => ({ transport, confirmed }))))
("settles private linked credentials only after confirmed canceled $transport setup (confirmed=$confirmed)", async ({ transport, confirmed }) => {
const home = await realpath(await mkdtemp(join(tmpdir(), "native-linked-cancel-")));
const auth = '{"tokens":{"account_id":"fixture-account","access_token":"fixture-access"}}';
try {
await writeFile(join(home, "account-store.json"), auth, { mode: 0o600 });
await symlink(join(home, "account-store.json"), join(home, "auth.json"));
nativeProbe.mockImplementation(async input => {
expect(await readFile(join(input.transportOptions.sourceCodexHome, "auth.json"), "utf8")).toBe(auth);
if (confirmed) await input.onCleanupConfirmed();
throw new Error("Native provider setup canceled");
});
const testContext = transport === "remote" ? sshContext : { companyId: "company", adapterType: "paperclip_runner", config: {} };
expect((await testNativeRunnerAuthentication({ ...testContext, managedAiCredentialHome: home }, "codex", "gpt-6.1-sol")).status).toBe("fail");
const root = nativeProbe.mock.calls[0][0].runtimeDirectory;
if (confirmed) await expect(stat(root)).rejects.toMatchObject({ code: "ENOENT" });
else expect(await readFile(join(root, "codex-credential-source", "auth.json"), "utf8")).toBe(auth);
expect(await readFile(join(home, "account-store.json"), "utf8")).toBe(auth);
} finally { await rm(home, { recursive: true, force: true }); }
});
it.each(["linux-arm64", "linux-x64"])("uses task staging and the selected %s SSH daemon without a provider pack", async target => {
const selectedBinary = `/fixture/bin/${target}/paperclip-runnerd`;
selectedRemoteRunner.mockResolvedValue(selectedBinary);
let released = false;
registerPrp.mockResolvedValue({ release: async () => { released = true; } });
nativeProbe.mockImplementation(async input => {
const registration = await input.transportOptions.controlPlaneRegistration({ marker: "native-authority" });
expect(registration.connection).toMatchObject({ mode: "connect", connectUrl: expect.stringMatching(/^wss:\/\/paperclip\.example\.test\/api\/runner\/v1\/connect\//) });
await registration.release();
await input.onCleanupConfirmed();
return receipt("codex", "gpt-6.1-sol");
});
expect((await testNativeRunnerAuthentication({ ...sshContext, config: { externallySandboxed: true, env: { OPENAI_API_KEY: "company-account", PAPERCLIP_RUNNER_EXTERNAL_SANDBOX: "true" }, modelReasoningEffort: "low" } }, "codex", "gpt-6.1-sol")).status).toBe("pass");
const input = nativeProbe.mock.calls[0][0];
expect(input).toMatchObject({ reasoningEffort: "low", environment: { OPENAI_API_KEY: "company-account" }, workingDirectory: expect.stringMatching(/^\/qa-workspace\/.paperclip-runtime\/paperclip-native-setup-.*\/filesystem\/workspace$/) });
expect(input.transportOptions).toMatchObject({ runnerBinary: selectedBinary, codexCommand: expect.stringMatching(/\/bin\/codex$/), runnerFilesystemRoot: expect.stringMatching(/\/filesystem$/), externallySandboxed: false });
expect(nativeArtifacts).toHaveBeenCalledWith(expect.objectContaining({ controllerRunnerBinary: selectedBinary, model: "gpt-6.1-sol", targetPlatformArtifactVerified: true }));
expect(selectedRemoteRunner).toHaveBeenCalledExactlyOnceWith({ runner: { execute: sshExecute }, remoteCwd: "/qa-workspace", configuredPath: null });
expect(selectedRunner).not.toHaveBeenCalled();
expect(nativeArtifacts.mock.results[0]!.value.prepare).toHaveBeenCalledWith("dial_wss");
expect(registerPrp).toHaveBeenCalledWith({ companyId: "company", runId: input.transportOptions.prpIdentity.runId, authority: { marker: "native-authority" } });
expect(remoteLauncher).toHaveBeenCalledWith(expect.objectContaining({ runnerInstanceId: input.transportOptions.prpIdentity.runnerInstanceId }));
expect(released).toBe(true);
expect(sshExecute.mock.calls.at(-1)![0]).toMatchObject({ command: "rm", args: ["-rf", "--", expect.stringContaining("paperclip-native-setup-")] });
expect(sshExecute.mock.calls.some(([value]) => JSON.stringify(value).includes("company-account"))).toBe(false);
expect(execute).not.toHaveBeenCalled();
});
it.each(["runtime missing", "authentication failed: selected-key", "no native completion", "model mismatch"])("fails SSH native setup without a legacy fallback (%s)", async message => {
nativeProbe.mockRejectedValue(new Error(message));
const result = await testNativeRunnerAuthentication({ ...sshContext, config: { env: { OPENAI_API_KEY: "selected-key" } } }, "codex", "gpt-6.1-sol");
expect(result.status).toBe("fail"); expect(JSON.stringify(result)).not.toContain("selected-key");
expect(nativeProbe).toHaveBeenCalledOnce(); expect(execute).not.toHaveBeenCalled();
expect(sshExecute.mock.calls.some(([value]) => value.command === "rm")).toBe(false);
expect((await stat(nativeProbe.mock.calls[0][0].runtimeDirectory)).isDirectory()).toBe(true);
});
it("fails SSH transport eligibility before staging or opening the native provider", async () => {
vi.stubEnv("PAPERCLIP_RUNNER_PUBLIC_URL", "");
expect((await testNativeRunnerAuthentication(sshContext, "codex", null)).status).toBe("fail");
expect(sshExecute).not.toHaveBeenCalled(); expect(nativeArtifacts).not.toHaveBeenCalled(); expect(nativeProbe).not.toHaveBeenCalled();
});
it("reports an unavailable SSH target daemon before probing credentials or switching runtimes", async () => {
selectedRemoteRunner.mockRejectedValue(new Error("runner_remote_artifact_unavailable: install the matching linux-arm64 Paperclip release"));
const result = await testNativeRunnerAuthentication(sshContext, "codex", null);
expect(result.status).toBe("fail"); expect(result.checks[0].message).toContain("matching linux-arm64 Paperclip release");
expect(nativeArtifacts).not.toHaveBeenCalled(); expect(nativeProbe).not.toHaveBeenCalled();
expect(selectedRunner).not.toHaveBeenCalled(); expect(execute).not.toHaveBeenCalled();
});
it.each([true, false])("removes SSH probe state only after confirmed teardown (%s)", async confirmed => {
nativeProbe.mockImplementation(async input => {
const registration = await input.transportOptions.controlPlaneRegistration({});
await registration.release();
if (confirmed) await input.onCleanupConfirmed();
throw new Error("authentication failed");
});
expect((await testNativeRunnerAuthentication(sshContext, "codex", null)).status).toBe("fail");
expect(sshExecute.mock.calls.some(([value]) => value.command === "rm")).toBe(confirmed);
const root = nativeProbe.mock.calls[0][0].runtimeDirectory;
if (confirmed) await expect(stat(root)).rejects.toThrow(); else expect((await stat(root)).isDirectory()).toBe(true);
});
it.each(["same-account", "another-account", "read-failure", "uncertain-close"] as const)("preserves managed SSH account refresh and confirmed cleanup (%s)", async mode => {
const home = await mkdtemp(join(tmpdir(), "native-ssh-managed-account-"));
const auth = (account: string, marker: string, age: number) => JSON.stringify({ tokens: { account_id: account, id_token: `id-${marker}`, access_token: `access-${marker}`, refresh_token: `refresh-${marker}` }, last_refresh: new Date(Date.now() - age).toISOString() });
try {
await writeFile(join(home, "account-store.json"), auth("qa-account", "old", 120_000), { mode: 0o600 });
await symlink(join(home, "account-store.json"), join(home, "auth.json"));
const refreshed = auth(mode === "another-account" ? "different-account" : "qa-account", "new", 60_000);
sshExecute.mockImplementation(async input => ({ exitCode: input.args?.[2] === "paperclip-native-refresh" && mode === "read-failure" ? 1 : 0, timedOut: false,
stdout: input.args?.[2] === "paperclip-native-refresh" ? Buffer.from(refreshed).toString("base64") : "", stderr: "" }));
nativeProbe.mockImplementation(async input => {
const registration = await input.transportOptions.controlPlaneRegistration({});
await registration.release();
const original = new Error("authentication failed in native SSH" + (mode === "uncertain-close" ? "; daemon teardown was not confirmed" : ""));
try { await input.onCodexCredentialRefresh(posix.join(input.transportOptions.runnerFilesystemRoot, "codex-home", "auth.json")); }
catch (error) { throw new AggregateError([original, error], original.message + "; " + (error as Error).message, { cause: original }); }
if (mode !== "uncertain-close") await input.onCleanupConfirmed();
throw original;
});
const result = await testNativeRunnerAuthentication({ ...sshContext, managedAiCredentialHome: home }, "codex", "gpt-6.1-sol");
expect(result.status).toBe("fail"); expect(result.checks[0].message).toContain("authentication failed in native SSH");
expect(JSON.parse(await readFile(join(home, "auth.json"), "utf8")).tokens.access_token).toBe(mode === "same-account" || mode === "uncertain-close" ? "access-new" : "access-old");
expect(sshExecute.mock.calls.some(([input]) => input.command === "rm")).toBe(mode === "same-account" || mode === "another-account");
expect(JSON.stringify(result)).not.toContain("access-new");
const read = sshExecute.mock.calls.find(([input]) => input.args?.[2] === "paperclip-native-refresh")![0];
expect(read.command).toBe("sh"); expect(read.args[1]).toContain("head -c 65537"); expect(read.args[1]).toContain("test ! -L");
expect(nativeProbe.mock.calls[0][0].transportOptions.sourceCodexHome).toBe(join(nativeProbe.mock.calls[0][0].runtimeDirectory, "codex-credential-source"));
if (mode === "read-failure") expect(result.checks[0].message).toContain("refresh handoff unavailable");
if (mode === "uncertain-close") expect(result.checks[0].message).toContain("teardown was not confirmed");
} finally { await rm(home, { recursive: true, force: true }); }
});
it.each([
["authentication failed: bound-account", "auth_required"], ["Native provider hello probe timed out.", "timeout"],
["Model is unavailable", "failed"], ["native executable is missing", "failed"], ["native provider returned no response", "failed"],
])("fails closed and redacts native failures (%s)", async (message, code) => {
nativeProbe.mockRejectedValue(new Error(message));
const result = await testNativeRunnerAuthentication({ companyId: "company", adapterType: "paperclip_runner", config: { env: { OPENAI_API_KEY: "bound-account" } } }, "codex", "gpt-6.1-sol");
expect(result.status).toBe("fail");
expect(result.checks[0]).toMatchObject({ code: `codex_hello_probe_${code}`, level: "error" });
expect(result.checks.some(check => check.code === ADAPTER_AUTH_MISSING_CHECK_CODE)).toBe(code === "auth_required");
expect(JSON.stringify(result)).not.toContain("bound-account");
expect(nativeProbe).toHaveBeenCalledOnce();
expect(execute).not.toHaveBeenCalled();
});
it.each([
["authentication required: selected-account", true],
["Model is unavailable", false],
["native executable is missing", false],
])("projects the canonical sandbox login check only for authentication failures (%s)", async (message, authentication) => {
nativeProbe.mockRejectedValue(new Error(message));
const result = await testNativeRunnerAuthentication(context, "codex", "gpt-6.1-sol");
expect(result.status).toBe("fail");
expect(result.checks[0].code).toBe(`codex_hello_probe_${authentication ? "auth_required" : "failed"}`);
expect(result.checks.some(check => check.code === ADAPTER_AUTH_MISSING_CHECK_CODE)).toBe(authentication);
if (authentication) expect(result.checks).toContainEqual(expect.objectContaining({ code: ADAPTER_AUTH_MISSING_CHECK_CODE, level: "error" }));
expect(nativeProbe).toHaveBeenCalledOnce();
expect(execute).not.toHaveBeenCalled();
});
it.each([
{ helloProbePassed: undefined }, { effectiveModel: "another-model" }, { effectiveModel: null }, { effectiveModel: "" }, { provider: "opencode" }, { providerDriver: "codex_cli" },
])("rejects installation-only, legacy, or mismatched receipts", async override => {
nativeProbe.mockResolvedValue({ ...receipt("codex", "gpt-6.1-sol"), ...override });
expect((await testNativeRunnerAuthentication({ companyId: "company", adapterType: "paperclip_runner", config: {} }, "codex", "gpt-6.1-sol")).status).toBe("fail");
});
it("accepts an automatic Codex model only when the native provider observed a nonempty model", async () => {
nativeProbe.mockResolvedValue(receipt("codex", "gpt-6.1-sol"));
expect((await testNativeRunnerAuthentication({ companyId: "company", adapterType: "paperclip_runner", config: {} }, "codex", null)).status).toBe("pass");
expect(nativeProbe.mock.calls[0][0].model).toBeNull();
nativeProbe.mockResolvedValue(receipt("codex", null));
expect((await testNativeRunnerAuthentication({ companyId: "company", adapterType: "paperclip_runner", config: {} }, "codex", null)).status).toBe("fail");
nativeProbe.mockResolvedValue(receipt("codex", "unknown"));
expect((await testNativeRunnerAuthentication({ companyId: "company", adapterType: "paperclip_runner", config: {} }, "codex", null)).status).toBe("fail");
});
it.each([true, false])("removes failed local probe state only after confirmed teardown (%s)", async confirmed => {
nativeProbe.mockImplementation(async input => {
if (confirmed) await input.onCleanupConfirmed();
throw new Error("authentication failed");
});
expect((await testNativeRunnerAuthentication({ companyId: "company", adapterType: "paperclip_runner", config: {} }, "codex", null)).status).toBe("fail");
const root = nativeProbe.mock.calls[0][0].runtimeDirectory;
if (confirmed) await expect(stat(root)).rejects.toThrow();
else expect((await stat(root)).isDirectory()).toBe(true);
});
it.each(["arm64", "x64"].flatMap(controllerArch => ["arm64", "x64"].flatMap(targetArch => [false, true].map(explicit => ({ controllerArch, targetArch, explicit })))))
("uses target $targetArch artifacts and controller PRP from Mac $controllerArch for sandbox setup (override=$explicit)", async ({ controllerArch, targetArch, explicit }) => {
vi.stubGlobal("process", { ...process, platform: "darwin", arch: controllerArch });
const selectedBinary = `/controller/artifacts/linux-${targetArch}/paperclip-runnerd`;
selectedRemoteRunner.mockResolvedValue(selectedBinary);
if (explicit) vi.stubEnv("PAPERCLIP_RUNNER_REMOTE_BINARY_PATH", selectedBinary);
nativeProbe.mockImplementation(async input => {
const registration = await input.transportOptions.controlPlaneRegistration({});
expect(registration.connection.mode).toBe("connect");
await registration.release(); await input.onCleanupConfirmed();
return receipt("codex", "gpt-6.1-sol");
});
const target = { ...context.executionTarget, runner: { execute: sshExecute } };
expect((await testNativeRunnerAuthentication({ ...context, executionTarget: target }, "codex", "gpt-6.1-sol")).status).toBe("pass");
expect(sshRunner).not.toHaveBeenCalled();
expect(selectedRemoteRunner).toHaveBeenCalledExactlyOnceWith({ runner: target.runner, remoteCwd: target.remoteCwd, configuredPath: explicit ? selectedBinary : null });
expect(selectedRunner).not.toHaveBeenCalled();
expect(nativeArtifacts).toHaveBeenCalledWith(expect.objectContaining({ target, runner: target.runner, controllerRunnerBinary: selectedBinary, targetPlatformArtifactVerified: true }));
expect(nativeProbe.mock.calls[0][0].transportOptions.runnerBinary).toBe(selectedBinary);
expect(nativeProbe.mock.calls[0][0].transportOptions.externallySandboxed).toBe(true);
expect(nativeArtifacts.mock.results[0].value.prepare).toHaveBeenCalledWith("dial_wss");
expect(execute).not.toHaveBeenCalled();
});
it.each(["runner_remote_artifact_unavailable", "runner_remote_artifact_platform_mismatch"])("fails sandbox %s before setup or a controller fallback", async code => {
selectedRemoteRunner.mockRejectedValue(new Error(code));
const target = { ...context.executionTarget, runner: { execute: sshExecute } };
const result = await testNativeRunnerAuthentication({ ...context, executionTarget: target }, "codex", "gpt-6.1-sol");
expect(result.status).toBe("fail");
expect(result.checks[0].message).toContain(code);
expect(selectedRunner).not.toHaveBeenCalled(); expect(nativeArtifacts).not.toHaveBeenCalled();
expect(nativeProbe).not.toHaveBeenCalled(); expect(sshExecute).not.toHaveBeenCalled();
expect(execute).not.toHaveBeenCalled();
});
it("prepares listener artifacts before acquiring sandbox ingress and owns its release", async () => {
const order: string[] = [];
const close = vi.fn();
const endpoint = { kind: "authenticated_websocket" as const, websocketUrl: "wss://ingress.test", secretHeaders: [], generation: "generation", refresh: vi.fn(), close };
const getRunnerIngressEndpoint = vi.fn(async () => { order.push("ingress"); return endpoint; });
nativeArtifacts.mockReturnValue({ prepare: vi.fn(async mode => { expect(mode).toBe("listen_ws"); order.push("artifacts"); }) });
const connectionClose = vi.fn();
ingressConnect.mockImplementation(() => { order.push("connect"); return { ready: Promise.resolve(), close: connectionClose }; });
nativeProbe.mockImplementation(async input => {
const registration = await input.transportOptions.controlPlaneRegistration({});
expect(registration.connection.mode).toBe("listen");
remoteLauncher.mock.calls[0][0].onRunnerProcessSpawned();
await registration.activate(); await registration.ready(); await registration.release();
await input.onCleanupConfirmed(); return receipt("codex", "gpt-6.1-sol");
});
const target = { ...context.executionTarget, providerKey: "daytona", runner: { execute: sshExecute }, leaseId: "lease", effectiveCapabilities: { reusableLeases: false, nativeSyncIn: false, nativeSyncOut: false, persistentProcessSessions: false, independentControlCommands: true, incrementalSessionOutput: false, concurrentSyncOperations: false, duplexCommandStream: false, runnerWebSocketIngress: true }, getRunnerIngressEndpoint };
expect((await testNativeRunnerAuthentication({ ...context, executionTarget: target }, "codex", "gpt-6.1-sol")).status).toBe("pass");
expect(order).toEqual(["artifacts", "ingress", "connect"]);
expect(nativeProbe.mock.calls[0][0].transportOptions.externallySandboxed).toBe(true);
expect(connectionClose).toHaveBeenCalledOnce(); expect(registerPrp).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,275 @@
import { constants } from "node:fs";
import { lstat, mkdir, mkdtemp, open, realpath, rm, stat, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, posix } from "node:path";
import { copyBackCodexAuth } from "@paperclipai/adapter-codex-local/server";
import type { AdapterEnvironmentTestContext, AdapterEnvironmentTestResult } from "@paperclipai/adapter-utils";
import { ADAPTER_AUTH_MISSING_CHECK_CODE } from "@paperclipai/shared";
import { probeNativeRunnerEnvironment, resolveSourceCodexHome } from "../../vendor/paperclip-runner/index.js";
import { resolvePaperclipRunnerBinary, resolveRemotePaperclipRunnerBinary } from "./native-codex-runner.js";
import { readLocalAiCredentialFile } from "../local-ai-credential-file.js";
import { createNativeSshCommandRunner } from "./native-ssh-command-runner.js";
import { resolvePaperclipRunnerTransport } from "@paperclipai/adapter-utils/runner-connectivity";
import { registerRunnerPrpAuthority } from "../../realtime/runner-prp-ws.js";
import { connectRunnerPrpIngress } from "../../realtime/runner-prp-outbound.js";
const PROBE_TRANSPORT_ENV_KEYS = ["PATH", "LANG", "LANGUAGE", "TZ", "TMPDIR", "TEMP", "TMP", "SSL_CERT_FILE", "SSL_CERT_DIR", "NODE_EXTRA_CA_CERTS", "HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", "ALL_PROXY", "http_proxy", "https_proxy", "no_proxy", "all_proxy"];
function redactNativeProbeMessage(message: string, environment: Record<string, string>): string {
const redact = (value: unknown): void => {
if (typeof value === "string" && value) message = message.replaceAll(value, "[REDACTED]");
else if (value && typeof value === "object") for (const item of Object.values(value)) redact(item);
};
for (const [name, value] of Object.entries(environment)) {
if (!/key|token|secret|password/i.test(name) || !value) continue;
redact(value);
try { redact(JSON.parse(value)); } catch { /* Most credentials are plain strings. */ }
}
return message.slice(0, 2000);
}
async function prepareProbeCodexSource(runtimeDirectory: string, sourceCodexHome: string, authJson?: string): Promise<string> {
if (authJson === undefined) return sourceCodexHome;
let config: Buffer | undefined;
const source = await open(join(sourceCodexHome, "config.toml"), constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK)
.catch((error: NodeJS.ErrnoException) => { if (error.code === "ENOENT" || error.code === "ELOOP") return null; throw error; });
if (source) {
try {
const metadata = await source.stat();
if (metadata.isFile()) {
const bytes = Buffer.alloc(1024 * 1024 + 1);
let size = 0;
while (size < bytes.length) {
const result = await source.read(bytes, size, bytes.length - size, size);
if (!result.bytesRead) break;
size += result.bytesRead;
}
if (size > 1024 * 1024) throw new Error("The selected Codex configuration exceeds its size limit.");
config = bytes.subarray(0, size);
}
} finally { await source.close(); }
}
const credentialSource = join(runtimeDirectory, "codex-credential-source");
let created = false;
try {
await mkdir(credentialSource, { mode: 0o700 });
created = true;
await writeFile(join(credentialSource, "auth.json"), authJson, { mode: 0o600, flag: "wx" });
if (config) await writeFile(join(credentialSource, "config.toml"), config, { mode: 0o600, flag: "wx" });
return credentialSource;
} catch (error) {
if (created) await rm(credentialSource, { recursive: true, force: true });
throw error;
}
}
async function probePreparedNativeRuntime(runtimeDirectory: string, prepare: () => Promise<Parameters<typeof probeNativeRunnerEnvironment>[0]>) {
let handedOff = false;
try {
const options = await prepare();
handedOff = true;
return await probeNativeRunnerEnvironment(options);
} catch (error) {
if (!handedOff) {
try { await rm(runtimeDirectory, { recursive: true, force: true }); }
catch (cleanupError) {
const message = error instanceof Error ? error.message : "Native setup preparation failed.";
throw new AggregateError([error, cleanupError], `${message}; Native setup preparation cleanup is incomplete.`, { cause: error });
}
}
throw error;
}
}
async function probeRemoteNativeCodex(options: {
context: AdapterEnvironmentTestContext;
model: string | null;
reasoningEffort?: string;
environment: Record<string, string>;
sourceCodexHome: string;
credentialRefreshAuthPath?: string;
sourceAuthJson?: string;
timeoutMs: number;
}) {
const target = options.context.executionTarget;
if (target?.kind !== "remote") throw new Error("Native setup requires the selected remote execution target.");
const { createRemoteNativeArtifactPreparation, createRemoteRunnerProcessLauncher, stageRemoteRunnerFile, readRemoteRunnerState } = await import("./native-session-executor.js");
const runner = target.transport === "ssh" ? createNativeSshCommandRunner({ spec: target.spec, defaultCwd: target.remoteCwd }) : target.runner;
if (!runner) throw new Error("runner_transport_ineligible: remote process runner is unavailable");
const id = crypto.randomUUID();
const runnerInstanceId = `setup-${id}`;
const ingress = target.transport === "sandbox" && target.effectiveCapabilities?.runnerWebSocketIngress === true;
const outbound = !ingress ? await resolvePaperclipRunnerTransport({ target, runId: id, localConnectUrl: "ws://127.0.0.1/unused",
runnerPublicUrl: process.env.PAPERCLIP_RUNNER_PUBLIC_URL?.trim() || null,
runnerCaBundlePath: process.env.PAPERCLIP_RUNNER_CA_BUNDLE_PATH?.trim() || null, runnerIngressAuthorized: true }) : null;
if (outbound && outbound.mode !== "direct_outbound") throw new Error("runner_transport_ineligible: expected outbound native transport");
if (outbound?.mode === "direct_outbound" && outbound.caBundlePath) await stat(outbound.caBundlePath).catch(() => { throw new Error("runner_direct_wss_failed: configured runner CA bundle is unavailable"); });
const remoteRoot = posix.join(target.remoteCwd, ".paperclip-runtime", `paperclip-native-setup-${id}`);
const remoteBinary = posix.join(remoteRoot, "bin", "paperclip-runnerd");
const remoteStateDirectory = posix.join(remoteRoot, "runner");
const remoteFilesystemRoot = posix.join(remoteRoot, "filesystem");
const remoteWorkspace = posix.join(remoteFilesystemRoot, "workspace");
const remoteCodexNpmSpec = process.env.PAPERCLIP_RUNNER_REMOTE_CODEX_NPM_SPEC?.trim() || null;
const remoteCodexBinary = remoteCodexNpmSpec ? posix.join(remoteRoot, "harnesses", "codex", "node_modules", ".bin", "codex") : posix.join(remoteRoot, "bin", "codex");
const explicitRunner = process.env.PAPERCLIP_RUNNER_REMOTE_BINARY_PATH?.trim() || null;
const controllerRunnerBinary = await resolveRemotePaperclipRunnerBinary({ runner, remoteCwd: target.remoteCwd, configuredPath: explicitRunner });
const artifacts = createRemoteNativeArtifactPreparation({ target, runner, remoteBinary, controllerRunnerBinary, remoteRuntimeRoot: remoteRoot, remoteCodexBinary,
targetPlatformArtifactVerified: true,
runnerRemoteBinaryPath: explicitRunner, runnerRemoteCodexPath: process.env.PAPERCLIP_RUNNER_REMOTE_CODEX_PATH?.trim(), runnerRemoteCodexNpmSpec: remoteCodexNpmSpec, model: options.model });
const requireSuccess = (result: { timedOut: boolean; exitCode: number | null }, message: string) => {
if (result.timedOut || result.exitCode !== 0) throw new Error(message);
};
const runtimeDirectory = await realpath(await mkdtemp(join(tmpdir(), "paperclip-native-setup-")));
return probePreparedNativeRuntime(runtimeDirectory, async () => {
const sourceCodexHome = await prepareProbeCodexSource(runtimeDirectory, options.sourceCodexHome, options.sourceAuthJson);
let remoteDirectoryCreated = false;
let remoteCaBundlePath: string | undefined;
let signalSpawned!: () => void;
const spawned = new Promise<void>(resolve => { signalSpawned = resolve; });
return { runtimeDirectory: runtimeDirectory, provider: "codex", model: options.model, reasoningEffort: options.reasoningEffort,
workingDirectory: remoteWorkspace, environment: options.environment, timeoutMs: options.timeoutMs,
transportOptions: {
externallySandboxed: target.transport === "sandbox",
runnerBinary: controllerRunnerBinary, codexCommand: remoteCodexBinary, sourceCodexHome,
runnerFilesystemRoot: remoteFilesystemRoot, runnerStateDirectory: remoteStateDirectory,
prpIdentity: { runnerInstanceId, runId: id, environmentLeaseId: `setup-${id}`, normalizedSessionId: `setup-${id}`, turnId: `turn-${id}`, itemId: `item-${id}` },
readRunnerState: () => readRemoteRunnerState({ runner, stateDirectory: remoteStateDirectory }),
controlPlaneRegistration: async authority => {
const created = await runner.execute({ command: "sh", args: ["-c", 'set -eu; umask 077; mkdir -p -- "$1"; test -d "$1" && test ! -L "$1"; mkdir -- "$2"; install -d -m 0700 "$3" "$4" "$5"',
"paperclip-native-setup", posix.dirname(remoteRoot), remoteRoot, remoteFilesystemRoot, remoteWorkspace, posix.join(remoteFilesystemRoot, "probe-home")], cwd: target.remoteCwd, bypassSession: true, timeoutMs: 10_000 });
requireSuccess(created, "Native setup could not claim a private runtime directory.");
remoteDirectoryCreated = true;
await artifacts.prepare(ingress ? "listen_ws" : "dial_wss");
if (outbound?.mode === "direct_outbound" && outbound.caBundlePath) {
remoteCaBundlePath = posix.join(remoteRoot, "bin", "runner-ca-bundle.pem");
await stageRemoteRunnerFile({ target, runner, sourcePath: outbound.caBundlePath, targetPath: remoteCaBundlePath, mode: 0o600 });
}
if (outbound?.mode === "direct_outbound") {
const registration = await registerRunnerPrpAuthority({ companyId: options.context.companyId, runId: id, authority });
return { connection: { mode: "connect" as const, connectUrl: outbound.connectUrl, ...(remoteCaBundlePath ? { caBundlePath: remoteCaBundlePath } : {}) },
startupFailureCode: "runner_direct_wss_failed" as const, release: registration.release };
}
const transport = await resolvePaperclipRunnerTransport({ target, runId: id, localConnectUrl: "ws://127.0.0.1/unused", runnerIngressAuthorized: true });
if (transport.mode !== "provider_ingress") throw new Error("runner_transport_ineligible: expected provider ingress");
let connection: ReturnType<typeof connectRunnerPrpIngress> | undefined;
let activation: Promise<void> | undefined;
return {
connection: { mode: "listen" as const, listenAddress: transport.listenAddress, listenPort: transport.listenPort, listenPath: transport.listenPath },
activate: () => {
activation = spawned.then(() => { connection = connectRunnerPrpIngress({ authority, endpoint: transport.ingress }); });
void activation.catch(() => undefined);
},
ready: async () => { await activation; if (!connection) throw new Error("runner_ingress_unavailable"); await connection.ready; },
get failure() { return connection?.failure; },
startupFailureCode: "runner_ingress_unavailable" as const,
release: async () => { if (connection) await connection.close(); else await transport.ingress.close(); },
};
},
runnerProcessLauncher: createRemoteRunnerProcessLauncher({ target, runner, remoteBinary, stateDirectory: remoteStateDirectory,
onRunnerProcessSpawned: signalSpawned, processIdentityPath: posix.join(remoteStateDirectory, "runner-process.identity"), diagnosticsDirectory: posix.join(remoteRoot, "diagnostics"), runnerInstanceId,
ensureArtifact: async () => {
for (const name of ["auth.json", "config.toml"] as const) {
const sourcePath = join(runtimeDirectory, "codex-home", name);
try { await stat(sourcePath); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") continue; throw error; }
await stageRemoteRunnerFile({ target, runner, sourcePath, targetPath: posix.join(remoteFilesystemRoot, "codex-home", name), mode: 0o600 });
}
} }),
},
...(options.credentialRefreshAuthPath ? { onCodexCredentialRefresh: async (filename: string) => {
if (filename !== posix.join(remoteFilesystemRoot, "codex-home", "auth.json")) throw new Error("The native Codex credential refresh handoff is invalid.");
await copyBackCodexAuth({ hostAuthPath: options.credentialRefreshAuthPath!, log: () => {}, readSandboxAuth: async () => {
const read = await runner.execute({ command: "sh", args: ["-c", 'set -eu; file=$1; test -f "$file" || exit 66; test ! -L "$file"; parent=$(dirname -- "$file"); while test "$parent" != /; do test -d "$parent" && test ! -L "$parent"; parent=$(dirname -- "$parent"); done; metadata=$(stat -c "%u %a %s" "$file" 2>/dev/null || stat -f "%u %Lp %z" "$file"); set -- $metadata; test "$1" = "$(id -u)" && test "$2" = 600 && test "$3" -le 65536; head -c 65537 -- "$file" | base64', "paperclip-native-refresh", filename], bypassSession: true, timeoutMs: 10_000 });
if (read.timedOut || read.exitCode !== 0) throw Object.assign(new Error("Native Codex credential refresh handoff unavailable."), { code: read.exitCode === 66 ? "ENOENT" : "INVALID_CREDENTIAL" });
const body = Buffer.from(read.stdout, "base64");
if (body.length > 65_536) throw new Error("Native Codex credential refresh handoff exceeds the credential size limit.");
return body;
} });
} } : {}),
onCleanupConfirmed: async () => {
if (remoteDirectoryCreated) {
const cleaned = await runner.execute({ command: "rm", args: ["-rf", "--", remoteRoot], bypassSession: true, timeoutMs: 10_000 });
requireSuccess(cleaned, "Native setup cleanup is incomplete; private recovery state was retained.");
}
await rm(runtimeDirectory, { recursive: true, force: true });
},
};
});
}
export async function testNativeRunnerAuthentication(context: AdapterEnvironmentTestContext, provider: "codex", model: string | null): Promise<AdapterEnvironmentTestResult> {
const remote = context.executionTarget?.kind === "remote";
const configured = context.config.env;
const environment: Record<string, string> = Object.fromEntries([
...(remote ? [] : PROBE_TRANSPORT_ENV_KEYS.flatMap(key => typeof process.env[key] === "string" ? [[key, process.env[key]!]] : [])),
...Object.entries(configured && typeof configured === "object" && !Array.isArray(configured) ? configured : {})
.filter((entry): entry is [string, string] => typeof entry[1] === "string"),
]);
const authKey = "_PAPERCLIP_NATIVE_SETUP_CODEX_AUTH_JSON_SECRET";
const configKey = "_PAPERCLIP_NATIVE_SETUP_CODEX_CONFIG_TOML_SECRET";
delete environment[authKey];
delete environment[configKey];
let sourceCodexHome: string | null | undefined;
const effort = context.config.modelReasoningEffort ?? context.config.reasoningEffort ?? context.config.effort;
const timeoutMs = remote ? 90_000 : 45_000;
let runtimeDirectory: string | undefined;
let sourceAuthJson: string | undefined;
try {
if (provider === "codex") {
const { buildNativeProviderEnvironment } = await import("./native-session-executor.js");
sourceCodexHome = context.managedAiCredentialHome ?? resolveSourceCodexHome(buildNativeProviderEnvironment(environment));
}
let credentialRefreshAuthPath: string | undefined;
if (sourceCodexHome && !environment.CODEX_API_KEY?.trim() && !environment.OPENAI_API_KEY?.trim() && !environment.PAPERCLIP_AI_PROVIDER_KEY?.trim()) {
let linked = false;
try {
const authPath = join(sourceCodexHome, "auth.json");
linked = (await lstat(authPath)).isSymbolicLink();
if (linked && !context.managedAiCredentialHome?.trim()) {
const field = environment.CODEX_HOME?.trim() ? "env.CODEX_HOME" : "operator CODEX_HOME";
throw new Error(`${field} uses a linked auth.json that Paperclip Runner tasks cannot read. Choose Legacy runner in Advanced or use a managed Codex Connection.`);
}
const resolvedAuthPath = await realpath(authPath);
const source = await stat(resolvedAuthPath);
if (!source.isFile() || source.uid !== process.getuid?.()) throw new Error("Invalid Codex source credential owner.");
if (linked) sourceAuthJson = await readLocalAiCredentialFile(resolvedAuthPath);
credentialRefreshAuthPath = resolvedAuthPath;
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
if (linked) throw new Error("Codex authentication required: the selected credential link is unavailable.");
}
}
let receipt: Awaited<ReturnType<typeof probeNativeRunnerEnvironment>>;
if (context.executionTarget?.kind === "remote") {
receipt = await probeRemoteNativeCodex({ context, model, environment, sourceCodexHome: sourceCodexHome ?? "", credentialRefreshAuthPath, sourceAuthJson, timeoutMs,
...(typeof effort === "string" && effort ? { reasoningEffort: effort } : {}) });
} else {
const runnerBinary = resolvePaperclipRunnerBinary();
runtimeDirectory = await realpath(await mkdtemp(join(tmpdir(), "paperclip-native-setup-")));
receipt = await probePreparedNativeRuntime(runtimeDirectory, async () => {
const probeSourceCodexHome = await prepareProbeCodexSource(runtimeDirectory!, sourceCodexHome ?? "", sourceAuthJson);
return { runtimeDirectory: runtimeDirectory!, provider, model, environment, timeoutMs,
...(provider === "codex" && typeof effort === "string" && effort ? { reasoningEffort: effort } : {}),
onCleanupConfirmed: async () => { await rm(runtimeDirectory!, { recursive: true, force: true }); },
transportOptions: { runnerBinary, sourceCodexHome: probeSourceCodexHome, externallySandboxed: false },
...(credentialRefreshAuthPath ? { onCodexCredentialRefresh: async (filename: string) => {
if (filename !== join(runtimeDirectory!, "codex-home", "auth.json")) throw new Error("The native Codex credential refresh handoff is invalid.");
await copyBackCodexAuth({ hostAuthPath: credentialRefreshAuthPath!, log: () => {}, readSandboxAuth: async () => Buffer.from(await readLocalAiCredentialFile(filename)) });
} } : {}),
};
});
}
if (receipt.helloProbePassed !== true || receipt.provider !== provider || typeof receipt.effectiveModel !== "string" || !receipt.effectiveModel.trim() || receipt.effectiveModel === "unknown" || (model !== null && receipt.effectiveModel !== model)
|| receipt.providerDriver !== "codex_app_server") throw new Error("The selected native runtime returned an incompatible account or model verification receipt.");
return { adapterType: "paperclip_runner", status: "pass", testedAt: new Date().toISOString(), checks: [{ code: `${provider}_hello_probe_passed`, level: "info",
message: `The native ${provider} runtime verified the selected account and model in ${remote ? "the selected environment" : "the Paperclip host"}.` }] };
} catch (error) {
const message = redactNativeProbeMessage(error instanceof Error ? error.message : "The selected native runtime could not verify this account.", { ...environment, ...(sourceAuthJson ? { [authKey]: sourceAuthJson } : {}) });
const authentication = /(?:invalid (?:api[- ]?key|auth(?:entication)? token)|authentication (?:failed|required)|unauthenticated|unauthorized|not authenticated|please (?:log|sign) in|not logged in|\b(?:401|403)\b)/i.test(message);
return { adapterType: "paperclip_runner", status: "fail", testedAt: new Date().toISOString(), checks: [{ code: `${provider}_hello_probe_${authentication ? "auth_required" : /timed out/i.test(message) ? "timeout" : "failed"}`, level: "error", message,
hint: "Check the selected account, model access, and native runtime prerequisites, then retry. Legacy runner is available explicitly in Advanced." },
...(authentication && provider === "codex" ? [{ code: ADAPTER_AUTH_MISSING_CHECK_CODE, level: "error" as const,
message: "The selected Codex account needs authentication.",
hint: "Sign in to Codex or choose an authenticated account, then test again." }] : [])] };
}
}
@@ -93,6 +93,35 @@ describe("first-task proposal mode policy", () => {
expect(skill).toContain("`request_checkbox_confirmation` targeting its saved revision");
expect(skill).toContain("explicit plan requests regardless of the single-task proposal mode");
});
it("hands accepted file-producing child work to its own execution before reporting results", async () => {
const skill = await readFile(new URL("../onboarding-assets/first-task/skills/first-task/SKILL.md", import.meta.url), "utf8");
expect(skill).toContain("If the accepted child work requires a file deliverable");
expect(skill).toContain("self-contained child description");
expect(skill).toContain("requested filenames");
expect(skill).toContain("child's own Paperclip execution");
expect(skill).toContain("even when the child is assigned to yourself");
expect(skill).toContain("Do not create or register that file from the onboarding run");
expect(skill).toContain("`register_deliverable` is bound to its current task");
expect(skill).toContain("preserve existing blocker IDs");
expect(skill).toContain("`set_dependencies`");
expect(skill).toContain("`paperclip_block`");
expect(skill).toContain("`blockedByIssueIds`");
expect(skill).toContain("End the turn so the child can execute");
expect(skill).toContain("Do not poll for its result");
expect(skill).toContain("verify the saved child document and retrievable file");
expect(skill).toContain("actual child task and download links");
});
it("keeps plan-only authorization and document-only child execution distinct from file handoff", async () => {
const skill = await readFile(new URL("../onboarding-assets/first-task/skills/first-task/SKILL.md", import.meta.url), "utf8");
expect(skill).toContain("For a plan-only request, retain the accepted plan on this task");
expect(skill).toContain("Do not start its implementation or hire the proposed team without authorization");
expect(skill).toContain("Create exactly one child linked to this onboarding task");
expect(skill).toContain("For document-only child work, you may execute it directly");
expect(skill).toContain("Save the finished output as a document on the child task and mark that child done");
expect(skill).toContain("On later wakes, continue that same child instead of creating another");
});
});
describe("chief-of-staff persona", () => {
+22 -5
View File
@@ -15,9 +15,10 @@ import type {
PluginManagedAgentDeclaration,
PluginManagedAgentResolution,
} from "@paperclipai/shared";
import { isUuidLike } from "@paperclipai/shared";
import { agentHarnessType, agentRunner, isUuidLike } from "@paperclipai/shared";
import { conflict, forbidden, notFound } from "../errors.js";
import { agentService } from "./agents.js";
import { resolveNewAgentRunnerForCompany } from "./agent-runner-selection.js";
import { approvalService } from "./approvals.js";
import { logActivity } from "./activity-log.js";
import { agentInstructionsBundleMode, agentInstructionsService } from "./agent-instructions.js";
@@ -356,12 +357,12 @@ export function pluginManagedAgentService(
async function companyAdapterUsage(companyId: string) {
const rows = await db
.select({ adapterType: agents.adapterType })
.select({ adapterType: agents.adapterType, adapterConfig: agents.adapterConfig })
.from(agents)
.where(and(eq(agents.companyId, companyId), ne(agents.status, "terminated")));
const counts = new Map<string, number>();
for (const row of rows) {
const adapterType = normalizeAdapterType(row.adapterType);
const adapterType = normalizeAdapterType(agentHarnessType(row.adapterType, row.adapterConfig));
if (!adapterType) continue;
counts.set(adapterType, (counts.get(adapterType) ?? 0) + 1);
}
@@ -645,11 +646,26 @@ export function pluginManagedAgentService(
? reconciled.agent.metadata
: {};
const adapterType = await resolveManagedAdapterType(companyId, declaration);
const defaults = declarationPatch(declaration, { adapterType });
const sameCodexHarness = adapterType === "codex_local" && agentHarnessType(reconciled.agent.adapterType, reconciled.agent.adapterConfig) === adapterType;
const retainedNative = sameCodexHarness && reconciled.agent.adapterType === "paperclip_runner";
const resetAdapterConfig = { ...defaults.adapterConfig };
if (retainedNative) {
for (const field of ["provider", "acpxAgent", "acpxMode"]) {
if (reconciled.agent.adapterConfig[field] !== undefined) resetAdapterConfig[field] = reconciled.agent.adapterConfig[field];
}
}
const execution = await resolveNewAgentRunnerForCompany(db, companyId, {
...defaults,
adapterType: retainedNative ? reconciled.agent.adapterType : adapterType,
adapterConfig: resetAdapterConfig,
defaultEnvironmentId: reconciled.agent.defaultEnvironmentId,
runner: sameCodexHarness ? agentRunner(reconciled.agent.adapterType) : undefined,
});
// Reset content through the canonical CAS path before changing defaults.
// A conflict must leave the existing adapter configuration untouched.
const withInstructions = await materializeDeclaredInstructions(companyId, reconciled.agent, declaration, { replaceExisting: true });
const defaults = declarationPatch(declaration, { adapterType });
const adapterConfig = { ...defaults.adapterConfig } as Record<string, unknown>;
const adapterConfig = { ...execution.adapterConfig };
if (declaration.instructions) {
for (const key of ["instructionsBundleMode", "instructionsRootPath", "instructionsEntryFile", "instructionsFilePath"]) {
if (withInstructions.adapterConfig[key] !== undefined) adapterConfig[key] = withInstructions.adapterConfig[key];
@@ -657,6 +673,7 @@ export function pluginManagedAgentService(
}
const updated = await agentSvc.update(reconciled.agent.id, {
...defaults,
adapterType: execution.adapterType,
adapterConfig,
metadata: managedMetadata(options.pluginId, options.pluginKey, declaration, currentMetadata),
}, {
+12 -3
View File
@@ -13,6 +13,7 @@ import {
LEGACY_DISPOSITION_REPAIR_INSTRUCTION, type LegacyDispositionEpisode,
} from "./legacy-continuation.js";
import { hasLiveLegacyController } from "../legacy-controller-lease.js";
import { isCompletedOnboardingHandoffWake } from "../chat-completion-delivery.js";
import { instanceSettingsService } from "../instance-settings.js";
import { isWaitingConversation, settleConversationTurn, deliverConversationComments } from "../agent-conversations.js";
import {
@@ -5938,13 +5939,21 @@ export function recoveryService(
const isTerminalSessionGoalControl =
runContext.resumeIntent === true &&
readNonEmptyString(runContext.goalControlRequestId) !== null;
const issueId = issueIdFromRunContext(run.contextSnapshot);
const preservesTerminalIssueReport = isTerminalSessionGoalControl ||
(issueId !== null && await isCompletedOnboardingHandoffWake(db, {
companyId: run.companyId,
issueId,
agentId: run.agentId,
reason: readNonEmptyString(runContext.wakeReason),
contextSnapshot: runContext,
}));
let issueTerminalStatus: "succeeded" | "cancelled" | null =
isTerminalSessionGoalControl
preservesTerminalIssueReport
? null
: (options?.referencingIssueTerminalStatus ?? null);
const issueId = issueIdFromRunContext(run.contextSnapshot);
if (
!isTerminalSessionGoalControl &&
!preservesTerminalIssueReport &&
!issueTerminalStatus &&
!options?.runReferencedByActiveIssue &&
issueId
+18
View File
@@ -186,3 +186,21 @@ For each linked issue, either:
- Short role drafting guide: `skills/paperclip-create-agent/references/baseline-role-guide.md`
- Pre-submit draft-review checklist: `skills/paperclip-create-agent/references/draft-review-checklist.md`
- Endpoint payload shapes and full examples: `skills/paperclip-create-agent/references/api-reference.md`
## Harness and runner selection
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`;
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.
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.
CLI equivalents: `paperclipai agent create --runner legacy --payload-json ...`
and `paperclipai agent hire --runner legacy --payload-json ...`.
+19 -3
View File
@@ -1437,10 +1437,26 @@ test.describe("Exact failed chat run retry", () => {
surface === "agent run"
? `/${seed.prefix}/agents/maya/runs/${failedRunId}`
: `/${seed.prefix}/inbox/all`;
const canonicalAgentResponse =
surface === "agent run"
? page.waitForResponse((response) => {
const url = new URL(response.url());
return (
response.request().method() === "GET" &&
url.pathname === "/api/agents/maya" &&
url.searchParams.get("companyId") === seed.companyId
);
})
: null;
await page.goto(startPath);
if (surface === "agent run") {
// Begin on the canonical route: observing the URL replacement
// alone can race the remount and discard retry mutation feedback.
if (canonicalAgentResponse) {
const response = await canonicalAgentResponse;
expect(response.ok()).toBe(true);
expect(await response.json()).toMatchObject({
id: seed.agentId,
companyId: seed.companyId,
urlKey: "maya",
});
await expect(page).toHaveURL(
new RegExp(`/${seed.prefix}/agents/maya/runs/${failedRunId}$`),
);
+2 -2
View File
@@ -95,7 +95,7 @@ test("new-task assignee, model, and project sheets scroll by touch and retain th
await json(await request.post(`/api/companies/${company.id}/agents`, {
data: {
name: `Touch Agent ${String(index).padStart(2, "0")}`, role: "engineer",
adapterType: "codex_local", adapterConfig: { model: "gpt-6-sol" },
adapterType: "codex_local", runner: "legacy", adapterConfig: { model: "gpt-6-sol" },
runtimeConfig: { heartbeat: { enabled: false } },
},
}));
@@ -196,7 +196,7 @@ test.describe("desktop picker scrolling", () => {
await json(await request.post(`/api/companies/${company.id}/agents`, {
data: {
name: `Wheel Agent ${String(index).padStart(2, "0")}`, role: "engineer",
adapterType: "codex_local", adapterConfig: { model: "gpt-6-sol" },
adapterType: "codex_local", runner: "legacy", adapterConfig: { model: "gpt-6-sol" },
runtimeConfig: { heartbeat: { enabled: false } },
},
}));
+59 -29
View File
@@ -307,28 +307,25 @@ test.describe("Onboarding wizard", () => {
expect(pageErrors, pageErrors.join("\n")).toHaveLength(0);
});
test("a reload during a login resumes the same session", async ({ page }) => {
// The last piece of the resume feature: a customer who reloads the page
// mid-login must see the same login still running, not the tile row
// again and not a second session started behind their back.
//
// A bare `/onboarding` reload is not the right way to exercise this: that
// route always forces its own fixed entry step (see
// `resolveRouteOnboardingOptions` — a bare `/onboarding` hit is a request
// to start a new company, by design, and always wins over a saved draft's
// step). A returning visit to an existing company's own onboarding path
// (`/{prefix}/onboarding`) is the real "reopen onboarding" entry point, so
// this test reloads through that path instead, landing on the agent step
// with the draft's agent name already restored, then advances once to the
// connect step — where the resumed login must already be running.
const pageErrors: string[] = [];
page.on("pageerror", (err) => pageErrors.push(err.message));
const loginSessionReads: string[] = [];
page.on("request", (request) => {
const match = new URL(request.url()).pathname.match(/\/setup-token-login-sessions\/([^/]+)(?:\/prompt)?$/);
if (match && match[1] !== "active") loginSessionReads.push(match[1]);
});
let hireCalls = 0;
await page.route("**/agent-hires", (route) => {
hireCalls += 1;
return route.fulfill({ status: 400, json: { error: "Unexpected hire while login is pending." } });
});
const flagRes = await page.request.patch("/api/instance/settings/experimental", {
data: { enableConferenceRoomChat: true },
});
expect(flagRes.ok()).toBe(true);
const FAKE_SANDBOX_ENVIRONMENT_ID = "e2e-fake-sandbox-environment-reload";
const FAKE_SANDBOX_ENVIRONMENT_ID = "a6d2e530-1e8b-4f0b-9c94-42f1d23e8750";
const FAKE_SANDBOX_PROVIDER = "e2e-fake-provider-reload";
await page.route("**/environments", async (route) => {
@@ -382,6 +379,15 @@ test.describe("Onboarding wizard", () => {
}),
);
await page.route("**/api/adapters?**", async (route) => {
const url = new URL(route.request().url());
if (url.searchParams.get("environmentId") !== FAKE_SANDBOX_ENVIRONMENT_ID) return route.continue();
url.searchParams.delete("environmentId");
const response = await route.fetch({ url: url.toString() });
expect(response.ok()).toBe(true);
await route.fulfill({ response });
});
const SESSION_ID = "e2e-reload-setup-token-session";
const AUTHORIZATION_URL = "https://claude.ai/oauth/authorize?code=true&client=e2e-reload";
let startCalls = 0;
@@ -390,6 +396,7 @@ test.describe("Onboarding wizard", () => {
// route, and keeping the pre-Connect part of this test the same as the
// ordinary sign-in test above.
let sessionStarted = false;
const resumedCompanyIds: string[] = [];
let aiConnection: Record<string, unknown> | undefined;
await page.route("**/setup-token-login-sessions", (route) => {
if (route.request().method() === "POST") {
@@ -441,6 +448,7 @@ test.describe("Onboarding wizard", () => {
body: JSON.stringify({ error: "Setup-token login session not found." }),
});
}
resumedCompanyIds.push(new URL(route.request().url()).pathname.split("/")[3]);
return route.fulfill({
contentType: "application/json",
body: JSON.stringify({
@@ -490,7 +498,7 @@ test.describe("Onboarding wizard", () => {
await expect(cardInstruction).toBeVisible({ timeout: 30_000 });
await expect(authorizationLink).toBeVisible({ timeout: 15_000 });
await expect(authorizationLink).toHaveAttribute("href", /claude\.ai\/oauth\/authorize/);
await expect(authorizationLink).toHaveAttribute("href", AUTHORIZATION_URL);
expect(startCalls).toBe(1);
const companiesRes = await page.request.get("/api/companies");
@@ -501,22 +509,44 @@ test.describe("Onboarding wizard", () => {
);
expect(company, "the created company should exist").toBeTruthy();
// Reload through the company's own onboarding path — the real "reopen
// onboarding" entry point for a company that already exists.
const savedSelection = {
step: 4,
companyName: `${COMPANY_NAME}-reload`,
agentName: "Ada",
adapterType: "claude_local",
runner: "auto",
credentialMode: "subscription",
credentialModeChoice: null,
createdCompanyId: company.id,
createdCompanyPrefix: company.issuePrefix,
createdAgentId: null,
};
const readDraft = () => page.evaluate(() => JSON.parse(localStorage.getItem("paperclip-onboarding-state") ?? "null"));
await expect.poll(readDraft).toMatchObject(savedSelection);
const assertResumedLogin = async () => {
await expect(page.getByRole("heading", { name: "Connect a model", exact: true })).toBeVisible({ timeout: 15_000 });
await expect(page.getByText("Ada", { exact: true })).toBeVisible();
await expect(page.getByRole("radio", { name: "Claude Subscription", exact: true })).toBeChecked();
await expect(cardInstruction).toBeVisible({ timeout: 15_000 });
await expect(authorizationLink).toBeVisible({ timeout: 15_000 });
await expect(authorizationLink).toHaveAttribute("href", AUTHORIZATION_URL);
await expect.poll(readDraft).toMatchObject(savedSelection);
expect(startCalls, "reopening must resume the session without a second source choice").toBe(1);
expect(hireCalls, "a pending login must not hire an agent").toBe(0);
expect(loginSessionReads.length).toBeGreaterThan(0);
expect(new Set(loginSessionReads)).toEqual(new Set([SESSION_ID]));
expect(new Set(resumedCompanyIds)).toEqual(new Set([company.id]));
};
const beforeReopenReads = resumedCompanyIds.length;
await page.goto(`/${company.issuePrefix}/onboarding`);
await expect.poll(() => resumedCompanyIds.length, { timeout: 15_000 }).toBeGreaterThan(beforeReopenReads);
await assertResumedLogin();
// This entry point always re-enters on the agent step, with the agent
// name restored from the draft. One more press reaches the connect step.
await page.waitForSelector("#onboarding-agent-name", { timeout: 30_000 });
await expect(page.locator("#onboarding-agent-name")).toHaveValue("Ada");
await page.getByRole("button", { name: "Next" }).click();
// The resumed login shows with no new source pick and no fresh sign-in
// press: the panel and the step both discover it from the caller's active
// session.
await expect(cardInstruction).toBeVisible({ timeout: 15_000 });
await expect(authorizationLink).toBeVisible({ timeout: 15_000 });
expect(startCalls, "the reload must resume the session, not start a new one").toBe(1);
const beforeReloadReads = resumedCompanyIds.length;
await page.reload();
await expect.poll(() => resumedCompanyIds.length, { timeout: 15_000 }).toBeGreaterThan(beforeReloadReads);
await assertResumedLogin();
expect(pageErrors, pageErrors.join("\n")).toHaveLength(0);
});
+1
View File
@@ -48,6 +48,7 @@ test("project creation and repository configuration persist on a short mobile vi
await section.getByRole("button", { name: "Edit", exact: true }).click();
await section.getByRole("textbox", { name: "Existing repo URL" }).fill("https://git.example/org/updated");
await section.getByRole("button", { name: "Save URL" }).click();
await expect(section.getByText("https://git.example/org/updated", { exact: true })).toBeVisible();
await page.reload();
await expect(section.getByText("https://git.example/org/updated", { exact: true })).toBeVisible();
await expect(page.getByText("Set the KEY to the env var name", { exact: false })).toHaveCount(0);
+5 -3
View File
@@ -111,7 +111,7 @@ async function fetchVerifiedRun(
candidateRunId: string | null | undefined,
agentId: string,
issueId: string,
): Promise<{ id: string; status: string } | null> {
): Promise<{ id: string; status: string; errorCode: string | null } | null> {
if (!candidateRunId) return null;
const runRes = await board.get(`${BASE_URL}/api/heartbeat-runs/${candidateRunId}`);
if (!runRes.ok()) return null;
@@ -119,7 +119,9 @@ async function fetchVerifiedRun(
const context = candidateRun.contextSnapshot ?? {};
if (candidateRun.agentId !== agentId) return null;
if (context.issueId !== issueId && context.taskId !== issueId) return null;
return { id: candidateRunId, status: candidateRun.status };
const errorCode = typeof candidateRun.errorCode === "string" && /^[a-z][a-z0-9_.-]{0,79}$/i.test(candidateRun.errorCode)
? candidateRun.errorCode : null;
return { id: candidateRunId, status: candidateRun.status, errorCode };
}
/**
@@ -289,7 +291,7 @@ async function waitForFixtureRun(
status = run!.status;
if (status === "succeeded") return true;
if (status === "cancelled" && allowCancelledLock) return false;
expect(["queued", "running"], `Fixture run ${runId} ended as ${status}`).toContain(status);
expect(["queued", "running"], `Fixture run ${runId} ended as ${status} (errorCode=${run!.errorCode ?? "unavailable"})`).toContain(status);
await new Promise((resolve) => setTimeout(resolve, WAKEUP_POLL_INTERVAL_MS));
} while (Date.now() < deadline);
throw new Error(`Fixture run ${runId} did not succeed within ${waitBudgetMs}ms (last status: ${status})`);
+13
View File
@@ -124,6 +124,19 @@ attribution. Failed setup before fixture completion retains unavailable/unknown
cost coverage and its holds. A connection-only probe or name-only snapshot cannot
qualify native defaults.
The manual managed first-task cell is the exception to fixture prequalification:
`confirmation-replies.runner-codex.daytona.task-card-accept` leaves the saved
environment unqualified until the ordinary onboarding Connect action. It
observes exactly one platform response and one selected-runtime response, fresh
scoped native discovery and the saved automatic choice. Both setup leases must
have authoritative response IDs, match this company/environment, and reach
released/success before setup is accepted. Exact IDs join the existing task-run
lease collector and runtime estimate, including failed setup. New uncorrelated
or response-less leases remain unknown and unattributed; incomplete coverage
removes the estimate and retains its budget holds. No retry or extra fixture
probe closes that gap. The existing environment deletion, expiry backstops and
instance cleanup still apply on every outcome.
Daytona creates sandbox environments through the public API. The core fixture
keeps `reuseLease:false` and `runnerLifecycleMode:"per_turn"`. The dedicated
warm-continuity fixture uses `reuseLease:true` and
+68 -24
View File
@@ -139,8 +139,9 @@ Paperclip task, run an agent, or replace a Product E2E result.
`--suite confirmation-replies` selects ten local native Claude/Codex cells:
conversational single-task approval, saved-plan approval, rejection, card-click
acceptance as a control, and ambiguous approval with two independent pending
proposals. The onboarding cells use the production wizard, runtime switch and
persona. The ambiguity fixture creates ordinary board cards through the public
proposals. The onboarding cells use the production wizard and persona. Codex
retains its automatic native selection; Claude uses an explicit runtime switch.
The ambiguity fixture creates ordinary board cards through the public
API, sends "Yes, go ahead" through the browser, requires both to stay pending
with a clarification reply and no execution, then approves only one and rejects
the other through separate browser messages.
@@ -184,8 +185,9 @@ pnpm test:e2e:runner -- --suite confirmation-replies
and native Claude, each with onboarding, idle handoff, busy handoff, two-task
handoff, and restart recovery. These exercise the production completion-delivery
path and agent-authored responses. There is no separate completion feature flag.
The onboarding cell reuses the real wizard and its existing pre-execution native
runtime switch, retaining the production persona.
The onboarding cell reuses the real wizard and retains the production persona.
Codex uses its automatic native default; Claude explicitly changes runtime
before execution.
The idle chat cell asks the agent to delegate one welcome note to a named worker and
report its result without another user message. A bounded local file read in
@@ -1082,18 +1084,19 @@ default is retained**, even when it differs from that profile's normal harness
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). Native profiles complete the same
production wizard using their legacy provider, then change only the agent's
runtime configuration via the public API before its first task. The wizard does
not currently offer native Runner. Persona, managed instructions, skills, seeded
question, and task invocation are preserved. Explicit model choices are retained;
an unset model resolves through the production runtime-switch defaults. The
production switch removes the legacy Paperclip operational skill because Runner
supplies its control-plane contract through its protocol; other assigned skills,
including `/first-task`, are retained. Native runtime permissions come from the
existing qualified profile. Evidence labels
this setup `post-onboarding-runtime-switch`; it does not claim a native wizard
path exists. Legacy setup is labeled `production-wizard`.
`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
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
separate campaign reservation because they do not create task cost events.
| First response / control | Complete journey |
| --- | --- |
@@ -1449,12 +1452,11 @@ released on failure and normal isolated-instance cleanup removes the workspace.
The usual provider billing and partial-attempt reporting apply. No production
prompts, onboarding defaults, or provider permissions are changed.
For pre-default native onboarding qualification, select all `first-task` cases
with profiles `runner-codex,runner-acpx-claude` (26 cells). The existing public-API
runtime switch occurs after the real wizard creates its first agent and before
any provider work. It preserves the wizard's model, persona, skills, and task.
This tests the native first-task process in advance of the UI/default rollout;
it does not certify a native option in the wizard, which is not offered yet.
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.
Current proof and remaining decisions are recorded in
[the 21 September qualification report](QUALIFICATION-2026-09-21.md). In particular,
@@ -1999,12 +2001,31 @@ Run the explicit `paperclipai runtime setup pi` for that installation first.
This lane rejects candidate flags, local/remote daemon overrides, provider asset
or pack overrides, and Node injection. It launches the installed JavaScript CLI
without a TypeScript loader, rechecks its pins on controller restart, and records
`installed-cli-admission.json` in private attempt evidence. It supports only the
explicit Pi extended, native, controls and warm suites, which require none of the
`installed-cli-admission.json` in private attempt evidence. Pi uses the explicit
extended, native, controls and warm suites, which require none of the
source-only response barriers. Cursor/Copilot qualification gates stay intact.
Pi's historical `qualificationCandidate` fixture selector remains a roster key;
it no longer grants an opt-in when the source Pi declaration admits normal use.
The same four installed-package pins also admit three existing local native
Codex cells: `first-task.runner-codex.local.task-card-accept`,
`everyday-workflows.runner-codex.local.build-revise`, and
`agent-chat.runner-codex.local.continuity-restart`. Select exact IDs; other
Codex cells, legacy profiles and remote environments remain outside this lane.
These reuse the onboarding, independently checked file/revision, and restart
graders. They require separately authorized provider access and are not executed
by the provider-free release smoke.
The release consumer calls `probeInstalledCodexStartup` from `installed-cli.ts`
against its installed server, normal platform daemon and contained npm Codex.
It checks a real native read-only session and joined cleanup using empty homes,
file-only authentication storage and a closed environment. It sends no provider
turn, consults no account, records the actual CLI version without an equality
gate, and exposes no session IDs. Build/source, Mach-O/ELF and official npm
provenance checks precede this probe. Startup proof does not establish useful-file
delivery, authentication, reasoning effort or follow-up behavior; the existing
live journeys must prove those separately.
Installed Pi Daytona cells also require the published plugin fixture inputs
`PAPERCLIP_RUNNER_E2E_INSTALLED_DAYTONA_PLUGIN` (canonical package root),
`PAPERCLIP_RUNNER_E2E_INSTALLED_DAYTONA_PLUGIN_AUTHORITY` (canonical JSON file),
@@ -2131,6 +2152,29 @@ the changed chief-of-staff persona and skill selections; it needs no fixture
change for that default selection. Hiring from that wizard-created chief of
staff remains a separate follow-up qualification.
The explicit-only cell `confirmation-replies.runner-codex.daytona.task-card-accept`
extends that wizard journey to one fresh managed company. Select this exact ID;
the automatic roster and all local prompts remain unchanged. Its fixture seeds
encrypted credentials and an immutable OCI image environment, but does not
prequalify the image or create an agent. Connect owns the existing saved
environment probe, fresh scoped adapter discovery, and selected runtime setup.
The case requires one confirmed platform probe and one native Codex setup lease,
then verifies the automatically saved native configuration after reload.
The completed child must expose a downloaded `garden-welcome.txt` attachment
whose byte size, SHA-256, content and scenario marker are independently checked.
A saved document alone cannot pass. Existing task-card grading and the same-task
follow-up continuity checks remain required. This cell has one attempt, zero
automatic retries and a shared fifteen-minute deadline with cleanup time
reserved. The script authors three turns; four task runs were observed in the
historical local journey. The existing twelve-run guard and $5 company task cap
remain, with native setup, one completion judge request, sandbox time and cleanup
requiring separate reservation. Registration and provider-free controls do not
establish live qualification or funding within the remaining paid reserve.
This OCI-image Core case does not prove Cloud signup, warm/cold provisioning or
name-only snapshot qualification. Those remain separate acceptance gates.
### Hiring completion accounting evidence
The v3 hiring grader uses turn-accounting v2 in both executable guards. It requires complete per-run public event streams, exact native tool-use/result pairing and canonical execution IDs for completion actions. Only successful known GET issue/document/comment operations, verified reads/discovery, and attributed native chat finish are admitted. Writes, failed mutation attempts, incomplete streams and unknown actions cannot pass. Separate ACPX host request IDs and provider execution IDs are not joined by name/order/count; missing mapping is uncomparable action coverage, not a measured task failure. The original source-read and exact template checks remain unchanged.
+10
View File
@@ -126,6 +126,16 @@ The Paperclip server process also receives none; the browser posts each value
once to the encrypted company secret API and agents/environments retain only
secret references.
The explicit managed first-task cell observes the wizard's two owned setup
responses only to correlate their exact lease IDs with same-company environment
records. Raw probe/setup responses, account references, opaque provider lease
identities and setup screenshots remain private. Public first-task evidence
continues through the existing projection. Missing or foreign lease identities
cannot acquire attribution or a zero-cost estimate; incomplete setup coverage
stays unavailable even if later task-run billing succeeds. Platform qualification
uses the existing permission-gated environment probe, without granting new
permissions or accepting fixture-authored qualification metadata.
The context-integrity catalog lists six pending Kimi/Grok cells for discovery:
two cases each across `legacy-kimi-cli`, `legacy-kimi-acp`, and `legacy-grok`.
The launcher and direct Playwright runner reject these profiles before loading
+20 -3
View File
@@ -32,6 +32,23 @@ import {
} from "./selectors.js";
describe("runner E2E catalog", () => {
it("keeps the single managed onboarding variant explicit-only without changing local onboarding coverage", () => {
const id = "confirmation-replies.runner-codex.daytona.task-card-accept";
const suite = runnerSuites.find(candidate => candidate.id === "confirmation-replies")!;
const remote = runnerMatrix.filter(entry => entry.suite.id === suite.id && entry.environment.id === "daytona");
expect(remote.map(entry => entry.id)).toEqual([id]);
expect(suite.manualOnly).toBe(true);
expect(remote[0]!.task.flow).toBe("first_task");
expect(remote[0]!.task.expectedRunCount).toBe(4);
expect(remote[0]!.task.automaticRetryPolicy).toBe("single_attempt");
expect(remote[0]!.requiredCredentials).toEqual(["OPENAI_API_KEY", "DAYTONA_API_KEY"]);
expect(runnerMatrix.filter(entry => entry.suite.id === "first-task")).toHaveLength(52);
expect(runnerMatrix.filter(entry => entry.suite.id === suite.id && entry.environment.id === "local")).toHaveLength(12);
expect(selectRunnerExecutions(parseRunnerSelectors(["--id", id])).map(entry => entry.id)).toEqual([id]);
expect(selectRunnerExecutions(parseRunnerSelectors(["--all"])).some(entry => entry.id === id)).toBe(false);
expect(selectRunnerExecutions(parseRunnerSelectors(["--profile", "runner-codex", "--environment", "daytona"])).some(entry => entry.id === id)).toBe(false);
});
it("keeps private repository skill delivery explicit and bounded in both environments", () => {
const cases = runnerMatrix.filter(entry => entry.suite.id === "repository-skills");
expect(cases).toHaveLength(2);
@@ -159,10 +176,10 @@ describe("runner E2E catalog", () => {
expect(localIntegrityTasks).toHaveLength(2);
expect(openRouterBreadthTasks).toHaveLength(3);
expect(runnerSuites.map((suite) => suite.expectedMatrixSize)).toEqual([
3, 4, 8, 10, 4, 4, 6, 63, 12, 6, 2, 30, 3, 16, 16, 2, 6, 8, 46, 2, 23, 15, 52, 6, 6, 20, 26, 52, 28, 18, 2, 6, 6, 12, 10, 48, 16, 10, 2, 1, 1, 116, 2,
3, 4, 8, 10, 4, 4, 6, 63, 12, 6, 2, 30, 3, 16, 16, 2, 6, 8, 46, 2, 23, 15, 52, 6, 6, 20, 26, 52, 28, 18, 2, 6, 6, 13, 10, 48, 16, 10, 2, 1, 1, 116, 2,
]);
expect(validateRunnerCatalog()).toHaveLength(729);
expect(new Set(runnerMatrix.map((entry) => entry.id)).size).toBe(729);
expect(validateRunnerCatalog()).toHaveLength(730);
expect(new Set(runnerMatrix.map((entry) => entry.id)).size).toBe(730);
expect(
runnerMatrix.filter((entry) => entry.suite.id === "core-compatibility"),
).toHaveLength(48);
+13 -6
View File
@@ -101,6 +101,7 @@ function commonAgent(
capabilities:
"Completes deterministic standard, planning, and ask-mode runner acceptance tasks.",
adapterType,
...(adapterType === "codex_local" ? { runner: "legacy" as const } : {}),
adapterConfig,
defaultEnvironmentId: input.environmentId,
budgetMonthlyCents: 0,
@@ -1439,7 +1440,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: 4, runGrading: "evidenced-nonexecution-and-refusal", credentialPersistenceCheck: false, questionChoiceMinimum: 2, nativeSetup: "post-onboarding-runtime-switch", productionInstructions: true, qualityGrading: "informational" },
definitionMetadata: { version: 5, runGrading: "evidenced-nonexecution-and-refusal", credentialPersistenceCheck: false, questionChoiceMinimum: 2, nativeSetup: "codex-production-default-claude-explicit-runtime-switch", productionInstructions: true, qualityGrading: "informational" },
},
{
id: "agent-chat", label: "Persistent Agent Chat",
@@ -1495,10 +1496,14 @@ export const runnerSuites: readonly RunnerSuiteFixture[] = [
groups: ["chat", "native"],
profiles: runnerProfiles.filter(profile => ["runner-codex", "runner-acpx-claude"].includes(profile.id))
.map(profile => productionStoryProfile(defaultPermissionProfile(profile))),
environments: [localEnvironment],
environments: [localEnvironment, runnerEnvironments.find(environment => environment.id === "daytona")!],
excludedExecutionIds: ["runner-codex", "runner-acpx-claude"].flatMap(profile =>
[...firstTaskTasks.filter(task => ["task-reply-accept", "interview-plan-accept", "reject-no-execution", "task-card-accept"].includes(task.id)), ...chatConfirmationTasks]
.map(task => `confirmation-replies.${profile}.daytona.${task.id}`)
.filter(id => id !== "confirmation-replies.runner-codex.daytona.task-card-accept")),
tasks: [...firstTaskTasks.filter(task => ["task-reply-accept", "interview-plan-accept", "reject-no-execution", "task-card-accept"].includes(task.id)), ...chatConfirmationTasks],
expectedMatrixSize: 12,
definitionMetadata: { version: 6, instructions: "production", grading: "card-message-provenance-before-child-creation", unansweredQuestionComposer: "history-card-only-including-fresh-dismissal", completionObservation: "120-seconds", scheduling: "explicit-only" },
expectedMatrixSize: 13,
definitionMetadata: { version: 7, managedOnboarding: "ui-owned-qualification-and-physical-file", managedExpectedTaskRuns: 4, managedAutomaticRetries: 0, instructions: "production", grading: "card-message-provenance-before-child-creation", unansweredQuestionComposer: "history-card-only-including-fresh-dismissal", completionObservation: "120-seconds", scheduling: "explicit-only" },
},
{
id: "completion-updates", label: "Delegated Completion Updates", manualOnly: true,
@@ -1676,8 +1681,10 @@ export function buildRunnerMatrix(
suiteDefinitionHash: suiteDefinitionHash(suite),
profile,
environment,
task: suite.id === "extended-harnesses" && profile.qualificationCandidate === "pi" && task.id === "file-edit-validate"
? { ...task, buildPrompt: piFilePrompt } : task,
task: suite.id === "confirmation-replies" && profile.id === "runner-codex" && environment.id === "daytona" && task.id === "task-card-accept"
? { ...task, expectedRunCount: 4, automaticRetryPolicy: "single_attempt" as const }
: suite.id === "extended-harnesses" && profile.qualificationCandidate === "pi" && task.id === "file-edit-validate"
? { ...task, buildPrompt: piFilePrompt } : task,
groups: [
...new Set([
...suite.groups,
+7 -1
View File
@@ -1,17 +1,23 @@
import { describe, expect, it } from "vitest";
import { runsCompletionUpdateProbe, COMPLETION_QUALITY_CONFIG, completionQualityControls, completionQualityStatus, completionQualityRequest, judgeCompletionQuality, reserveCompletionQuality, validateCompletionQuality } from "./completion-quality.js";
import { runnerMatrix } from "./catalog.js";
import { MANAGED_FIRST_TASK_CELL } from "./first-task-fixtures.js";
const observation = { sourceId: "chat", marker: "REF", worker: { id: "task", title: "Welcome", status: "done", completedAt: "2026-09-01T00:00:00Z" }, documents: [{ id: "doc", issueId: "task", key: "welcome", body: "Welcome to the garden. Meet at 10:30." }], comments: [{ id: "reply", issueId: "chat", authorAgentId: "agent", createdAt: "2026-09-01T00:01:00Z", body: "The note is ready at /issues/task", createdByRunId: "run" }], runs: [] };
const criteria = Object.keys(COMPLETION_QUALITY_CONFIG.rubric).map(id => ({ id, passed: true, rationale: "Supported by the saved note and reply", evidenceIds: ["reply", "doc"] }));
const reports = [{ replyId: "reply", rationale: "Reports the completed task", completedTaskIdsReferenced: ["task"], resultAccessTaskIds: ["task"], correctsReplyIds: [] as string[] }];
describe("completion semantic qualification", () => {
it.each(["runner-codex", "runner-acpx-claude"])("requires a completion judge only for accepted work on %s", profile => {
const cells = runnerMatrix.filter(e => e.suite.id === "confirmation-replies" && e.profile.id === profile);
const cells = runnerMatrix.filter(e => e.suite.id === "confirmation-replies" && e.profile.id === profile && e.environment.id === "local");
expect(cells).toHaveLength(6);
expect(cells.filter(runsCompletionUpdateProbe).map(e => e.task.id).sort()).toEqual([
"interview-plan-accept", "task-card-accept", "task-reply-accept",
]);
});
it("requires the existing completion judge for accepted managed onboarding work", () => {
const managed = runnerMatrix.find(e => e.id === MANAGED_FIRST_TASK_CELL);
expect(managed).toBeDefined();
expect(runsCompletionUpdateProbe(managed!)).toBe(true);
});
it("retains the completion suite's gate and excludes ordinary chat journeys", () => {
const completion = runnerMatrix.filter(e => e.suite.id === "completion-updates");
expect(completion).toHaveLength(10);
+10 -6
View File
@@ -3,7 +3,7 @@ import { describe, expect, it, vi } from "vitest";
import { gradeConfirmationReply, assertAmbiguousReplyUnresolved, ambiguousConfirmationFixtures } from "./confirmation-replies.js";
import { firstTaskScenario } from "./first-task-cases.js";
import type { FirstTaskEvidence } from "./first-task-scoring.js";
import { provisionFirstTaskFixtures } from "./first-task-fixtures.js";
import { MANAGED_FIRST_TASK_CELL, provisionFirstTaskFixtures } from "./first-task-fixtures.js";
import { runnerMatrix } from "./catalog.js";
import { parseRunnerSelectors, selectRunnerExecutions } from "./selectors.js";
@@ -111,13 +111,15 @@ describe("confirmation-reply independent oracle", () => {
payload: { questions: [{ prompt, options: [{ label: "Welcome note" }, { label: "Poster" }] }] },
}] })).toThrow();
});
it.each(runnerMatrix.filter(e => e.suite.id === "confirmation-replies" && e.task.flow === "first_task"))("provisions the real onboarding fixture contract for $id", async execution => {
it.each(runnerMatrix.filter(e => e.suite.id === "confirmation-replies" && e.task.flow === "first_task" && e.environment.id === "local"))("provisions the real local onboarding fixture contract for $id", async execution => {
const get = vi.fn().mockResolvedValue([{ id: "local", driver: "local" }]);
const patch = vi.fn().mockResolvedValue({ id: "company" });
const postSensitive = vi.fn().mockResolvedValue({ id: "secret" });
const credential = execution.profile.credential;
const fixtures = await provisionFirstTaskFixtures({ api: { get, postSensitive }, execution, nonce: "fixture",
const fixtures = await provisionFirstTaskFixtures({ api: { get, patch, postSensitive }, execution, nonce: "fixture",
company: { id: "company", name: "Garden" }, credentials: { [credential]: "test-credential" } });
expect(get).toHaveBeenCalledExactlyOnceWith("/api/companies/company/environments?driver=local");
expect(patch).toHaveBeenCalledExactlyOnceWith("/api/companies/company", { budgetMonthlyCents: 500 });
expect(postSensitive).toHaveBeenCalledExactlyOnceWith("/api/companies/company/secrets", expect.objectContaining({ key: credential }));
expect(fixtures.agent.id).toBe(""); // The real wizard must still create the agent.
expect(JSON.stringify(fixtures)).not.toContain("test-credential");
@@ -135,12 +137,14 @@ describe("confirmation-reply independent oracle", () => {
originalIds: ["a", "b"], tasks: [], reply: "", agentId: "agent", answerId: "reply" })).toThrow();
});
it("selects exactly twelve explicit-only cases using production native profiles", () => {
it("selects twelve local and one managed explicit-only cases using production native profiles", () => {
const cells = runnerMatrix.filter(e => e.suite.id === "confirmation-replies");
expect(cells).toHaveLength(12);
expect(cells).toHaveLength(13);
expect(cells.filter(c => c.environment.id === "local")).toHaveLength(12);
expect(cells.filter(c => c.environment.id === "daytona").map(c => c.id)).toEqual([MANAGED_FIRST_TASK_CELL]);
expect(new Set(cells.map(c => c.profile.id))).toEqual(new Set(["runner-codex", "runner-acpx-claude"]));
expect(new Set(cells.map(c => c.task.id)).size).toBe(6);
expect(selectRunnerExecutions(parseRunnerSelectors(["--suite", "confirmation-replies"]))).toHaveLength(12);
expect(selectRunnerExecutions(parseRunnerSelectors(["--suite", "confirmation-replies"]))).toHaveLength(13);
expect(selectRunnerExecutions(parseRunnerSelectors(["--all"])).some(c => c.suite.id === "confirmation-replies")).toBe(false);
});
});
+131 -33
View File
@@ -1,21 +1,70 @@
import { normalizePaperclipOperationalSkillPreference } from "../../packages/adapter-utils/src/server-utils.js";
import { isDeepStrictEqual } from "node:util";
import type { RunnerApi } from "./api.js";
import type { LiveFixtureValues } from "./live-fixtures.js";
import { deleteDaytonaEnvironment, installFixtureDaytonaProvider, type LiveFixtureValues } from "./live-fixtures.js";
import { isImmutableDaytonaImage } from "./catalog.js";
import type { CredentialName, MatrixExecution } from "./types.js";
/** Provision only credentials for the UI-created company. The production wizard
* remains the sole creator/configurer of its first agent and onboarding task. */
export const MANAGED_FIRST_TASK_CELL = "confirmation-replies.runner-codex.daytona.task-card-accept";
export function isManagedFirstTask(execution: Pick<MatrixExecution, "id">): boolean {
return execution.id === MANAGED_FIRST_TASK_CELL;
}
interface FirstTaskRuntimeBound {
timeoutSec: 180;
maxConcurrentRuns: 1;
before: Record<string, unknown>;
after: Record<string, unknown>;
}
export type FirstTaskFixtureValues = LiveFixtureValues & {
onboardingRuntimeBound?: FirstTaskRuntimeBound;
};
export async function startFirstTaskWithRuntimeBound(input: {
api: Pick<RunnerApi, "get" | "patch">;
execution: MatrixExecution;
agent: Record<string, any>;
observe: (bound: FirstTaskRuntimeBound) => void;
start: () => Promise<void>;
}): Promise<void> {
if (input.execution.profile.id === "runner-codex") {
const snapshot = (agent: Record<string, any>) => structuredClone(Object.fromEntries([
"id", "companyId", "adapterType", "adapterConfig", "runtimeConfig", "permissions", "defaultEnvironmentId", "budgetMonthlyCents", "metadata",
].map(key => [key, agent?.[key]]))) as Record<string, any>;
const before = snapshot(input.agent);
if (!before.id || !before.companyId || before.adapterType !== "paperclip_runner" || before.adapterConfig?.provider !== "codex") {
throw new Error("First-task runtime bound requires the wizard's original native Codex selection");
}
const agentPath = `/api/agents/${encodeURIComponent(before.id)}`;
if (!isDeepStrictEqual(snapshot(await input.api.get(agentPath)), before)) {
throw new Error("The automatic wizard configuration changed before the first-task runtime bound");
}
const runtimeConfig = { ...before.runtimeConfig, heartbeat: { ...before.runtimeConfig?.heartbeat, maxConcurrentRuns: 1 } };
await input.api.patch(agentPath, { adapterConfig: { timeoutSec: 180 }, runtimeConfig });
const after = snapshot(await input.api.get(agentPath));
const expected = { ...before, adapterConfig: { ...before.adapterConfig, timeoutSec: 180 }, runtimeConfig };
if (!isDeepStrictEqual(after, expected)) {
throw new Error("The first-task runtime bound was not confirmed with the original execution and account settings");
}
input.observe({ timeoutSec: 180, maxConcurrentRuns: 1, before, after });
}
await input.start();
}
export async function provisionFirstTaskFixtures(input: {
api: Pick<RunnerApi, "get" | "postSensitive">;
api: Pick<RunnerApi, "get" | "patch" | "postSensitive"> & Partial<Pick<RunnerApi, "post" | "delete">>;
execution: MatrixExecution;
nonce: string;
daytonaImage?: string;
company: LiveFixtureValues["company"];
credentials: Partial<Record<CredentialName, string>>;
}): Promise<LiveFixtureValues> {
}): Promise<FirstTaskFixtureValues> {
const { api, execution, nonce, company, credentials } = input;
const managed = isManagedFirstTask(execution);
if (
!["first-task", "completion-updates", "confirmation-replies"].includes(execution.suite.id) || execution.task.flow !== "first_task" ||
execution.environment.id !== "local" ||
(execution.environment.id !== "local" && !managed) ||
![
"legacy-codex",
"legacy-claude",
@@ -27,40 +76,89 @@ export async function provisionFirstTaskFixtures(input: {
"First-task fixtures require a supported local onboarding profile",
);
}
const credential = execution.profile.credential;
const value = credentials[credential];
if (!value) throw new Error(`Missing credential ${credential}`);
const environments = await api.get<Array<LiveFixtureValues["environment"]>>(
`/api/companies/${company.id}/environments?driver=local`,
);
const environment = environments.find((e) => e.driver === "local");
if (!environment)
throw new Error("Onboarding fixture local environment missing");
const secret = await api.postSensitive<{ id: string }>(
`/api/companies/${company.id}/secrets`,
{
name: `First task ${credential}`,
key: credential,
value,
},
);
if (managed && (!isImmutableDaytonaImage(input.daytonaImage) || !api.post || !api.delete)) {
throw new Error("Managed onboarding requires the immutable image and normal environment create/cleanup APIs");
}
const required = managed ? [execution.profile.credential, "DAYTONA_API_KEY" as const] : [execution.profile.credential];
for (const name of required) if (!credentials[name]) throw new Error(`Missing credential ${name}`);
const previousSettings = managed ? await api.get<{
defaultEnvironmentId: string | null;
experimental: { enableManagedSandboxOnly: boolean };
}>("/api/instance/settings") : null;
if (managed && (!previousSettings ||
(previousSettings.defaultEnvironmentId !== null && typeof previousSettings.defaultEnvironmentId !== "string") ||
typeof previousSettings.experimental?.enableManagedSandboxOnly !== "boolean")) {
throw new Error("Managed onboarding fixture prior instance settings could not be read");
}
await api.patch(`/api/companies/${company.id}`, { budgetMonthlyCents: 500 });
let localEnvironment: LiveFixtureValues["environment"] | undefined;
if (!managed) {
const environments = await api.get<Array<LiveFixtureValues["environment"]>>(`/api/companies/${company.id}/environments?driver=local`);
localEnvironment = environments.find(candidate => candidate.driver === "local");
if (!localEnvironment) throw new Error("Onboarding fixture local environment missing");
}
const secretRefs: LiveFixtureValues["secretRefs"] = {};
for (const name of required) {
const secret = await api.postSensitive<{ id: string }>(`/api/companies/${company.id}/secrets`, {
name: `First task ${name}`, key: name, value: credentials[name],
});
secretRefs[name] = { type: "secret_ref", secretId: secret.id, version: "latest" };
}
let environment: LiveFixtureValues["environment"];
let teardown = async () => {};
if (managed) {
const remoteApi = api as Pick<RunnerApi, "post" | "delete">;
await installFixtureDaytonaProvider(remoteApi, execution);
environment = await remoteApi.post<LiveFixtureValues["environment"]>(`/api/companies/${company.id}/environments`,
execution.environment.buildEnvironment({ secretRefs, daytonaImage: input.daytonaImage, executionId: nonce }));
let restoreDefault = false;
let restoreManagedOnly = false;
let environmentDeleted = false;
teardown = async () => {
const failures: unknown[] = [];
if (restoreManagedOnly) {
try {
await api.patch("/api/instance/settings/experimental", { enableManagedSandboxOnly: previousSettings!.experimental.enableManagedSandboxOnly });
restoreManagedOnly = false;
} catch (error) { failures.push(error); }
}
if (restoreDefault) {
try {
await api.patch("/api/instance/settings", { defaultEnvironmentId: previousSettings!.defaultEnvironmentId });
restoreDefault = false;
} catch (error) { failures.push(error); }
}
if (!restoreDefault && !environmentDeleted) {
try {
await deleteDaytonaEnvironment(remoteApi, environment.id);
environmentDeleted = true;
} catch (error) { failures.push(error); }
}
if (failures.length) throw new AggregateError(failures, restoreDefault
? "Managed onboarding fixture cleanup failed; instance default restoration is unconfirmed and environment deletion was not attempted"
: "Managed onboarding fixture cleanup failed");
};
try {
restoreDefault = true;
await api.patch("/api/instance/settings", { defaultEnvironmentId: environment.id });
restoreManagedOnly = true;
await api.patch("/api/instance/settings/experimental", { enableManagedSandboxOnly: true });
} catch (error) {
try { await teardown(); } catch (cleanup) { throw new AggregateError([error, cleanup], "Managed onboarding fixture setup and cleanup failed"); }
throw error;
}
} else environment = localEnvironment!;
return {
company,
environment,
secretRefs: {
[credential]: {
type: "secret_ref",
secretId: secret.id,
version: "latest",
},
},
secretRefs,
agent: { id: "", name: `Garden lead ${nonce}`, companyId: company.id },
teardown: async () => {}, // Existing launcher removes the complete isolated instance.
...(managed ? { setupLeases: [], setupQualification: { recorded: false, adapterType: null, platformResponses: 0, adapterResponses: 0 } } : {}),
teardown,
};
}
/** Reuse the qualified runtime configuration only, never the QA persona from
* buildAgent. Native onboarding is not offered by the production wizard yet. */
export function firstTaskNativeRuntimePatch(
execution: MatrixExecution,
fixtures: LiveFixtureValues,
+289 -18
View File
@@ -10,9 +10,12 @@ import { observeCompletionUpdate } from "./completion-update-flow.js";
import {
firstTaskNativeRuntimePatch,
provisionFirstTaskFixtures,
isManagedFirstTask,
startFirstTaskWithRuntimeBound,
type FirstTaskFixtureValues,
} from "./first-task-fixtures.js";
import { execFileSync } from "node:child_process";
import { expect, type Page } from "@playwright/test";
import { expect, type Page, type Request, type Response } from "@playwright/test";
import { readFile } from "node:fs/promises";
import { pollUntil, type RunnerApi } from "./api.js";
import {
@@ -21,10 +24,11 @@ import {
collectChatRunEvidence,
} from "./chat-flow.js";
import type { LiveFixtureValues } from "./live-fixtures.js";
import type { CredentialName, MatrixExecution } from "./types.js";
import type { CredentialName, MatrixExecution, RunnerE2EResult } from "./types.js";
import { firstTaskScenario } from "./first-task-cases.js";
import {
activeRuns,
digestText,
snapshotInstruction,
gradeFirstTask,
firstTaskCompletionSettled,
@@ -33,16 +37,247 @@ import {
type Row,
} from "./first-task-scoring.js";
/** The wizard creates the agent/task. Only credential provisioning uses Node fetch,
* keeping plaintext keys out of Playwright's trace and recorded form values. */
export function projectFirstTaskResult(result: RunnerE2EResult): RunnerE2EResult {
const e = result.firstTask;
if (!e) return result;
const pick = (value: unknown, fields: readonly string[]): Row => {
const row = value && typeof value === "object" ? value as Record<string, unknown> : {};
return Object.fromEntries(fields.flatMap(key => {
const item = row[key];
return item === null || ["string", "number", "boolean"].includes(typeof item)
? [[key, item]] : [];
})) as Row;
};
const rows = (value: unknown): Row[] => Array.isArray(value)
? value.filter(row => row && typeof row === "object") : [];
const strings = (value: unknown): string[] => Array.isArray(value)
? value.filter((item): item is string => typeof item === "string") : [];
const usage = (value: unknown) => {
const u = value as Row | undefined;
const measurements = ["inputTokens", "outputTokens", "cachedInputTokens", "cacheWriteTokens", "cacheReadTokens", "cachedReadTokens", "input", "output", "promptTokens", "completionTokens", "cacheAdjustedCostUsd", "costUsd", "providerCostUsd", "costUsdExact"];
return {
...pick(u, [...measurements, "model", "provider", "biller", "billingType", "costStatus", "usageSource", "accountingReceiptReady"]),
...Object.fromEntries(["runDelta", "total", "cumulative"].filter(key => u?.[key]).map(key => [key, pick(u?.[key], measurements)])),
...(u?.cost ? { cost: pick(u.cost, ["currency", "amount", "total"]) } : {}),
...(u?.pricingProvenance ? { pricingProvenance: pick(u.pricingProvenance, ["source", "version", "contextTier", "serviceTier", "inputCentsPerMillion", "outputCentsPerMillion", "cacheWriteCentsPerMillion", "cachedInputCentsPerMillion"]) } : {}),
};
};
const questionSet = (value: unknown) => {
const set = value as Row | undefined;
return { ...pick(set, ["title", "description", "submitLabel"]), questions: rows(set?.questions).map(q => ({
...pick(q, ["id", "prompt", "header", "helpText", "required", "answerMode", "selectionMode", "allowOther"]),
options: rows(q.options).map(option => pick(option, ["id", "label", "description", "recommended", "freeText"])),
...(q.customAnswer ? { customAnswer: pick(q.customAnswer, ["enabled", "label", "placeholder"]) } : {}),
})) };
};
const interaction = (row: Row): Row => {
const payload = row.payload as Row | undefined;
const answers = row.result?.answers;
const answerRows: Row[] = Array.isArray(answers) ? rows(answers) : answers && typeof answers === "object"
? Object.entries(answers).map(([questionId, answer]) => ({ ...answer as Row, questionId })) : [];
return {
...pick(row, ["id", "issueId", "kind", "status", "createdAt", "resolvedAt", "resolvedByAgentId"]),
...(row.resolvedByUserId ? { resolvedByUserId: "fixture-board" } : {}),
payload: {
...pick(payload, ["version", "title", "prompt", "detailsMarkdown", "acceptLabel", "rejectLabel", "rejectRequiresReason", "rejectReasonLabel", "submitLabel"]),
...(payload?.questions ? questionSet(payload) : {}),
...(payload?.questionSet ? { questionSet: questionSet(payload.questionSet) } : {}),
...(payload?.target ? { target: pick(payload.target, ["type", "key", "label", "revisionNumber"]) } : {}),
},
...(row.result ? { result: {
...pick(row.result, ["outcome", "reason", "commentId"]),
answers: answerRows.map(answer => ({
...pick(answer, ["id", "questionId", "text", "otherText", "customText"]),
optionIds: strings(answer.optionIds ?? answer.selectedOptionIds),
})),
} } : {}),
};
};
const privateInstructions = "Full instructions are retained in the access-controlled first-task snapshot.";
const settings = e.runtimeSettings as Record<string, any> | undefined;
const checkpointIds = new Set(e.checkpoints.map(c => c.id));
const firstTask: FirstTaskEvidence = {
caseId: e.caseId, nonce: e.nonce, onboardingIssueId: e.onboardingIssueId,
agentId: e.agentId, initialTaskIds: [...e.initialTaskIds],
...(e.source ? { source: { sha: e.source.sha, ref: e.source.ref, dirty: e.source.dirty } } : {}),
configuredModel: e.configuredModel, observedModels: [...e.observedModels],
instructions: e.instructions.map(i => ({
path: /^(?:\/|[A-Za-z]:)|(?:^|\/)\.\.(?:\/|$)/.test(i.path) ? "private-instruction-path" : i.path,
sha256: i.sha256, content: privateInstructions, contentSha256: digestText(privateInstructions), redacted: true,
})),
runtimeSettings: {
...pick(settings, ["adapterType", "completionDeliveryProbe"]),
adapterConfig: pick(settings?.adapterConfig, ["provider", "model", "modelReasoningEffort", "lifecycleMode", "codexPermissionMode"]),
...(settings?.onboardingRuntime ? { onboardingRuntime: pick(settings.onboardingRuntime, ["mode", "originalAdapterType", "testedAdapterType", "originalModel"]) } : {}),
},
checkpoints: e.checkpoints.map(c => ({
id: c.id, at: c.at, phase: c.phase, issueId: c.issueId,
tasks: c.tasks.map(t => pick(t, ["id", "identifier", "title", "description", "status", "parentId", "assigneeAgentId", "createdAt", "completedAt"])),
agents: c.agents.map(a => ({ ...pick(a, ["id", "name", "adapterType"]),
adapterConfig: pick(a.adapterConfig, ["provider", "model", "modelReasoningEffort", "lifecycleMode", "codexPermissionMode"]) })),
comments: c.comments.map(comment => ({ ...pick(comment, ["id", "issueId", "body", "authorAgentId", "createdAt"]),
...(comment.authorUserId ? { authorUserId: "fixture-board" } : {}) })),
interactions: c.interactions.map(interaction),
documents: c.documents.map(d => pick(d, ["id", "issueId", "key", "title", "body", "format", "latestRevisionId", "latestRevisionNumber", "createdAt", "updatedAt"])),
attachments: (c.attachments ?? []).map(a => pick(a, ["id", "issueId", "title", "originalFilename", "filename", "body", "contentVerified", "contentSha256", "byteSize", "createdAt"])),
runs: c.runs.map(run => ({ ...pick(run, ["id", "issueId", "status", "startedAt", "finishedAt", "runtimeMode", "driverKind"]),
...pick({ model: run.resultJson?.model ?? run.usageJson?.model }, ["model"]) })),
})),
checks: e.checks.map(check => ({
id: check.id, passed: check.passed,
...(check.notReached ? { notReached: "The journey did not reach this check; private evidence retains the reason." } : {}),
evidence: check.evidence.filter(ref => checkpointIds.has(ref)),
detail: `${check.id}: ${check.notReached ? "not reached" : check.passed ? "passed" : "failed"}. Full diagnostic evidence is retained in the access-controlled snapshot.`,
})),
};
return {
...result, firstTask,
...(result.usage ? { usage: Array.isArray(result.usage.runs)
? { runs: rows(result.usage.runs).map(run => ({ runId: run.runId, usage: run.usage ? usage(run.usage) : null })) }
: usage(result.usage) } : {}),
...(result.error ? { error: "First-task journey failed; inspect the recorded checks and access-controlled evidence for the original error." } : {}),
matcherResults: (result.matcherResults ?? []).map(matcher => ({
...matcher,
detail: matcher.matcher.kind === "json_path"
? firstTask.checks.find(check => matcher.matcher.kind === "json_path" && matcher.matcher.path === `firstTask.checks.${check.id}`)?.detail
?? `${matcher.matcher.kind}: ${matcher.passed ? "passed" : "failed"}. Private evidence retains diagnostics.`
: `${matcher.matcher.kind}: ${matcher.passed ? "passed" : "failed"}. Private evidence retains diagnostics.`,
})),
};
}
export function managedFirstTaskFileCheck(e: FirstTaskEvidence) {
const last = e.checkpoints.at(-1);
const children = last?.tasks.filter(task => task.parentId === e.onboardingIssueId && task.status === "done") ?? [];
const marker = firstTaskScenario(e.caseId, e.nonce).marker;
const passed = children.length === 1 && (last?.attachments ?? []).some(attachment =>
attachment.issueId === children[0]!.id && attachment.originalFilename === "garden-welcome.txt"
&& /^text\/(?:plain|markdown)(?:;|$)/i.test(String(attachment.contentType))
&& attachment.contentVerified === true && typeof attachment.body === "string"
&& attachment.contentSha256 === digestText(attachment.body) && attachment.body.includes(marker));
return { id: "managed-onboarding-physical-artifact", passed, evidence: last ? [last.id] : [],
detail: "The completed child has a downloaded, size/hash-verified garden-welcome.txt attachment with the requested marker." };
}
export async function observeManagedFirstTaskSetup(page: Page, api: RunnerApi, fixtures: LiveFixtureValues, deadlineAt: number) {
const before = new Set((await api.get<Row[]>(`/api/environments/${fixtures.environment.id}/leases`)).map(lease => lease.id));
const startedAt = Date.now();
const qualification = fixtures.setupQualification!;
const operations: Promise<void>[] = [];
let rejectFailure!: (error: unknown) => void;
const failure = new Promise<never>((_resolve, reject) => { rejectFailure = reject; });
void failure.catch(() => undefined);
const requestStarts = new WeakMap<Request, number>();
const pendingSetupRequests = new Set<Request>();
let platformEndedAt = Infinity;
let qualifiedDefault = false;
let automaticRequest = false;
function phase(request: Request): "platform" | "adapter" | "discovery" | null {
const url = new URL(request.url());
if (request.method() === "POST" && url.pathname === `/api/environments/${fixtures.environment.id}/probe`
&& url.searchParams.get("companyId") === fixtures.company.id) return "platform";
if (request.method() === "POST" && url.pathname === `/api/companies/${fixtures.company.id}/adapters/codex_local/test-environment`) return "adapter";
if (request.method() === "GET" && url.pathname === "/api/adapters" && url.searchParams.get("companyId") === fixtures.company.id
&& url.searchParams.get("environmentId") === fixtures.environment.id) return "discovery";
return null;
}
const onRequest = (request: Request) => {
const kind = phase(request);
if (kind) requestStarts.set(request, Date.now());
if (kind === "platform" || kind === "adapter") pendingSetupRequests.add(request);
};
const onRequestFailed = (request: Request) => {
if (pendingSetupRequests.has(request)) fixtures.setupLeaseAccountingIncomplete = true;
};
const onResponse = (response: Response) => {
const request = response.request();
const kind = phase(request);
if (!kind) return;
const responseAt = Date.now();
if (kind === "platform") { qualification.platformResponses++; platformEndedAt = responseAt; }
if (kind === "adapter") qualification.adapterResponses++;
if (qualification.platformResponses > 1 || qualification.adapterResponses > 1) rejectFailure(new Error("The UI repeated a managed setup request"));
const operation = (async () => {
const body = await response.json() as Row | Row[];
if (kind === "discovery") {
if (Array.isArray(body) && responseAt >= platformEndedAt) {
const codex = body.find(adapter => adapter.type === "codex_local");
qualifiedDefault = codex?.defaultRunner === "paperclip" && codex?.supportedRunners?.includes("paperclip") === true;
if (!qualifiedDefault) rejectFailure(new Error("The selected image is not qualified for native Codex onboarding"));
}
return;
}
const data = body as Row;
if (kind === "platform") {
qualification.recorded = response.ok() && data.ok === true && data.details?.platformQualificationRecorded === true;
if (!qualification.recorded) rejectFailure(new Error("The UI environment qualification did not pass"));
}
else {
qualification.adapterType = response.ok() && data.status === "pass" ? data.adapterType : null;
if (qualification.adapterType !== "paperclip_runner") rejectFailure(new Error("The UI native runtime setup did not pass"));
automaticRequest = request.postDataJSON()?.runner === undefined && (requestStarts.get(request) ?? 0) >= platformEndedAt;
}
const leaseId = kind === "platform" ? data.details?.leaseId
: /(?:^|;\s*)paperclipLeaseId=([a-zA-Z0-9-]+)(?:;|$)/.exec(String(data.checks?.find((check: Row) => check.code === "sandbox_test_identity")?.detail ?? ""))?.[1];
if (typeof leaseId !== "string" || !leaseId) {
fixtures.setupLeaseAccountingIncomplete = true;
return;
}
const lease = await api.get<Row>(`/api/environment-leases/${encodeURIComponent(leaseId)}`, { timeout: 10_000 });
if (before.has(leaseId) || lease.companyId !== fixtures.company.id || lease.environmentId !== fixtures.environment.id
|| lease.provider !== "daytona" || lease.heartbeatRunId || lease.issueId) throw new Error("Setup response references a foreign or non-setup lease");
pendingSetupRequests.delete(request);
if (!fixtures.setupLeases!.some(entry => entry.leaseId === leaseId)) fixtures.setupLeases!.push({ leaseId, phase: kind,
durationMs: responseAt - (requestStarts.get(request) ?? startedAt) });
})();
void operation.catch(error => { fixtures.setupLeaseAccountingIncomplete = true; rejectFailure(error); });
operations.push(operation);
};
page.on("request", onRequest);
page.on("response", onResponse);
page.on("requestfailed", onRequestFailed);
const stop = async (requireSuccess: boolean) => {
page.off("request", onRequest);
page.off("response", onResponse);
page.off("requestfailed", onRequestFailed);
const settled = await Promise.allSettled(operations);
if (pendingSetupRequests.size > 0) fixtures.setupLeaseAccountingIncomplete = true;
const failures = settled.flatMap(result => result.status === "rejected" ? [result.reason] : []);
let leases: Row[];
try { leases = await api.get<Row[]>(`/api/environments/${fixtures.environment.id}/leases`, { timeout: 10_000 }); }
catch (error) { fixtures.setupLeaseAccountingIncomplete = true; throw error; }
for (const lease of leases) {
if (before.has(lease.id) || lease.companyId !== fixtures.company.id || lease.environmentId !== fixtures.environment.id
|| lease.provider !== "daytona" || lease.heartbeatRunId || lease.issueId) continue;
if (!fixtures.setupLeases!.some(entry => entry.leaseId === lease.id)) fixtures.setupLeaseAccountingIncomplete = true;
}
if (failures.length) throw new AggregateError(failures, "Managed onboarding setup evidence could not be verified");
if (!requireSuccess) return;
if (!qualification.recorded || qualification.platformResponses !== 1 || qualification.adapterResponses !== 1
|| qualification.adapterType !== "paperclip_runner" || !automaticRequest || !qualifiedDefault
|| fixtures.setupLeaseAccountingIncomplete || fixtures.setupLeases!.length !== 2 || !["platform", "adapter"].every(kind => fixtures.setupLeases!.some(lease => lease.phase === kind))) {
throw new Error("The UI did not prove one qualified automatic native setup with both owned leases");
}
await pollUntil({ label: "managed onboarding setup lease cleanup", deadlineAt: Math.min(deadlineAt, Date.now() + 120_000),
load: () => api.get<Row[]>(`/api/environments/${fixtures.environment.id}/leases`),
accept: rows => fixtures.setupLeases!.every(entry => rows.some(row => row.id === entry.leaseId
&& row.companyId === fixtures.company.id && row.environmentId === fixtures.environment.id
&& row.status === "released" && row.cleanupStatus === "success")),
});
};
return Object.assign(stop, { failure });
}
export async function setupFirstTaskFixtures(input: {
page: Page;
api: RunnerApi;
execution: MatrixExecution;
nonce: string;
credentials: Partial<Record<CredentialName, string>>;
daytonaImage?: string;
deadlineAt?: number;
observe: (fixtures: LiveFixtureValues) => void;
}): Promise<LiveFixtureValues> {
}): Promise<FirstTaskFixtureValues> {
const { page, api, execution, nonce } = input;
await api.patch("/api/instance/settings/experimental", {
enableClassicTaskInterface: false,
@@ -67,6 +302,7 @@ export async function setupFirstTaskFixtures(input: {
execution,
nonce,
credentials: input.credentials,
daytonaImage: input.daytonaImage,
company: {
id: company.id,
name: company.name,
@@ -93,33 +329,48 @@ export async function setupFirstTaskFixtures(input: {
exact: true,
});
const savedKey = page.getByRole("combobox", { name: "Saved API key" });
if (execution.profile.id === "legacy-codex") {
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();
}
// Credential mode depends on the selected provider and its asynchronous key lookup.
await expect(savedKey.or(useKey).first()).toBeVisible();
if (await useKey.isVisible()) await useKey.click();
await page
.getByRole("combobox", { name: "Saved API key" })
.selectOption(`company:${secret.secretId}`);
await page
.getByRole("button", { name: /^(Connect|Next)$/, exact: true })
.last()
.click();
await expect(
page.getByRole("button", { name: "Get started", exact: true }),
).toBeVisible({ timeout: 120_000 });
const managed = isManagedFirstTask(execution);
if (managed && (!input.deadlineAt || input.deadlineAt - Date.now() <= 120_000)) throw new Error("Managed onboarding has no bounded setup deadline");
const stopSetup = managed ? await observeManagedFirstTaskSetup(page, api, fixtures, input.deadlineAt! - 60_000) : null;
let setupError: unknown;
try {
await page.getByRole("button", { name: /^(Connect|Next)$/, exact: true }).last().click();
const visible = expect(page.getByRole("button", { name: "Get started", exact: true })).toBeVisible({
timeout: managed ? input.deadlineAt! - Date.now() - 120_000 : 120_000,
});
if (stopSetup) await Promise.race([visible, stopSetup.failure]); else await visible;
} catch (error) { setupError = error; }
try { await stopSetup?.(!setupError); }
catch (error) { setupError = setupError ? new AggregateError([setupError, error], "Managed onboarding and setup evidence failed") : error; }
if (setupError) throw setupError;
const agents = await api.get<Row[]>(`/api/companies/${company.id}/agents`);
expect(agents).toHaveLength(1);
const wizardAdapter =
execution.profile.credential === "OPENAI_API_KEY"
? "codex_local"
? execution.profile.id === "runner-codex" ? "paperclip_runner" : "codex_local"
: "claude_local";
expect(agents[0].adapterType).toBe(wizardAdapter);
if (execution.profile.id === "runner-codex") {
expect(agents[0].adapterConfig?.provider).toBe("codex");
}
fixtures.onboardingRuntime = {
mode: "production-wizard",
originalAdapterType: wizardAdapter,
testedAdapterType: wizardAdapter,
originalModel: agents[0].adapterConfig?.model ?? null,
};
if (execution.profile.generation === "native") {
if (execution.profile.id === "runner-acpx-claude") {
const runtimePatch = firstTaskNativeRuntimePatch(
execution,
fixtures,
@@ -154,16 +405,27 @@ export async function setupFirstTaskFixtures(input: {
name: agents[0].name,
};
input.observe(fixtures);
await page.getByRole("button", { name: "Get started", exact: true }).click();
if (managed) {
await page.reload({ waitUntil: "domcontentloaded" });
const persisted = await api.get<Row>(`/api/agents/${fixtures.agent.id}`);
expect(persisted.adapterType).toBe("paperclip_runner");
expect(persisted.adapterConfig?.provider).toBe("codex");
}
await startFirstTaskWithRuntimeBound({
api, execution, agent: agents[0],
observe: bound => { fixtures.onboardingRuntimeBound = bound; input.observe(fixtures); },
start: () => page.getByRole("button", { name: "Get started", exact: true }).click(),
});
return fixtures;
}
export async function runFirstTaskFlow(input: {
page: Page;
api: RunnerApi;
fixtures: LiveFixtureValues;
fixtures: FirstTaskFixtureValues;
execution: MatrixExecution;
nonce: string;
deadlineAt?: number;
observe: (issue: any, runs: any[], evidence: FirstTaskEvidence) => void;
secrets: readonly string[];
createOrdinary: (title: string, prompt: string) => Promise<Row>;
@@ -172,8 +434,14 @@ export async function runFirstTaskFlow(input: {
}) {
const { page, api, fixtures, execution, nonce } = input;
const scenario = firstTaskScenario(execution.task.id, nonce);
const deadlineAt =
Date.now() + execution.task.attemptTimeoutMs.local - 120_000;
const managed = isManagedFirstTask(execution);
if (managed) {
const attachment = "Attach the finished note as a UTF-8 text file named garden-welcome.txt.";
scenario.prompt += ` ${attachment}`;
scenario.facts += ` ${attachment}`;
}
const deadlineAt = managed ? (input.deadlineAt ?? Date.now()) - 120_000
: Date.now() + execution.task.attemptTimeoutMs.local - 120_000;
const tasksPath = `/api/companies/${fixtures.company.id}/issues?limit=100`;
const allRuns = async () => {
const runs = await api.get<Row[]>(
@@ -258,6 +526,7 @@ export async function runFirstTaskFlow(input: {
};
e.checkpoints.push(checkpoint);
e.checks = gradeFirstTask(e);
if (managed && e.checkpoints.at(-1)?.phase === "finished") e.checks.push(managedFirstTaskFileCheck(e));
if (execution.suite.id === "confirmation-replies" && scenario.id !== "task-card-accept") e.checks.push(...gradeConfirmationReply(e));
input.observe(issue, runs, e);
await input.evidence("first-task.json", e);
@@ -348,6 +617,7 @@ export async function runFirstTaskFlow(input: {
e.runtimeSettings = {
completionDeliveryProbe: runsCompletionUpdateProbe(execution),
onboardingRuntime: fixtures.onboardingRuntime,
...(fixtures.onboardingRuntimeBound ? { onboardingRuntimeBound: fixtures.onboardingRuntimeBound } : {}),
adapterType: agent.adapterType,
adapterConfig: agent.adapterConfig,
runtimeConfig: agent.runtimeConfig,
@@ -619,6 +889,7 @@ export async function runFirstTaskFlow(input: {
await snapshot("finished");
}
e.checks = gradeFirstTask(e);
if (managed && e.checkpoints.at(-1)?.phase === "finished") e.checks.push(managedFirstTaskFileCheck(e));
if (execution.suite.id === "confirmation-replies" && scenario.id !== "task-card-accept") e.checks.push(...gradeConfirmationReply(e));
await input.evidence("first-task.json", e);
await input.capture(
+532 -2
View File
@@ -6,9 +6,15 @@ import { firstTaskRejectionReplyRecorded, isFirstTaskRejectionCancellation } fro
import { createIssueThreadInteractionSchema } from "../../packages/shared/src/validators/issue.js";
import { renderInteractionCard } from "./interaction-report.js";
import { main as judgeCommand } from "./first-task-judge.js";
import { projectFirstTaskResult, managedFirstTaskFileCheck, observeManagedFirstTaskSetup } from "./first-task-flow.js";
import { validateRetainedRunnerResult } from "./result-validation.js";
import { buildRuntimeUsage } from "./billing.js";
import { fixtureRuntimeCostCoverage, selectFixtureRuntimeLeases } from "./live-fixtures.js";
import {
firstTaskNativeRuntimePatch,
provisionFirstTaskFixtures,
startFirstTaskWithRuntimeBound,
MANAGED_FIRST_TASK_CELL,
} from "./first-task-fixtures.js";
import { describe, expect, it, vi } from "vitest";
import { mkdtemp, mkdir, writeFile, readFile, rm } from "node:fs/promises";
@@ -31,6 +37,7 @@ import {
QUALITY_DIMENSIONS,
judgeFirstTask,
pendingQuality,
qualityInput,
validateQualityScores,
} from "./first-task-quality.js";
import { renderFirstTaskDetails } from "./first-task-report.js";
@@ -216,6 +223,82 @@ const scores = () =>
evidence: ["response-1"],
}));
describe("first-task public result projection", () => {
it("keeps fixture-visible evidence while excluding private records from both normalized result fields", () => {
const e = recording();
const sentinel = "PRIVATE_SESSION_ACCOUNT_SENTINEL";
e.instructions[0].content = sentinel;
e.instructions[0].path = `/private/${sentinel}/AGENTS.md`;
e.runtimeSettings = {
adapterType: "paperclip_runner",
adapterConfig: { provider: "codex", model: "gpt-5.6-sol", env: { OPENAI_API_KEY: sentinel }, codexHome: sentinel },
runtimeConfig: { accountId: sentinel }, permissions: { privateTrace: sentinel },
onboardingRuntime: { mode: "production-wizard", originalAdapterType: "paperclip_runner", testedAdapterType: "paperclip_runner", accountId: sentinel },
onboardingRuntimeBound: { timeoutSec: 180, maxConcurrentRuns: 1, before: { runtimeConfig: { accountId: sentinel } }, after: { runtimeConfig: { accountId: sentinel } } },
};
for (const checkpoint of e.checkpoints) {
checkpoint.tasks.forEach(t => Object.assign(t, { executionState: { sessionId: sentinel }, responsibleUserId: sentinel }));
checkpoint.agents.forEach(a => Object.assign(a, { accountId: sentinel, metadata: { auth: sentinel }, adapterType: "paperclip_runner",
adapterConfig: { provider: "codex", model: "gpt-5.6-sol", env: { TOKEN: sentinel }, home: sentinel } }));
checkpoint.comments.forEach(c => Object.assign(c, { hiddenReasoning: sentinel, metadata: { sessionId: sentinel },
...(c.authorUserId ? { authorUserId: sentinel } : {}) }));
checkpoint.interactions.forEach(i => Object.assign(i, { nativeQuestionId: sentinel, metadata: { sessionId: sentinel },
payload: { ...i.payload, providerSessionId: sentinel, toolAction: { secret: sentinel } },
result: { ...i.result, outcome: "answered", refreshToken: sentinel } }));
checkpoint.documents.forEach(d => Object.assign(d, { privateStorageRef: sentinel }));
checkpoint.runs.forEach(r => Object.assign(r, { nativeSessionId: sentinel, sessionIdBefore: sentinel, sessionIdAfter: sentinel,
runnerProfileJson: { sessionCheckpoint: { providerSessionId: sentinel } }, contextSnapshot: { home: sentinel },
resultJson: { model: "gpt-5.6-sol", privateAuth: sentinel }, stdoutExcerpt: sentinel, error: sentinel }));
}
e.checks = gradeFirstTask(e);
e.checks.push({ id: "native-session-continuity", passed: false, evidence: ["finished-3"], detail: sentinel });
const original = structuredClone(e);
const raw = { ...result(e), error: sentinel, failureClass: "candidate_failure" as const,
usage: { runs: [{ runId: "parent-run", usage: { inputTokens: 100, outputTokens: 20, cachedInputTokens: 30,
costUsdExact: "0.499592800", costUsd: null, costStatus: "estimated", accountingReceiptReady: true,
pricingProvenance: { source: "rate_card", version: "openai-standard-2026-09-30", accountId: sentinel },
persistedSessionId: sentinel, providerRequestId: sentinel, accountingReceiptSourceId: sentinel } }] },
matcherResults: e.checks.map(c => ({ matcher: { kind: "json_path" as const, path: `firstTask.checks.${c.id}`, expected: true }, passed: c.passed, detail: c.detail })) };
raw.matcherResults.push({ matcher: { kind: "json_path", path: "cleanup.provider-retired", expected: true }, passed: false, detail: sentinel });
const projected = projectFirstTaskResult(raw);
const normalized = { results: [projected] };
expect(JSON.stringify(normalized)).not.toContain(sentinel);
expect(JSON.stringify(raw)).toContain(sentinel);
expect(e).toEqual(original);
expect(projected.firstTask!.checks.map(c => ({ id: c.id, passed: c.passed, notReached: Boolean(c.notReached) })))
.toEqual(e.checks.map(c => ({ id: c.id, passed: c.passed, notReached: Boolean(c.notReached) })));
expect(projected.matcherResults!.map(c => ({ matcher: c.matcher, passed: c.passed })))
.toEqual(raw.matcherResults.map(c => ({ matcher: c.matcher, passed: c.passed })));
expect(projected.firstTask!.runtimeSettings).toMatchObject({ adapterType: "paperclip_runner", adapterConfig: { provider: "codex", model: "gpt-5.6-sol" } });
expect(projected.usage).toMatchObject({ runs: [{ runId: "parent-run", usage: { inputTokens: 100, outputTokens: 20,
cachedInputTokens: 30, costUsdExact: "0.499592800", costStatus: "estimated", accountingReceiptReady: true,
pricingProvenance: { source: "rate_card", version: "openai-standard-2026-09-30" } } }] });
expect(summarizeExecutionBilling(projected)).toEqual(summarizeExecutionBilling(raw));
expect(qualityInput(projected.firstTask!)).toContain(firstTaskScenario(e.caseId, e.nonce).acceptance);
expect(qualityInput(projected.firstTask!)).toContain(firstTaskScenario(e.caseId, e.nonce).marker);
expect(renderFirstTaskDetails(projected)).not.toContain(sentinel);
expect(projected.firstTask!.checkpoints.at(-1)!.documents[0].body).toBe(e.checkpoints.at(-1)!.documents[0].body);
validateRetainedRunnerResult(projected);
});
it("preserves failures, unreached checks, cleanup controls and private artifact hashes", () => {
const e = recording();
e.checks = [{ id: "recorded-response", passed: false, evidence: ["response-1"], detail: "private diagnostic" },
{ id: "durable-completion", passed: false, notReached: "private interrupted-session reason", evidence: ["finished-3"], detail: "private diagnostic" }];
const raw = { ...result(e), error: "private transport/session error", failureClass: "candidate_failure" as const, cleanup: "failed" as const,
matcherResults: [{ matcher: { kind: "json_path" as const, path: "cleanup.provider-retired", expected: true }, passed: false, detail: "private process details" }] };
const projected = projectFirstTaskResult(raw);
expect(projected).toMatchObject({ status: "failed", failureClass: "candidate_failure", cleanup: "failed" });
expect(projected.firstTask!.checks[0].passed).toBe(false);
expect(projected.firstTask!.checks[1]).toMatchObject({ passed: false, notReached: expect.any(String) });
expect(projected.matcherResults![0]).toMatchObject({ matcher: raw.matcherResults[0].matcher, passed: false });
expect(projected.firstTask!.instructions[0]).toMatchObject({ sha256: e.instructions[0].sha256, redacted: true });
expect(raw.error).toBe("private transport/session error");
expect(projected.error).not.toContain("private transport/session error");
expect(projectFirstTaskResult(projected)).toEqual(projected);
});
});
describe("first-task attachment evidence", () => {
it("reads persisted bytes, verifies their hash, and redacts the retained text", async () => {
const body = "Welcome SECRET-VALUE";
@@ -695,9 +778,10 @@ Accept the card above and I write it. This task stays in review until then.`;
(e) => e.suite.id === "first-task" && e.profile.id === "legacy-codex",
)!;
const get = vi.fn().mockResolvedValue([{ id: "local", driver: "local" }]);
const patch = vi.fn().mockResolvedValue({});
const postSensitive = vi.fn().mockResolvedValue({ id: "encrypted-secret" });
const fixtures = await provisionFirstTaskFixtures({
api: { get, postSensitive },
api: { get, patch, postSensitive },
execution,
nonce: "test",
company: {
@@ -710,6 +794,9 @@ Accept the card above and I write it. This task stays in review until then.`;
expect(get).toHaveBeenCalledExactlyOnceWith(
"/api/companies/ui-created-company/environments?driver=local",
);
expect(patch).toHaveBeenCalledExactlyOnceWith(
"/api/companies/ui-created-company", { budgetMonthlyCents: 500 },
);
expect(postSensitive).toHaveBeenCalledExactlyOnceWith(
"/api/companies/ui-created-company/secrets",
expect.objectContaining({ key: "OPENAI_API_KEY", value: "fixture-key" }),
@@ -723,7 +810,7 @@ Accept the card above and I write it. This task stays in review until then.`;
expect(JSON.stringify(fixtures)).not.toContain("fixture-key");
await expect(
provisionFirstTaskFixtures({
api: { get, postSensitive },
api: { get, patch, postSensitive },
execution,
nonce: "test",
company: fixtures.company,
@@ -733,6 +820,110 @@ Accept the card above and I write it. This task stays in review until then.`;
expect(postSensitive).toHaveBeenCalledTimes(1);
});
describe("bounded automatic Codex onboarding admission", () => {
function harness(environment = "local", profile = "runner-codex") {
const execution = runnerMatrix.find(entry => entry.task.flow === "first_task" && entry.profile.id === profile && entry.environment.id === environment)!;
const original = {
id: "agent", companyId: "company", adapterType: "paperclip_runner",
adapterConfig: { provider: "codex", timeoutSec: 0, model: "gpt-5.6-sol", modelReasoningEffort: "high", codexPermissionMode: "readOnly",
env: { OPENAI_API_KEY: { type: "secret_ref", secretId: "encrypted-key", version: "latest" } }, paperclipSkillSync: { desiredSkills: ["paperclipai/paperclip/first-task"] } },
runtimeConfig: { aiConnection: { provider: "openai", method: "api_key", mode: "responsible_user" },
heartbeat: { maxConcurrentRuns: 20, enabled: false, intervalSec: 0, wakeOnAssignment: true, customHeartbeatSetting: "retained" }, customSetting: "retained" },
permissions: { canCreateAgents: true }, defaultEnvironmentId: environment, budgetMonthlyCents: 500, metadata: { ownerUserId: "qa-user" },
};
let stored = structuredClone(original);
const calls: string[] = [];
const api = {
get: vi.fn(async (_path: string) => { calls.push("GET"); return structuredClone(stored); }),
patch: vi.fn(async (_path: string, data: any) => {
calls.push("PATCH");
stored.adapterConfig = { ...stored.adapterConfig, ...data.adapterConfig };
if (data.runtimeConfig) stored.runtimeConfig = structuredClone(data.runtimeConfig);
return structuredClone(stored);
}),
};
const observe = vi.fn(() => { calls.push("OBSERVE"); });
const start = vi.fn(async () => { calls.push("START"); });
const admit = () => startFirstTaskWithRuntimeBound({ api: api as any, execution, agent: original, observe, start });
return { api, original, calls, observe, start, admit, stored: () => stored };
}
it.each(["local", "daytona"])("bounds %s without choosing a runner, provider, model, or account", async environment => {
const h = harness(environment);
const original = structuredClone(h.original);
const expected = { ...original, adapterConfig: { ...original.adapterConfig, timeoutSec: 180 },
runtimeConfig: { ...original.runtimeConfig, heartbeat: { ...original.runtimeConfig.heartbeat, maxConcurrentRuns: 1 } } };
await h.admit();
expect(h.stored()).toEqual(expected);
expect(h.api.patch).toHaveBeenCalledExactlyOnceWith("/api/agents/agent", { adapterConfig: { timeoutSec: 180 }, runtimeConfig: expected.runtimeConfig });
expect(h.calls).toEqual(["GET", "PATCH", "GET", "OBSERVE", "START"]);
expect(h.original).toEqual(original);
expect(h.observe).toHaveBeenCalledExactlyOnceWith({ timeoutSec: 180, maxConcurrentRuns: 1, before: original, after: expected });
});
it.each(["read-failure", "patch-failure", "verification-failure", "missing-timeout", "missing-concurrency"])("does not admit task execution after %s", async mode => {
const h = harness();
if (mode === "read-failure") h.api.get.mockRejectedValue(new Error("Agent read failed"));
if (mode === "patch-failure") h.api.patch.mockRejectedValue(new Error("Timeout patch failed"));
if (mode === "verification-failure") h.api.get.mockResolvedValueOnce(structuredClone(h.original)).mockRejectedValueOnce(new Error("Verification read failed"));
if (mode === "missing-timeout") h.api.patch.mockImplementation(async (_path, data) => {
h.stored().runtimeConfig = structuredClone(data.runtimeConfig);
return structuredClone(h.stored());
});
if (mode === "missing-concurrency") h.api.patch.mockImplementation(async (_path, data) => {
h.stored().adapterConfig = { ...h.stored().adapterConfig, ...data.adapterConfig };
return structuredClone(h.stored());
});
await expect(h.admit()).rejects.toThrow();
expect(h.start).not.toHaveBeenCalled();
expect(h.observe).not.toHaveBeenCalled();
if (mode === "read-failure") expect(h.api.patch).not.toHaveBeenCalled();
});
it.each(["legacy", "wrong-provider", "foreign-agent", "changed-account"])("rejects %s before changing the automatic wizard choice", async mode => {
const h = harness();
if (mode === "legacy") h.original.adapterType = "codex_local";
if (mode === "wrong-provider") h.original.adapterConfig.provider = "claude";
if (mode === "foreign-agent") h.api.get.mockResolvedValue({ ...h.original, companyId: "another-company" });
if (mode === "changed-account") h.api.get.mockResolvedValue({ ...h.original, runtimeConfig: { ...h.original.runtimeConfig,
aiConnection: { ...h.original.runtimeConfig.aiConnection, method: "subscription" } } });
await expect(h.admit()).rejects.toThrow();
expect(h.api.patch).not.toHaveBeenCalled();
expect(h.start).not.toHaveBeenCalled();
expect(h.observe).not.toHaveBeenCalled();
});
it.each(["runner", "provider", "model", "account", "permissions", "environment", "instructions", "heartbeat-policy", "other-runtime", "missing-heartbeat"])("refuses a persisted %s change instead of treating the bound PATCH as proof", async field => {
const h = harness();
const altered = structuredClone(h.original);
altered.adapterConfig.timeoutSec = 180;
altered.runtimeConfig.heartbeat.maxConcurrentRuns = 1;
if (field === "runner") altered.adapterType = "codex_local";
if (field === "provider") altered.adapterConfig.provider = "claude";
if (field === "model") altered.adapterConfig.model = "another-model";
if (field === "account") altered.runtimeConfig.aiConnection.mode = "fixed";
if (field === "permissions") altered.permissions.canCreateAgents = false;
if (field === "environment") altered.defaultEnvironmentId = "another-environment";
if (field === "instructions") altered.adapterConfig.paperclipSkillSync.desiredSkills = [];
if (field === "heartbeat-policy") altered.runtimeConfig.heartbeat.wakeOnAssignment = false;
if (field === "other-runtime") altered.runtimeConfig.customSetting = "lost";
if (field === "missing-heartbeat") delete (altered.runtimeConfig as Partial<typeof altered.runtimeConfig>).heartbeat;
h.api.get.mockResolvedValueOnce(structuredClone(h.original)).mockResolvedValueOnce(altered);
await expect(h.admit()).rejects.toThrow("runtime bound was not confirmed");
expect(h.start).not.toHaveBeenCalled();
expect(h.observe).not.toHaveBeenCalled();
});
it.each(["legacy-codex", "legacy-claude", "runner-acpx-claude"])("leaves %s admission unchanged", async profile => {
const h = harness("local", profile);
await h.admit();
expect(h.api.get).not.toHaveBeenCalled();
expect(h.api.patch).not.toHaveBeenCalled();
expect(h.observe).not.toHaveBeenCalled();
expect(h.start).toHaveBeenCalledTimes(1);
});
});
it.each(["runner-codex", "runner-acpx-claude"])(
"switches only the runtime for %s, preserving onboarding assets and the default model",
(id) => {
@@ -1360,3 +1551,342 @@ describe("recorded refusal cancellation", () => {
}
});
});
describe("managed onboarding through the production wizard", () => {
const image = `registry.example.test/runtime@sha256:${"a".repeat(64)}`;
const execution = () => runnerMatrix.find(entry => entry.id === MANAGED_FIRST_TASK_CELL)!;
function managedFixtureApi(previousDefault: string | null = "previous-default", previousManagedOnly = false) {
const state = { defaultEnvironmentId: previousDefault, experimental: { enableManagedSandboxOnly: previousManagedOnly, enableAgentChat: true } };
const calls: string[] = [];
const api = {
get: vi.fn(async () => structuredClone(state)),
patch: vi.fn(async (path: string, data: any) => {
calls.push(`PATCH ${path}`);
if (path === "/api/instance/settings") state.defaultEnvironmentId = data.defaultEnvironmentId;
if (path === "/api/instance/settings/experimental") Object.assign(state.experimental, data);
return structuredClone(state);
}),
postSensitive: vi.fn(async (_path: string, data: any) => ({ id: `encrypted-${data.key}` })),
post: vi.fn(async (path: string, data: any) => {
calls.push(`POST ${path}`);
return path === "/api/plugins/install" ? { id: "plugin", status: "ready" } : { id: "environment", driver: "sandbox", config: data.config };
}),
delete: vi.fn(async (path: string) => { calls.push(`DELETE ${path}`); }),
};
const provision = () => provisionFirstTaskFixtures({ api: api as any, execution: execution(), nonce: "private", company: { id: "company", name: "Fresh" },
daytonaImage: image, credentials: { OPENAI_API_KEY: "synthetic-key", DAYTONA_API_KEY: "synthetic-platform" } });
return { api, state, calls, provision };
}
it.each([[null, false], ["previous-default", false], ["previous-default", true]] as const)(
"restores prior default %s and managed-only %s before deleting the managed fixture", async (previousDefault, previousManagedOnly) => {
const h = managedFixtureApi(previousDefault, previousManagedOnly);
const fixtures = await h.provision();
expect(h.state).toMatchObject({ defaultEnvironmentId: "environment", experimental: { enableManagedSandboxOnly: true } });
let deletedState: typeof h.state | undefined;
h.api.delete.mockImplementation(async () => { deletedState = structuredClone(h.state); });
await fixtures.teardown();
expect(deletedState).toMatchObject({ defaultEnvironmentId: previousDefault, experimental: { enableManagedSandboxOnly: previousManagedOnly } });
expect(h.api.get).toHaveBeenCalledExactlyOnceWith("/api/instance/settings");
expect(h.api.patch).toHaveBeenLastCalledWith("/api/instance/settings", { defaultEnvironmentId: previousDefault });
expect(h.state.experimental.enableAgentChat).toBe(true);
expect(h.api.delete).toHaveBeenCalledExactlyOnceWith("/api/environments/environment?destroyReusableSandboxLeases=true", { allowNotFound: true });
},
);
it.each(["default", "managed-only"])("restores a %s setup write whose response is lost after committing", async phase => {
const h = managedFixtureApi();
const patch = h.api.patch.getMockImplementation()!;
let failed = false;
h.api.patch.mockImplementation(async (path, data) => {
const result = await patch(path, data);
if (!failed && (phase === "default" ? data.defaultEnvironmentId === "environment" : data.enableManagedSandboxOnly === true)) {
failed = true;
throw new Error("Setup response lost after commit");
}
return result;
});
await expect(h.provision()).rejects.toThrow("Setup response lost after commit");
expect(h.state).toEqual({ defaultEnvironmentId: "previous-default", experimental: { enableManagedSandboxOnly: false, enableAgentChat: true } });
expect(h.api.delete).toHaveBeenCalledTimes(1);
if (phase === "default") expect(h.api.patch.mock.calls.some(([path]) => path === "/api/instance/settings/experimental")).toBe(false);
});
it("retains setup and restoration failures instead of deleting an unconfirmed instance default", async () => {
const h = managedFixtureApi();
const patch = h.api.patch.getMockImplementation()!;
h.api.patch.mockImplementation(async (path, data) => {
if (path === "/api/instance/settings/experimental" && data.enableManagedSandboxOnly === true) throw new Error("Managed mode setup failed");
if (path === "/api/instance/settings" && data.defaultEnvironmentId === "previous-default") throw new Error("Default restoration failed");
return patch(path, data);
});
const error = await h.provision().catch(error => error);
expect(error).toBeInstanceOf(AggregateError);
expect(error.errors[0].message).toBe("Managed mode setup failed");
expect(error.errors[1].errors[0].message).toBe("Default restoration failed");
expect(error.errors[1].message).toContain("environment deletion was not attempted");
expect(h.api.delete).not.toHaveBeenCalled();
expect(h.state.defaultEnvironmentId).toBe("environment");
});
it("keeps failed default restoration outstanding and only deletes after a later confirmed restoration", async () => {
const h = managedFixtureApi();
const fixtures = await h.provision();
const patch = h.api.patch.getMockImplementation()!;
h.api.patch.mockImplementation(async (path, data) => {
if (path === "/api/instance/settings") throw new Error("Default restoration failed");
return patch(path, data);
});
await expect(fixtures.teardown()).rejects.toThrow("environment deletion was not attempted");
expect(h.state).toMatchObject({ defaultEnvironmentId: "environment", experimental: { enableManagedSandboxOnly: false } });
expect(h.api.delete).not.toHaveBeenCalled();
h.api.patch.mockImplementation(patch);
await fixtures.teardown();
await fixtures.teardown();
expect(h.state.defaultEnvironmentId).toBe("previous-default");
expect(h.api.delete).toHaveBeenCalledTimes(1);
});
it("reports failed managed-mode restoration while still removing an environment that is no longer the default", async () => {
const h = managedFixtureApi();
const fixtures = await h.provision();
h.state.experimental.enableAgentChat = false;
const patch = h.api.patch.getMockImplementation()!;
h.api.patch.mockImplementation(async (path, data) => {
if (path === "/api/instance/settings/experimental") throw new Error("Managed mode restoration failed");
return patch(path, data);
});
const error = await fixtures.teardown().catch(error => error);
expect(error).toBeInstanceOf(AggregateError);
expect(error.errors.map((error: Error) => error.message)).toEqual(["Managed mode restoration failed"]);
expect(h.state.defaultEnvironmentId).toBe("previous-default");
expect(h.api.delete).toHaveBeenCalledTimes(1);
h.api.patch.mockImplementation(patch);
await fixtures.teardown();
expect(h.state.experimental).toEqual({ enableManagedSandboxOnly: false, enableAgentChat: false });
expect(h.api.delete).toHaveBeenCalledTimes(1);
});
it("keeps failed environment deletion red after successful settings restoration", async () => {
const h = managedFixtureApi();
const fixtures = await h.provision();
h.api.delete.mockRejectedValue(new Error("Provider cleanup unconfirmed"));
vi.useFakeTimers();
try {
const rejected = expect(fixtures.teardown()).rejects.toThrow("Managed onboarding fixture cleanup failed");
await vi.runAllTimersAsync();
await rejected;
expect(h.state).toMatchObject({ defaultEnvironmentId: "previous-default", experimental: { enableManagedSandboxOnly: false } });
h.api.delete.mockResolvedValue();
await fixtures.teardown();
const deletes = h.api.delete.mock.calls.length;
await fixtures.teardown();
expect(h.api.delete).toHaveBeenCalledTimes(deletes);
} finally { vi.useRealTimers(); }
});
it.each(["read-failure", "missing-default", "missing-mode"])("fails before fixture mutations when prior settings have %s", async mode => {
const h = managedFixtureApi();
if (mode === "read-failure") h.api.get.mockRejectedValue(new Error("Settings read failed"));
else h.api.get.mockResolvedValue((mode === "missing-default" ? { experimental: { enableManagedSandboxOnly: false } } : { defaultEnvironmentId: null, experimental: {} }) as any);
await expect(h.provision()).rejects.toThrow(mode === "read-failure" ? "Settings read failed" : "prior instance settings could not be read");
expect(h.api.patch).not.toHaveBeenCalled();
expect(h.api.postSensitive).not.toHaveBeenCalled();
expect(h.api.post).not.toHaveBeenCalled();
expect(h.api.delete).not.toHaveBeenCalled();
});
it("prepares only encrypted credentials and an immutable managed default, with no fixture probe or agent", async () => {
const calls: string[] = [];
const api = {
get: vi.fn(async () => ({ defaultEnvironmentId: null, experimental: { enableManagedSandboxOnly: false } })),
patch: vi.fn(async (path: string) => { calls.push(`PATCH ${path}`); return {}; }),
postSensitive: vi.fn(async (_path: string, data: any) => ({ id: `encrypted-${data.key}` })),
post: vi.fn(async (path: string, data: any) => {
calls.push(`POST ${path}`);
if (path === "/api/plugins/install") return { id: "plugin", status: "ready" };
expect(path).toBe("/api/companies/company/environments");
expect(data.config).toMatchObject({ image, apiKey: { type: "secret_ref", secretId: "encrypted-DAYTONA_API_KEY", version: "latest" } });
expect(data).not.toHaveProperty("metadata");
return { id: "environment", driver: "sandbox", config: data.config };
}),
delete: vi.fn(async () => {}),
};
const fixtures = await provisionFirstTaskFixtures({ api: api as any, execution: execution(), nonce: "private", company: { id: "company", name: "Fresh" },
daytonaImage: image, credentials: { OPENAI_API_KEY: "synthetic-key", DAYTONA_API_KEY: "synthetic-platform" } });
expect(calls).toEqual(["PATCH /api/companies/company", "POST /api/plugins/install", "POST /api/companies/company/environments", "PATCH /api/instance/settings", "PATCH /api/instance/settings/experimental"]);
expect(api.patch).toHaveBeenCalledWith("/api/instance/settings", { defaultEnvironmentId: "environment" });
expect(api.patch).toHaveBeenCalledWith("/api/instance/settings/experimental", { enableManagedSandboxOnly: true });
expect(fixtures.agent.id).toBe("");
expect(fixtures.setupLeases).toEqual([]);
expect(JSON.stringify(fixtures)).not.toContain("synthetic");
await fixtures.teardown();
expect(api.delete).toHaveBeenCalledExactlyOnceWith("/api/environments/environment?destroyReusableSandboxLeases=true", { allowNotFound: true });
});
it.each(["missing-key", "mutable-image", "other-cell"])("rejects %s before admitting a managed browser fixture", async mode => {
const api = { get: vi.fn(), patch: vi.fn(), postSensitive: vi.fn(), post: vi.fn(), delete: vi.fn() };
await expect(provisionFirstTaskFixtures({ api, execution: mode === "other-cell" ? { ...execution(), id: "other" } : execution(), nonce: "private", company: { id: "company", name: "Fresh" },
daytonaImage: mode === "mutable-image" ? "ubuntu:24.04" : image,
credentials: mode === "missing-key" ? { OPENAI_API_KEY: "synthetic" } : { OPENAI_API_KEY: "synthetic", DAYTONA_API_KEY: "synthetic" } })).rejects.toThrow();
expect(api.post).not.toHaveBeenCalled();
expect(api.postSensitive).not.toHaveBeenCalled();
});
function fileEvidence() {
const e = recording("task-card-accept");
const last = e.checkpoints.at(-1)!;
last.tasks = [{ id: "child", parentId: e.onboardingIssueId, status: "done" }];
const body = `Welcome beginners. Join our free Saturday meetup ${firstTaskScenario(e.caseId, e.nonce).marker}.`;
last.attachments = [{ id: "file", issueId: "child", originalFilename: "garden-welcome.txt", contentType: "text/plain", body,
contentVerified: true, contentSha256: digestText(body) }];
return e;
}
it("requires the downloaded verified physical artifact", () => {
expect(managedFirstTaskFileCheck(fileEvidence()).passed).toBe(true);
});
it.each(["document-only", "unverified", "wrong-hash", "wrong-child", "wrong-name", "wrong-marker"])("rejects %s as physical-file proof", async mode => {
const e = fileEvidence();
const last = e.checkpoints.at(-1)!;
const file = last.attachments![0]!;
if (mode === "document-only") { last.documents = [{ ...file, key: "welcome" }]; last.attachments = []; }
if (mode === "unverified") file.contentVerified = false;
if (mode === "wrong-hash") file.contentSha256 = "0".repeat(64);
if (mode === "wrong-child") file.issueId = "foreign";
if (mode === "wrong-name") file.originalFilename = "plan.txt";
if (mode === "wrong-marker") { file.body = "A different welcome note"; file.contentSha256 = digestText(file.body); }
expect(managedFirstTaskFileCheck(e).passed).toBe(false);
});
function setupHarness(mode = "success") {
const listeners = new Map<string, Set<(event: any) => void>>();
const page = { on: (name: string, callback: (event: any) => void) => { const set = listeners.get(name) ?? new Set(); set.add(callback); listeners.set(name, set); },
off: (name: string, callback: (event: any) => void) => listeners.get(name)?.delete(callback) };
const fixtures = { company: { id: "company", name: "Fresh" }, environment: { id: "environment", driver: "sandbox" },
agent: { id: "", companyId: "company", name: "Ada" }, secretRefs: {}, setupLeases: [],
setupQualification: { recorded: false, adapterType: null, platformResponses: 0, adapterResponses: 0 }, teardown: async () => {} } as import("./live-fixtures.js").LiveFixtureValues;
const rows = ["platform", "adapter"].map(id => ({ id, companyId: "company", environmentId: "environment", provider: "daytona", heartbeatRunId: null, issueId: null,
status: mode === "cleanup" ? "failed" : "released", cleanupStatus: "success" }));
if (mode === "foreign") rows[0]!.companyId = "another-company";
let begun = false;
const api = { get: vi.fn(async (path: string) => path.includes("environment-leases/") ? rows.find(row => path.endsWith(`/${row.id}`))
: begun ? [...rows, { ...rows[0], id: "unrelated", issueId: "other-task" }] : []) } as unknown as RunnerApi;
const start = (kind: "platform" | "discovery" | "adapter", runner?: string) => {
begun = true;
const request = { url: () => `http://fixture.test${kind === "platform" ? "/api/environments/environment/probe?companyId=company"
: kind === "discovery" ? "/api/adapters?companyId=company&environmentId=environment" : "/api/companies/company/adapters/codex_local/test-environment"}`,
method: () => kind === "discovery" ? "GET" : "POST", postDataJSON: () => runner ? { runner } : {} };
for (const listener of listeners.get("request") ?? []) listener(request);
return request;
};
const respond = (request: ReturnType<typeof start>, body: any) => {
const response = { request: () => request, json: async () => body, ok: () => true };
for (const listener of listeners.get("response") ?? []) listener(response);
};
const emit = (kind: "platform" | "discovery" | "adapter", body: any, runner?: string) => respond(start(kind, runner), body);
const fail = (request: ReturnType<typeof start>) => {
for (const listener of listeners.get("requestfailed") ?? []) listener(request);
};
return { fixtures, page: page as unknown as import("@playwright/test").Page, api, emit, start, respond, fail, listeners, rows };
}
it.each(["missing-response", "request-failed"])("retains unknown adapter setup cost after %s without an attributable row", async mode => {
const h = setupHarness();
h.rows.splice(1);
const stop = await observeManagedFirstTaskSetup(h.page, h.api, h.fixtures, Date.now() + 1000);
h.emit("platform", { ok: true, details: { platformQualificationRecorded: true, leaseId: "platform" } });
const request = h.start("adapter");
if (mode === "request-failed") h.fail(request);
await stop(false);
expect(h.fixtures.setupLeases!.map(row => row.leaseId)).toEqual(["platform"]);
expect(h.fixtures.setupLeaseAccountingIncomplete).toBe(true);
expect(h.fixtures.setupQualification!.adapterResponses).toBe(0);
const leases = selectFixtureRuntimeLeases({ leases: h.rows.map(row => ({ ...row,
acquiredAt: "2026-10-13T00:00:00Z", releasedAt: "2026-10-13T00:00:02Z", metadata: { cpu: 4, memory: 4, disk: 10 } })),
runIds: [], setupLeaseIds: h.fixtures.setupLeases!.map(row => row.leaseId) });
const usage = fixtureRuntimeCostCoverage(buildRuntimeUsage({ environmentId: "daytona", runs: [], leases }), h.fixtures);
expect(usage.leaseCount).toBe(1);
expect(usage.costStatus).toBe("unavailable");
expect(usage).not.toHaveProperty("estimatedListCostUsd");
expect([...h.listeners.values()].every(listeners => listeners.size === 0)).toBe(true);
});
it("retains unknown failed platform setup without a response or visible lease row", async () => {
const h = setupHarness();
h.rows.splice(0);
const stop = await observeManagedFirstTaskSetup(h.page, h.api, h.fixtures, Date.now() + 1000);
h.fail(h.start("platform"));
await stop(false);
expect(h.fixtures.setupLeases).toEqual([]);
expect(h.fixtures.setupLeaseAccountingIncomplete).toBe(true);
expect(h.fixtures.setupQualification!.recorded).toBe(false);
});
it("does not clear failed-request accounting uncertainty when later responses correlate", async () => {
const h = setupHarness();
const stop = await observeManagedFirstTaskSetup(h.page, h.api, h.fixtures, Date.now() + 1000);
h.emit("platform", { ok: true, details: { platformQualificationRecorded: true, leaseId: "platform" } });
const request = h.start("adapter");
h.fail(request);
h.respond(request, { adapterType: "paperclip_runner", status: "pass", checks: [{ code: "sandbox_test_identity", detail: "paperclipLeaseId=adapter" }] });
await stop(false);
expect(h.fixtures.setupLeases!.map(row => row.leaseId)).toEqual(["platform", "adapter"]);
expect(h.fixtures.setupLeaseAccountingIncomplete).toBe(true);
});
it.each(["success", "missing-discovery", "explicit-runner", "duplicate", "foreign", "cleanup"])("verifies exact UI setup responses and owned cleanup for %s", async mode => {
const h = setupHarness(mode);
const stop = await observeManagedFirstTaskSetup(h.page, h.api, h.fixtures, mode === "cleanup" ? Date.now() - 1 : Date.now() + 1000);
const platform = { ok: true, details: { platformQualificationRecorded: true, leaseId: "platform" } };
h.emit("platform", platform);
if (mode === "duplicate") h.emit("platform", platform);
if (mode !== "missing-discovery") h.emit("discovery", [{ type: "codex_local", defaultRunner: "paperclip", supportedRunners: ["paperclip", "legacy"] }]);
h.emit("adapter", { adapterType: "paperclip_runner", status: "pass", checks: [{ code: "sandbox_test_identity", detail: "paperclipLeaseId=adapter; providerLeaseId=private-provider" }] }, mode === "explicit-runner" ? "paperclip" : undefined);
if (mode === "success") { await stop(true); expect(h.fixtures.setupLeases!.map(row => row.leaseId)).toEqual(["platform", "adapter"]); }
else await expect(stop(true)).rejects.toThrow();
expect(h.fixtures.setupLeases!.some(row => row.leaseId === "unrelated")).toBe(false);
expect([...h.listeners.values()].every(listeners => listeners.size === 0)).toBe(true);
});
it("reports a rejected platform setup immediately without waiting for the long browser deadline", async () => {
const h = setupHarness();
const stop = await observeManagedFirstTaskSetup(h.page, h.api, h.fixtures, Date.now() + 1000);
const rejected = expect(stop.failure).rejects.toThrow("UI environment qualification did not pass");
h.emit("platform", { ok: false, details: { leaseId: "platform", platformQualificationRecorded: false } });
await rejected;
await stop(false);
expect(h.fixtures.setupLeases!.map(row => row.leaseId)).toEqual(["platform"]);
});
it("keeps an uncorrelated lease out of setup billing and retains unavailable cost coverage", async () => {
const h = setupHarness();
const stop = await observeManagedFirstTaskSetup(h.page, h.api, h.fixtures, Date.now() + 1000);
h.emit("platform", { ok: false, details: { leaseId: "platform", platformQualificationRecorded: false } });
await stop(false);
expect(h.fixtures.setupLeases!.map(row => row.leaseId)).toEqual(["platform"]);
expect(h.fixtures.setupLeaseAccountingIncomplete).toBe(true);
expect(h.fixtures.setupQualification!.recorded).toBe(false);
});
it("retains exact failed setup leases for accounting without granting qualification", async () => {
const h = setupHarness();
const stop = await observeManagedFirstTaskSetup(h.page, h.api, h.fixtures, Date.now() + 1000);
h.emit("platform", { ok: true, details: { leaseId: "platform", platformQualificationRecorded: true } });
h.emit("adapter", { adapterType: "paperclip_runner", status: "fail", checks: [{ code: "sandbox_test_identity", detail: "paperclipLeaseId=adapter; providerLeaseId=private-provider" }] });
await stop(false);
expect(h.fixtures.setupLeases!.map(row => row.leaseId)).toEqual(["platform", "adapter"]);
expect(h.fixtures.setupQualification!.adapterType).toBeNull();
});
it("keeps a missing response lease ID unknown even without visible lease history", async () => {
const h = setupHarness();
h.rows.splice(0);
const stop = await observeManagedFirstTaskSetup(h.page, h.api, h.fixtures, Date.now() + 1000);
h.emit("platform", { ok: false, details: { platformQualificationRecorded: false } });
await stop(false);
expect(h.fixtures.setupLeases).toEqual([]);
expect(h.fixtures.setupLeaseAccountingIncomplete).toBe(true);
expect(h.fixtures.setupQualification!.recorded).toBe(false);
});
});
+29 -1
View File
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
import type { RunnerApi } from "./api.js";
import { buildRuntimeUsage } from "./billing.js";
import { runnerMatrix } from "./catalog.js";
import { setupLiveFixtures, selectFixtureRuntimeLeases } from "./live-fixtures.js";
import { setupLiveFixtures, selectFixtureRuntimeLeases, fixtureRuntimeCostCoverage } from "./live-fixtures.js";
describe("live runner fixtures", () => {
it.each(["runner-codex", "runner-acpx-claude", "runner-opencode"].flatMap(profile =>
@@ -398,3 +398,31 @@ describe("live runner fixtures", () => {
await fixtures.teardown();
});
});
describe("owned browser setup lease accounting", () => {
it("includes both exact setup IDs and task runs while excluding unrelated leases", () => {
const leases = [{ id: "platform", provider: "daytona" }, { id: "adapter", provider: "daytona" },
{ id: "task", provider: "daytona", heartbeatRunId: "run" }, { id: "unrelated", provider: "daytona" }];
expect(selectFixtureRuntimeLeases({ leases, runIds: ["run"], setupLeaseIds: ["platform", "adapter"] }).map(lease => lease.id)).toEqual(["platform", "adapter", "task"]);
expect(selectFixtureRuntimeLeases({ leases, runIds: [], setupLeaseIds: ["platform"] }).map(lease => lease.id)).toEqual(["platform"]);
expect(selectFixtureRuntimeLeases({ leases, runIds: [], setupLeaseIds: [] })).toEqual([]);
});
});
describe("managed setup runtime cost coverage", () => {
it("prices both exact setup windows with task usage and keeps missing attribution unknown", () => {
const leases = ["platform", "adapter", "task", "unrelated"].map((id, index) => ({ id, provider: "daytona", heartbeatRunId: id === "task" ? "run" : null,
acquiredAt: "2026-10-13T00:00:00Z", releasedAt: `2026-10-13T00:00:0${index + 1}Z`, metadata: { cpu: 4, memory: 4, disk: 10 } }));
const selected = selectFixtureRuntimeLeases({ leases, runIds: ["run"], setupLeaseIds: ["platform", "adapter"] });
const usage = buildRuntimeUsage({ environmentId: "daytona", runs: [], leases: selected });
expect(usage.leaseCount).toBe(3);
expect(usage.leaseDurationMs).toBe(6000);
expect(usage.estimatedListCostUsd).toBeGreaterThan(0);
expect(fixtureRuntimeCostCoverage(usage, {})).toEqual(usage);
const incomplete = fixtureRuntimeCostCoverage(usage, { setupLeaseAccountingIncomplete: true });
expect(incomplete.costStatus).toBe("unavailable");
expect(incomplete.costSource).toBe("provider_cost_unavailable");
expect(incomplete).not.toHaveProperty("estimatedListCostUsd");
expect(incomplete.leaseCount).toBe(3);
});
});
+24 -12
View File
@@ -13,6 +13,7 @@ import type {
MatrixExecution,
SecretReference,
SecretReferenceMap,
RunnerE2ERuntimeUsage,
} from "./types.js";
interface CompanyRecord {
@@ -65,6 +66,9 @@ export interface LiveFixtureValues {
project?: ProjectRecord;
aiConnection?: ManagedAccountFixture;
setupProbe?: { leaseId: string; durationMs: number; defaultRunner?: string };
setupLeases?: Array<{ leaseId: string; phase: "platform" | "adapter"; durationMs: number }>;
setupLeaseAccountingIncomplete?: boolean;
setupQualification?: { recorded: boolean; adapterType: string | null; platformResponses: number; adapterResponses: number };
onboardingRuntime?: {
mode: "production-wizard" | "post-onboarding-runtime-switch";
@@ -83,17 +87,24 @@ function value<T>(resolved: ReadonlyMap<string, unknown>, id: string): T {
export function selectFixtureRuntimeLeases<T extends {
id: string; provider?: string | null; heartbeatRunId?: string | null; issueId?: string | null;
}>(input: { leases: T[]; runIds: string[]; issueId?: string; setupProbeLeaseId?: string }): T[] {
}>(input: { leases: T[]; runIds: string[]; issueId?: string; setupProbeLeaseId?: string; setupLeaseIds?: readonly string[] }): T[] {
const selectedRunIds = new Set(input.runIds);
const setupIds = new Set([...(input.setupLeaseIds ?? []), ...(input.setupProbeLeaseId ? [input.setupProbeLeaseId] : [])]);
const relevant = input.leases.filter(lease => lease.provider === "daytona" && (
lease.id === input.setupProbeLeaseId || selectedRunIds.size === 0
setupIds.has(lease.id) || (input.setupLeaseIds === undefined && selectedRunIds.size === 0)
|| (lease.heartbeatRunId && selectedRunIds.has(lease.heartbeatRunId))
|| (input.issueId && lease.issueId === input.issueId)
));
return relevant.length > 0 ? relevant : input.leases;
return input.setupLeaseIds !== undefined || relevant.length > 0 ? relevant : input.leases;
}
async function deleteDaytonaEnvironment(api: RunnerApi, environmentId: string) {
export function fixtureRuntimeCostCoverage(usage: RunnerE2ERuntimeUsage, fixtures: Pick<LiveFixtureValues, "setupLeaseAccountingIncomplete">): RunnerE2ERuntimeUsage {
if (!fixtures.setupLeaseAccountingIncomplete) return usage;
const { estimatedListCostUsd: _estimate, ...known } = usage;
return { ...known, costStatus: "unavailable", costSource: "provider_cost_unavailable" };
}
export async function deleteDaytonaEnvironment(api: Pick<RunnerApi, "delete">, environmentId: string) {
const deadlineAt = Date.now() + 120_000;
let lastError: unknown;
while (Date.now() < deadlineAt) {
@@ -115,6 +126,14 @@ async function deleteDaytonaEnvironment(api: RunnerApi, environmentId: string) {
);
}
export async function installFixtureDaytonaProvider(api: Pick<RunnerApi, "post">, execution: MatrixExecution) {
const installed = await verifyInstalledDaytonaPlugin(process.env, [execution]);
return api.post<PluginRecord>("/api/plugins/install", {
packageName: installed?.packageRoot ?? path.resolve(import.meta.dirname, "../../packages/plugins/sandbox-providers/daytona"),
isLocalPath: true,
}, { timeout: 120_000 });
}
export async function setupLiveFixtures(input: {
api: RunnerApi;
execution: MatrixExecution;
@@ -131,14 +150,7 @@ export async function setupLiveFixtures(input: {
registry.register<PluginRecord>({
id: "sandbox-provider",
async setup() {
const installed = await verifyInstalledDaytonaPlugin(process.env, [execution]);
return api.post<PluginRecord>("/api/plugins/install", {
packageName: installed?.packageRoot ?? path.resolve(
import.meta.dirname,
"../../packages/plugins/sandbox-providers/daytona",
),
isLocalPath: true,
}, { timeout: 120_000 });
return installFixtureDaytonaProvider(api, execution);
},
async teardown() {
// The plugin is installed only in the isolated instance/database. The
@@ -180,7 +180,7 @@ describe("Pi controls catalog admission", () => {
expect(cells).toHaveLength(4); expect(cells.every(c => c.profile.qualificationCandidate === "pi" && c.task.expectedRunCount === 1)).toBe(true);
expect(cells.map(c => `${c.environment.id}/${c.task.id}`).sort()).toEqual(["daytona/pending-permission-stop", "daytona/same-turn-steering", "local/pending-permission-stop", "local/same-turn-steering"]);
expect(selectRunnerExecutions(parseRunnerSelectors(["--all"])).some(c => c.suite.id === "pi-controls")).toBe(false);
expect(validateRunnerCatalog()).toHaveLength(729);
expect(validateRunnerCatalog()).toHaveLength(730);
for (const cell of cells) expect(buildRunnerE2EProcessEnvironment({}, [cell]).PAPERCLIP_RUNNER_ACPX_QUALIFICATION).toBeUndefined();
expect(() => assertRemoteNativeEvidencePrerequisites(cells, {})).toThrow();
});
+14 -10
View File
@@ -36,7 +36,7 @@ import { captureStockHarness, gradeStockHarness, gradeStockHire } from "./stock-
import { verifyStockHarnessPreflight, STOCK_PREFLIGHT_ENV } from "./stock-harness-admission.js";
import { createTaskThroughUi, submitTaskReply } from "./user-actions.js";
import { runFirstTaskFlow, setupFirstTaskFixtures } from "./first-task-flow.js";
import { projectFirstTaskResult, runFirstTaskFlow, setupFirstTaskFixtures } from "./first-task-flow.js";
import { runChatFlow } from "./chat-flow.js";
import { restartChatServer } from "./chat-restart.js";
import { matchesRunCount, minimumRunCount } from "./run-count.js";
@@ -52,7 +52,7 @@ import { runnerExecutionById } from "./catalog.js";
import { classifyFailure } from "./failure-classifier.js";
import { runnerE2EServerControlPaths } from "./harness-env.js";
import { setupConnectionReview } from "./connection-reviews.js";
import { setupLiveFixtures, selectFixtureRuntimeLeases, type LiveFixtureValues } from "./live-fixtures.js";
import { setupLiveFixtures, selectFixtureRuntimeLeases, fixtureRuntimeCostCoverage, type LiveFixtureValues } from "./live-fixtures.js";
import { evaluateMatcher, persistedFinalRunMessage, type MatcherResult } from "./matchers.js";
import { readRegisteredArtifacts } from "./registered-artifact.js";
import {
@@ -698,7 +698,7 @@ for (const execution of executions) {
);
runtimeLeases = selectFixtureRuntimeLeases({ leases: listed,
runIds: selectedRuns.map(run => run.id), issueId: issue?.id,
setupProbeLeaseId: fixtures.setupProbe?.leaseId });
setupProbeLeaseId: fixtures.setupProbe?.leaseId, setupLeaseIds: fixtures.setupLeases?.map(lease => lease.leaseId) });
};
const cancelActiveRunsForCleanup = async () => {
@@ -865,10 +865,11 @@ for (const execution of executions) {
const receipt = verifyStockHarnessPreflight(process.env[STOCK_PREFLIGHT_ENV]);
await writeSanitizedJson(snapshotsDir, "stock-harness-preflight.json", receipt, secrets);
}
const nativeRolloutEnabled = execution.profile.provider !== "codex";
const experimental = await api.patch<{
enableNativeRunner: boolean;
}>("/api/instance/settings/experimental", {
enableNativeRunner: true,
enableNativeRunner: nativeRolloutEnabled,
...(["warm_three_turn", "everyday_workflow"].includes(execution.task.flow)
? { enableIsolatedWorkspaces: true }
: {}),
@@ -877,7 +878,7 @@ for (const execution of executions) {
? { enableRunnerPreviewIngress: true }
: {}),
});
expect(experimental.enableNativeRunner).toBe(true);
expect(experimental.enableNativeRunner).toBe(nativeRolloutEnabled);
if (execution.suite.id === "extended-harnesses" && execution.profile.qualificationCandidate === "pi" && execution.task.id === "file-edit-validate") {
piFileSeed = await seedPiFile(workspacePath, nonce);
@@ -888,7 +889,7 @@ for (const execution of executions) {
await setupGitStreamingWorkspace(workspacePath);
}
fixtures = execution.task.flow === "first_task"
? await setupFirstTaskFixtures({ page, api, execution, nonce, credentials, observe: value => { fixtures = value; } })
? await setupFirstTaskFixtures({ page, api, execution, nonce, credentials, daytonaImage: process.env.PAPERCLIP_E2E_DAYTONA_IMAGE, deadlineAt: startedAtMs + deadlineMs, observe: value => { fixtures = value; } })
: await setupLiveFixtures({
api,
execution,
@@ -1156,7 +1157,7 @@ for (const execution of executions) {
if (matcherResults.length === 0) matcherResults = [{ matcher: { kind: "issue_status", expected: "in_review" }, passed: true, detail: "Chat workflow and durable handoff/session assertions passed" }];
} else if (execution.task.flow === "first_task") {
const firstTask = await runFirstTaskFlow({
page, api, fixtures, execution, nonce, secrets,
page, api, fixtures, execution, nonce, deadlineAt: startedAtMs + deadlineMs, secrets,
observe: (currentIssue, runs, evidence) => {
issue = currentIssue; selectedRuns = runs; firstTaskEvidence = evidence;
matcherResults = evidence.checks.map(check => ({ matcher: { kind: "json_path" as const, path: `firstTask.checks.${check.id}`, expected: true }, passed: check.passed, detail: check.detail }));
@@ -3099,12 +3100,12 @@ for (const execution of executions) {
}
const finishedAtMs = Date.now();
const runtimeUsage = buildRuntimeUsage({
const runtimeUsage = fixtureRuntimeCostCoverage(buildRuntimeUsage({
environmentId: execution.environment.id,
runs: selectedRuns,
leases: runtimeLeases,
fallbackFinishedAt: new Date(finishedAtMs),
});
}), fixtures ?? {});
const resultWithoutBilling: RunnerE2EResult = {
schema: "paperclip.runner-e2e.result/v2",
executionId: execution.id,
@@ -3160,10 +3161,13 @@ for (const execution of executions) {
...resultWithoutBilling,
billing: summarizeExecutionBilling(resultWithoutBilling),
};
if (firstTaskEvidence) {
await writeSanitizedJson(snapshotsDir, "first-task-result-private.json", result, secrets);
}
await mkdir(privateDir, { recursive: true });
await writeFile(
resultPath,
`${JSON.stringify(sanitizeJson(result, secrets), null, 2)}\n`,
`${JSON.stringify(sanitizeJson(projectFirstTaskResult(result), secrets), null, 2)}\n`,
"utf8",
);
await testInfo.attach("runner-e2e-result", {
@@ -37,6 +37,8 @@ const EXTRA_SETTLE_MS: Record<string, number> = {
// waiting. The affected element is masked (solid overlay in both baseline and
// comparison) so the rest of the story still snapshot-verifies.
const MASKED_SELECTORS: Record<string, string> = {
"agents-codex-runner-creation--experimental-native-harnesses":
'[role="dialog"] [data-slot="agent-avatar"]',
// DocumentAnnotationLayer's ::highlight range over "two selectors" ends 1-2
// characters short on ~half of renders (anchor offsets race). Mask only the
// paragraph that carries that highlight.
@@ -82,6 +84,18 @@ async function renderStory(page: Page, storyId: string, theme: (typeof THEMES)[n
});
const errored = await page.locator(".sb-show-errordisplay").count();
expect(errored, `story ${storyId} threw during render`).toBe(0);
const codexCreation = storyId.startsWith("agents-codex-runner-creation--");
const codexSettings = /^agents-configuration-refresh--(?:codex-runtime|runner-runtime|codex-(?:legacy|native)-)/.test(storyId);
if (codexCreation || codexSettings) {
await page.waitForFunction(id => {
const preview = (window as unknown as { __STORYBOOK_PREVIEW__?: { storyRenders?: Array<{ id: string; phase: string }> } }).__STORYBOOK_PREVIEW__;
return preview?.storyRenders?.some(render => render.id === id && render.phase === "finished");
}, storyId);
const expected = storyId.endsWith("--experimental-native-harnesses") ? page.getByRole("button", { name: "Experimental harness", exact: true })
: storyId.endsWith("--task-ready") ? page.getByRole("heading", { name: /is ready/ })
: page.getByRole("button", { name: codexCreation || storyId.endsWith("--codex-legacy-advanced") ? "Runner" : "Harness", exact: true });
await expect(expected).toBeVisible();
}
if (storyId.startsWith("connections-speko-")) {
await page.waitForFunction((id) => document.body.dataset.spekoStoryReady === id, storyId);
expect(await page.locator("body").getAttribute("data-speko-story-error")).toBeNull();
@@ -95,7 +109,7 @@ for (const entry of entries) {
for (const theme of THEMES) {
test(`${entry.id} [${theme}]`, async ({ page }) => {
await renderStory(page, entry.id, theme);
const maskSelector = MASKED_SELECTORS[entry.id];
const maskSelector = MASKED_SELECTORS[entry.id] ?? (entry.id.startsWith("agents-codex-runner-creation--") ? 'header [data-slot="agent-avatar"]' : undefined);
await expect(page).toHaveScreenshot(`${entry.id}--${theme}.png`, {
fullPage: true,
mask: maskSelector ? [page.locator(maskSelector)] : undefined,
@@ -1,8 +1,22 @@
import { describe, expect, it } from "vitest";
import { getAdapterDisplay, getAdapterLabel, getAdapterLabels } from "./adapter-display-registry";
import { agentDisplayAdapterType, getAdapterDisplay, getAdapterLabel, getAdapterLabels } from "./adapter-display-registry";
describe("adapter display registry", () => {
it.each([
["openai_dot", "OpenAI Dot"],
["grok_bot", "Grok Bot"],
])("keeps saved %s external runners branded by provider", (provider, label) => {
const type = agentDisplayAdapterType({ adapterType: "paperclip_runner", adapterConfig: { provider } });
expect(getAdapterDisplay(type).label).toBe(label);
});
it("brands saved Codex runners as Codex without guessing managed or unknown providers", () => {
expect(agentDisplayAdapterType({ adapterType: "paperclip_runner", adapterConfig: { provider: "codex" } })).toBe("codex_local");
expect(agentDisplayAdapterType({ adapterType: "paperclip_runner", adapterConfig: {} })).toBe("codex_local");
for (const provider of ["claude_managed", "aws_agentcore", "unknown"]) {
expect(agentDisplayAdapterType({ adapterType: "paperclip_runner", adapterConfig: { provider } })).toBe("paperclip_runner");
}
});
it("uses user-facing labels without the legacy local qualifier for built-in adapters", () => {
expect(getAdapterLabel("codex_local")).toBe("Codex");
expect(getAdapterLabel("claude_local")).toBe("Claude Code");
@@ -6,6 +6,7 @@
* `getAdapterDisplay()`.
*/
import type { ComponentType } from "react";
import { agentHarnessType } from "@paperclipai/shared";
import {
Bot,
Code,
@@ -208,6 +209,14 @@ export function getAdapterDisplay(type: string): AdapterDisplayInfo {
};
}
export function agentDisplayAdapterType(agent: { adapterType: string; adapterConfig: Record<string, unknown> }): string {
if (agent.adapterType !== "paperclip_runner") return agent.adapterType;
if (agent.adapterConfig.provider === "openai_dot" || agent.adapterConfig.provider === "grok_bot") {
return agent.adapterConfig.provider;
}
return agentHarnessType(agent.adapterType, agent.adapterConfig) === "codex_local" ? "codex_local" : agent.adapterType;
}
export function isKnownAdapterType(type: string): boolean {
return type in adapterDisplayMap;
}
@@ -1,14 +1,19 @@
// @vitest-environment jsdom
import { createRoot } from "react-dom/client";
import { act } from "react";
import { act, useState } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactNode } from "react";
import type { CreateConfigValues } from "@paperclipai/adapter-utils";
import { beforeAll, describe, expect, it } from "vitest";
import { TooltipProvider } from "@/components/ui/tooltip";
import { CodexLocalConfigFields } from "./config-fields";
import { buildCodexRunnerConfig } from "./config-fields";
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 }); });
@@ -50,7 +55,170 @@ async function renderRunner(config: Record<string, unknown>, expand?: string, in
);
}
async function selectField(container: HTMLElement, label: string, value: string) {
const picker = container.querySelector<HTMLButtonElement>(`[aria-label="${label}"]`)!;
await act(async () => {
picker.focus();
await new Promise(resolve => window.setTimeout(resolve, 0));
});
await act(async () => picker.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true })));
const option = document.body.querySelector<HTMLButtonElement>(`[role="listbox"][aria-label="${label}"] [data-value="${value}"]`)!;
expect(option).not.toBeNull();
await act(async () => {
option.click();
await new Promise(resolve => window.setTimeout(resolve, 0));
});
}
async function editFields(
mode: "create" | "edit",
adapterType: "codex_local" | "paperclip_runner",
inspect: (container: HTMLElement, saved: () => Record<string, unknown>) => Promise<void>,
) {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
let currentConfig: Record<string, unknown> = {
...(adapterType === "paperclip_runner" ? { provider: "codex", lifecycleMode: "warm", idleTimeoutMs: 45_000 } : { engine: "acp", mode: "persistent", nonInteractivePermissions: "deny" }),
env: { CODEX_HOME: { type: "secret_ref", secretId: "codex-account" } },
};
let currentValues: CreateConfigValues = {
...defaultCreateValues,
adapterType,
runner: adapterType === "paperclip_runner" ? "paperclip" : "legacy",
adapterSchemaValues: { provider: "codex" },
envBindings: { CODEX_HOME: { type: "secret_ref", secretId: "codex-account" } },
paperclipRunnerLifecycleMode: "warm",
paperclipRunnerIdleTimeoutMs: 45_000,
codexEngine: "acp",
codexAcpMode: "persistent",
codexAcpNonInteractivePermissions: "deny",
};
function Fixture() {
const [config, setConfig] = useState(currentConfig);
const [values, setValues] = useState(currentValues);
currentConfig = config;
currentValues = values;
return <CodexLocalConfigFields mode={mode} isCreate={mode === "create"} adapterType={adapterType}
values={mode === "create" ? values : null} set={patch => setValues(previous => ({ ...previous, ...patch }))}
config={config} eff={(_group, _field, original) => original}
mark={(_group, field, value) => setConfig(previous => ({ ...previous, [field]: value }))}
models={[]} hideInstructionsFile hideRunnerHarness />;
}
try {
await act(async () => root.render(<QueryClientProvider client={new QueryClient()}><TooltipProvider><Fixture /></TooltipProvider></QueryClientProvider>));
await inspect(container, () => mode === "create"
? adapterType === "paperclip_runner" ? buildPaperclipRunnerConfig(currentValues) : buildCodexRunnerConfig(currentValues)
: currentConfig);
if (mode === "create") {
expect(currentValues.adapterType).toBe(adapterType);
expect(currentValues.runner).toBe(adapterType === "paperclip_runner" ? "paperclip" : "legacy");
}
} finally {
await act(async () => root.unmount());
container.remove();
}
}
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);
const root = createRoot(container);
let saved: typeof defaultCreateValues = { ...defaultCreateValues, adapterType: "codex_local", runner: "auto", extraArgs };
function Fixture() {
const [values, setValues] = useState(saved);
saved = values;
return <CodexLocalConfigFields mode="create" isCreate adapterType="codex_local" values={values}
set={(patch) => setValues(current => ({ ...current, ...patch }))} config={{}}
eff={(_group, _field, original) => original} mark={() => undefined} models={[]} hideInstructionsFile />;
}
try {
await act(async () => root.render(<QueryClientProvider client={new QueryClient()}><TooltipProvider><Fixture /></TooltipProvider></QueryClientProvider>));
const toggle = container.querySelector<HTMLButtonElement>('[aria-label="Bypass sandbox"]');
expect(toggle).toBeDefined();
expect(toggle?.getAttribute("aria-checked")).toBe(String(resolveCodexLocalCliPolicy(buildCodexRunnerConfig(saved)).bypass));
await act(async () => toggle!.click());
const config = buildCodexRunnerConfig(saved);
expect(saved.runner).toBe("auto");
expect(config.dangerouslyBypassApprovalsAndSandbox).toBe(extraArgs !== "");
expect(toggle?.getAttribute("aria-checked")).toBe(String(resolveCodexLocalCliPolicy(config).bypass));
} finally {
await act(async () => root.unmount());
container.remove();
}
});
it.each([
{},
{ dangerouslyBypassApprovalsAndSandbox: false, dangerouslyBypassSandbox: true },
{ dangerouslyBypassSandbox: false },
{ extraArgs: ["--sandbox", "read-only"] },
])("shows the actual saved Legacy CLI policy while editing %j", async config => {
await renderMarkup(<TooltipProvider><CodexLocalConfigFields mode="edit" isCreate={false}
adapterType="codex_local" values={null} set={null} config={config}
eff={(_group, _field, original) => original} mark={() => undefined} models={[]} hideInstructionsFile />
</TooltipProvider>, undefined, container => {
expect(container.querySelector('[aria-label="Bypass sandbox"]')?.getAttribute("aria-checked"))
.toBe(String(resolveCodexLocalCliPolicy(config).bypass));
});
});
it("edits the legacy execution engine through the shared keyboard picker", async () => {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
const changes: unknown[][] = [];
await act(async () => root.render(<QueryClientProvider client={new QueryClient()}><TooltipProvider>
<CodexLocalConfigFields mode="edit" isCreate={false} adapterType="codex_local" values={null} set={null}
config={{ engine: "acp" }} eff={(_group, _field, original) => original}
mark={(...change) => { changes.push(change); }} models={[]} hideInstructionsFile />
</TooltipProvider></QueryClientProvider>));
const picker = container.querySelector<HTMLButtonElement>('[aria-label="Execution engine"]')!;
await act(async () => picker.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true })));
const selected = document.body.querySelector<HTMLButtonElement>('[role="option"][aria-selected="true"]')!;
expect(selected.textContent).toBe("ACP");
await act(async () => selected.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowUp", bubbles: true })));
expect(document.activeElement?.textContent).toBe("Codex CLI");
await act(async () => (document.activeElement as HTMLButtonElement).click());
expect(changes).toEqual([["adapterConfig", "engine", "cli"]]);
await act(async () => root.unmount());
container.remove();
});
it.each(["create", "edit"] as const)("changes native lifecycle while retaining timeout and account in %s mode", async mode => {
await editFields(mode, "paperclip_runner", async (container, saved) => {
expect(container.querySelector('[aria-label="Runner lifecycle"]')?.textContent).toContain("Warm session");
expect(container.textContent).toContain("Warm idle timeout (ms)");
const account = saved().env;
await selectField(container, "Runner lifecycle", "per_turn");
expect(container.textContent).not.toContain("Warm idle timeout (ms)");
expect(saved()).toMatchObject({ provider: "codex", lifecycleMode: "per_turn", env: account });
await selectField(container, "Runner lifecycle", "warm");
expect(container.textContent).toContain("Warm idle timeout (ms)");
expect(saved()).toMatchObject({ provider: "codex", lifecycleMode: "warm", idleTimeoutMs: 45_000, env: account });
});
});
it.each(["create", "edit"] as const)("changes Legacy ACP session and permissions without replacing the account in %s mode", async mode => {
await editFields(mode, "codex_local", async (container, saved) => {
expect(container.querySelector('[aria-label="ACP session mode"]')?.textContent).toContain("Persistent");
expect(container.querySelector('[aria-label="ACP non-interactive permissions"]')?.textContent).toContain("Deny");
const account = saved().env;
await selectField(container, "ACP session mode", "oneshot");
await selectField(container, "ACP non-interactive permissions", "fail");
expect(saved()).toMatchObject({ engine: "acp", mode: "oneshot", nonInteractivePermissions: "fail", env: account });
await selectField(container, "ACP session mode", "persistent");
await selectField(container, "ACP non-interactive permissions", "deny");
expect(saved()).toMatchObject({ engine: "acp", mode: "persistent", nonInteractivePermissions: "deny", env: account });
});
});
it("preserves an unknown native provider instead of presenting it as Codex", async () => {
const html = await renderRunner({ provider: "future-provider" });
expect(html).toContain("future-provider");
expect(html).not.toContain('aria-label="Permission mode"');
});
it("keeps Dot out of the general harness picker and hides that picker for Dot", async () => {
expect(await renderRunner({ provider: "codex" }, "Harness", true)).not.toContain("OpenAI Dot");
const dot = await renderRunner({ provider: "openai_dot" });
@@ -58,6 +226,11 @@ describe("Paperclip Runner Codex configuration", () => {
expect(dot).toContain("Dot connection");
expect(dot).toContain("Allow externally billed provider");
});
it("keeps the general harness picker hidden for an existing Grok Bot", async () => {
const html = await renderRunner({ provider: "grok_bot" });
expect(html).not.toContain('aria-label="Harness"');
expect(html).toContain("Save the Grok Bot agent before connecting it.");
});
it.each([
[undefined, "Full auto (approve all)"],
["approve-paperclip", "Automatic Paperclip actions"],
+78 -56
View File
@@ -1,9 +1,11 @@
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../components/ui/select";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, SelectPopover } from "../../components/ui/select";
import { AdapterMark } from "../../components/AdapterMark";
import { GrokBotConnection } from "../../components/GrokBotConnection";
import { DotRunnerConnection } from "../../components/DotRunnerConnection";
import { configFieldsForSection } from "../config-sections";
import type { AdapterConfigFieldsProps } from "../types";
import type { CreateConfigValues } from "@paperclipai/adapter-utils";
import { buildCodexLocalConfig } from "@paperclipai/adapter-codex-local/ui";
import {
Field,
ToggleField,
@@ -18,6 +20,7 @@ import {
CODEX_LOCAL_FAST_MODE_SUPPORTED_MODELS,
isCodexLocalFastModeSupported,
isCodexLocalManualModel,
resolveCodexLocalCliPolicy,
} from "@paperclipai/adapter-codex-local";
import {
PAPERCLIP_RUNNER_IDLE_TIMEOUT_DEFAULT_MS,
@@ -28,7 +31,7 @@ import {
resolvePaperclipRunnerIdleTimeoutMs,
resolvePaperclipRunnerPermissionMode,
type PaperclipRunnerPermissionMode,
type PaperclipRunnerProvider,
type PaperclipRunnerPermissionCapability,
} from "@paperclipai/adapter-utils";
const inputClass =
@@ -48,6 +51,21 @@ const runnerHarnessOptions = [
{ value: "grok", label: "Grok Build", adapter: "grok_local" },
];
export function buildCodexRunnerConfig(values: CreateConfigValues): Record<string, unknown> {
const config = buildCodexLocalConfig(values);
if (values.codexLegacySandboxBypass !== undefined) {
config.dangerouslyBypassApprovalsAndSandbox = values.codexLegacySandboxBypass;
} else if (values.runner !== "legacy") {
delete config.dangerouslyBypassApprovalsAndSandbox;
}
return {
...config,
...(values.codexPermissionMode !== undefined ? { codexPermissionMode: values.codexPermissionMode } : {}),
...(values.paperclipRunnerLifecycleMode !== undefined ? { lifecycleMode: values.paperclipRunnerLifecycleMode } : {}),
...(values.paperclipRunnerIdleTimeoutMs !== undefined ? { idleTimeoutMs: values.paperclipRunnerIdleTimeoutMs } : {}),
};
}
export function CodexLocalConfigFields({
section,
companyId,
@@ -63,6 +81,7 @@ export function CodexLocalConfigFields({
models,
hideInstructionsFile,
managedSandboxOnly,
hideRunnerHarness,
}: AdapterConfigFieldsProps) {
const runnerManaged = adapterType === "paperclip_runner";
// The execution engine picks which binary runs on the execution host, and the
@@ -75,13 +94,11 @@ export function CodexLocalConfigFields({
? values!.adapterSchemaValues?.provider
: eff("adapterConfig", "provider", config.provider === "acpx" && config.acpxAgent === "codex" ? "codex" : config.provider ?? "codex")
: "codex";
const runnerProvider: PaperclipRunnerProvider = isPaperclipRunnerProvider(
configuredRunnerProvider,
)
? configuredRunnerProvider
: "codex";
const runnerPermissionCapability =
PAPERCLIP_RUNNER_PERMISSION_CAPABILITIES[runnerProvider];
const runnerProvider = String(configuredRunnerProvider ?? "codex");
const runnerPermissionCapability: PaperclipRunnerPermissionCapability =
isPaperclipRunnerProvider(runnerProvider)
? PAPERCLIP_RUNNER_PERMISSION_CAPABILITIES[runnerProvider]
: { configurable: false, defaultMode: "provider-managed", options: [], description: "Unknown provider configuration is preserved." };
const configuredRunnerPermissionMode =
runnerManaged && runnerPermissionCapability.configurable
? isCreate
@@ -107,7 +124,7 @@ export function CodexLocalConfigFields({
const runnerPermissionMode =
runnerManaged && runnerPermissionCapability.configurable
? resolvePaperclipRunnerPermissionMode(
runnerProvider,
isPaperclipRunnerProvider(runnerProvider) ? runnerProvider : "codex",
configuredRunnerPermissionMode,
)
: runnerPermissionCapability.defaultMode;
@@ -151,9 +168,13 @@ export function CodexLocalConfigFields({
const engine =
rawEngine === "acp" || rawEngine === "cli" ? rawEngine : "auto";
const acpSelected = engine === "acp";
const bypassEnabled =
config.dangerouslyBypassApprovalsAndSandbox === true ||
config.dangerouslyBypassSandbox === true;
const bypassEnabled = resolveCodexLocalCliPolicy({
...config,
dangerouslyBypassApprovalsAndSandbox: eff("adapterConfig", "dangerouslyBypassApprovalsAndSandbox", config.dangerouslyBypassApprovalsAndSandbox),
dangerouslyBypassSandbox: eff("adapterConfig", "dangerouslyBypassSandbox", config.dangerouslyBypassSandbox),
extraArgs: eff("adapterConfig", "extraArgs", config.extraArgs),
args: eff("adapterConfig", "args", config.args),
}).bypass;
const fastModeEnabled = isCreate
? Boolean(values!.fastMode)
: eff("adapterConfig", "fastMode", Boolean(config.fastMode));
@@ -177,14 +198,14 @@ export function CodexLocalConfigFields({
label="Execution engine"
hint="Default uses ACP. If ACP is unavailable, the run fails with a setup error. Choose CLI explicitly to use it."
>
<select
className={inputClass}
<SelectPopover
aria-label="Execution engine"
value={engine}
onChange={(e) => {
onValueChange={(next) => {
const value =
e.target.value === "acp"
next === "acp"
? "acp"
: e.target.value === "cli"
: next === "cli"
? "cli"
: "auto";
isCreate
@@ -195,14 +216,15 @@ export function CodexLocalConfigFields({
value === "auto" ? undefined : value,
);
}}
>
<option value="auto">Default (ACP)</option>
<option value="cli">Codex CLI</option>
<option value="acp">ACP</option>
</select>
options={[
{ value: "auto", label: "Default (ACP)" },
{ value: "cli", label: "Codex CLI" },
{ value: "acp", label: "ACP" },
]}
/>
</Field>
)}
{runnerManaged && runnerProvider !== "openai_dot" && runnerProvider !== "grok_bot" && (
{runnerManaged && !hideRunnerHarness && runnerProvider !== "openai_dot" && runnerProvider !== "grok_bot" && (
<Field configSection="adapter"
label="Harness"
hint="Choose the agent harness that runs your tasks."
@@ -254,6 +276,7 @@ export function CodexLocalConfigFields({
>
<SelectTrigger className="w-full" aria-label="Harness"><SelectValue /></SelectTrigger>
<SelectContent>
{!isPaperclipRunnerProvider(runnerProvider) && <SelectItem value={runnerProvider} disabled>{runnerProvider} (unknown provider)</SelectItem>}
{runnerHarnessOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
<AdapterMark type={option.adapter} className="size-4" />
@@ -471,7 +494,7 @@ export function CodexLocalConfigFields({
}
onValueChange={(selectedMode) => {
const value = resolvePaperclipRunnerPermissionMode(
runnerProvider,
isPaperclipRunnerProvider(runnerProvider) ? runnerProvider : "codex",
selectedMode,
) as PaperclipRunnerPermissionMode;
if (isCreate) {
@@ -523,19 +546,20 @@ export function CodexLocalConfigFields({
label="Runner lifecycle"
hint="Turn by turn suspends after each run. Warm keeps the same provider process available between governed runs."
>
<select
className={inputClass}
<SelectPopover
aria-label="Runner lifecycle"
value={runnerLifecycleMode}
onChange={(event) => {
const value = event.target.value === "warm" ? "warm" : "per_turn";
onValueChange={(next) => {
const value = next === "warm" ? "warm" : "per_turn";
isCreate
? set!({ paperclipRunnerLifecycleMode: value })
: mark("adapterConfig", "lifecycleMode", value);
}}
>
<option value="per_turn">Turn by turn</option>
<option value="warm">Warm session</option>
</select>
options={[
{ value: "per_turn", label: "Turn by turn" },
{ value: "warm", label: "Warm session" },
]}
/>
</Field>
)}
{runnerManaged && runnerProvider !== "openai_dot" && runnerProvider !== "grok_bot" && runnerLifecycleMode === "warm" && (
@@ -609,8 +633,8 @@ export function CodexLocalConfigFields({
label="ACP session mode"
hint="Persistent keeps ACP session state between runs. One-shot starts fresh each run."
>
<select
className={inputClass}
<SelectPopover
aria-label="ACP session mode"
value={
isCreate
? (values!.codexAcpMode ?? "persistent")
@@ -620,24 +644,25 @@ export function CodexLocalConfigFields({
String(config.mode ?? "persistent"),
)
}
onChange={(e) => {
onValueChange={(next) => {
const value =
e.target.value === "oneshot" ? "oneshot" : "persistent";
next === "oneshot" ? "oneshot" : "persistent";
isCreate
? set!({ codexAcpMode: value })
: mark("adapterConfig", "mode", value);
}}
>
<option value="persistent">Persistent</option>
<option value="oneshot">One-shot</option>
</select>
options={[
{ value: "persistent", label: "Persistent" },
{ value: "oneshot", label: "One-shot" },
]}
/>
</Field>
<Field
label="ACP non-interactive permissions"
hint="Fallback if the ACP agent asks for input outside an interactive session."
>
<select
className={inputClass}
<SelectPopover
aria-label="ACP non-interactive permissions"
value={
isCreate
? (values!.codexAcpNonInteractivePermissions ?? "deny")
@@ -647,16 +672,17 @@ export function CodexLocalConfigFields({
String(config.nonInteractivePermissions ?? "deny"),
)
}
onChange={(e) => {
const value = e.target.value === "fail" ? "fail" : "deny";
onValueChange={(next) => {
const value = next === "fail" ? "fail" : "deny";
isCreate
? set!({ codexAcpNonInteractivePermissions: value })
: mark("adapterConfig", "nonInteractivePermissions", value);
}}
>
<option value="deny">Deny</option>
<option value="fail">Fail</option>
</select>
options={[
{ value: "deny", label: "Deny" },
{ value: "fail", label: "Fail" },
]}
/>
</Field>
{!managedSandboxOnly && (
<Field
@@ -754,16 +780,12 @@ export function CodexLocalConfigFields({
hint={help.dangerouslyBypassSandbox}
checked={
isCreate
? values!.dangerouslyBypassSandbox
: eff(
"adapterConfig",
"dangerouslyBypassApprovalsAndSandbox",
bypassEnabled,
)
? resolveCodexLocalCliPolicy(buildCodexRunnerConfig(values!)).bypass
: bypassEnabled
}
onChange={(v) =>
isCreate
? set!({ dangerouslyBypassSandbox: v })
? set!({ dangerouslyBypassSandbox: v, codexLegacySandboxBypass: v })
: mark(
"adapterConfig",
"dangerouslyBypassApprovalsAndSandbox",
+2 -3
View File
@@ -1,12 +1,11 @@
import type { UIAdapterModule } from "../types";
import { parseCodexStdoutLine } from "@paperclipai/adapter-codex-local/ui";
import { CodexLocalConfigFields } from "./config-fields";
import { buildCodexLocalConfig } from "@paperclipai/adapter-codex-local/ui";
import { CodexLocalConfigFields, buildCodexRunnerConfig } from "./config-fields";
export const codexLocalUIAdapter: UIAdapterModule = {
type: "codex_local",
label: "Codex",
parseStdoutLine: parseCodexStdoutLine,
ConfigFields: CodexLocalConfigFields,
buildAdapterConfig: buildCodexLocalConfig,
buildAdapterConfig: buildCodexRunnerConfig,
};
+1
View File
@@ -53,6 +53,7 @@ export interface AdapterConfigFieldsProps {
managedSandboxOnly?: boolean;
/** Show Dot for new selections only when its experimental prerequisites are enabled. */
openAiDotEnabled?: boolean;
hideRunnerHarness?: boolean;
}
export interface UIAdapterModule extends TranscriptParserSource {
+10 -1
View File
@@ -34,6 +34,8 @@ export interface AcpTargetDescriptor {
}
export interface AdapterInfo {
supportedRunners?: import("@paperclipai/shared").AgentRunner[];
defaultRunner?: import("@paperclipai/shared").AgentRunner;
type: string;
label: string;
source: "builtin" | "external";
@@ -63,7 +65,14 @@ export interface AdapterInstallResult {
export const adaptersApi = {
/** List all registered adapters (built-in + external). */
list: () => api.get<AdapterInfo[]>("/adapters"),
list: (context?: { companyId: string; environmentId?: string | null }) => {
const query = new URLSearchParams();
if (context) {
query.set("companyId", context.companyId);
if (context.environmentId) query.set("environmentId", context.environmentId);
}
return api.get<AdapterInfo[]>(`/adapters${query.size ? `?${query}` : ""}`);
},
/** Install an external adapter from npm or a local path. */
install: (params: { packageName: string; version?: string; isLocalPath?: boolean }) =>
+1
View File
@@ -253,6 +253,7 @@ export const agentsApi = {
type: string,
data: {
adapterConfig: Record<string, unknown>;
runner?: import("@paperclipai/shared").AgentRunnerChoice;
aiConnection?: import("@paperclipai/shared").AiRuntimeConnectionBinding;
agentId?: string;
testCredentials?: Record<string, string>;
+1
View File
@@ -108,6 +108,7 @@ export interface BuiltInAgentState {
}
export interface BuiltInAgentProvisionInput {
runner?: import("@paperclipai/shared").AgentRunnerChoice;
adapterType?: string;
adapterConfig?: Record<string, unknown>;
budgetMonthlyCents?: number;
+196 -26
View File
@@ -9,7 +9,7 @@ import type { Agent, Environment, UserSecretDefinition } from "@paperclipai/shar
import { getEnvironmentCapabilities } from "@paperclipai/shared";
import { TooltipProvider } from "@/components/ui/tooltip";
import { ToastProvider } from "../context/ToastContext";
import { AgentConfigForm, AdapterLoginPanel, subtractPersistedOverlay, type AdapterLoginDescriptor } from "./AgentConfigForm";
import { AgentConfigForm, AdapterLoginPanel, ModelDropdown, subtractPersistedOverlay, type AdapterLoginDescriptor } from "./AgentConfigForm";
import { AgentLifecycleStatus, useAgentLifecycleStatus } from "./AgentLifecycleStatus";
import { queryKeys } from "../lib/queryKeys";
import { defaultCreateValues } from "./agent-config-defaults";
@@ -73,6 +73,10 @@ vi.mock("../api/agents", () => ({
agentsApi: mockAgentsApi,
}));
vi.mock("../api/adapters", () => ({
adaptersApi: { list: async () => [{ type: "codex_local", supportedRunners: ["legacy", "paperclip"], defaultRunner: "legacy" }] },
}));
vi.mock("../api/environments", () => ({
environmentsApi: mockEnvironmentsApi,
}));
@@ -307,6 +311,7 @@ async function renderForm(
agentOverrides: Partial<Agent> = {},
options: {
showAdapterTestEnvironmentButton?: boolean;
showAdapterTypeField?: boolean;
content?: "configuration" | "secrets";
environmentVariablesPlacement?: "configuration" | "secrets";
hideInlineSave?: boolean;
@@ -336,7 +341,7 @@ async function renderForm(
onDirtyChange={options.onDirtyChange}
onSaveActionChange={options.onSaveActionChange}
onCancelActionChange={options.onCancelActionChange}
showAdapterTypeField={false}
showAdapterTypeField={options.showAdapterTypeField ?? false}
showAdapterTestEnvironmentButton={options.showAdapterTestEnvironmentButton ?? false}
/>
);
@@ -571,16 +576,20 @@ async function renderStatefulCreateClaudeSandbox(environments: Environment[]) {
return { container, root, valuesRef };
}
async function environmentOptions(container: HTMLElement) {
const picker = container.querySelector('[aria-label="Environment override"]');
expect(picker).not.toBeNull();
await clickElement(picker);
const options = document.body.querySelector('[role="listbox"][aria-label="Environment override"]');
expect(options).not.toBeNull();
return options!;
}
async function selectEnvironment(container: HTMLElement, environmentId: string) {
const select = container.querySelector("select");
await act(async () => {
if (select) {
const setter = Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, "value")?.set;
setter?.call(select, environmentId);
select.dispatchEvent(new Event("change", { bubbles: true }));
}
});
await flushReact();
const options = await environmentOptions(container);
const option = options.querySelector(`[role="option"][data-value="${environmentId}"]`);
expect(option).not.toBeNull();
await clickElement(option);
}
async function clickByText(container: HTMLElement, label: string) {
@@ -623,6 +632,125 @@ async function flushUntil(check: () => boolean, timeoutMs = 4000) {
describe("AgentConfigForm environment selector", () => {
let roots: Root[] = [];
it("keeps an explicit Codex draft policy until a harness reset clears it", async () => {
mockEnvironmentsApi.list.mockResolvedValue([]);
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } });
let current = { ...defaultCreateValues, adapterType: "codex_local", runner: "auto" as const,
dangerouslyBypassSandbox: false, codexLegacySandboxBypass: false };
function Fixture() {
const [values, setValues] = useState<typeof defaultCreateValues>(current);
current = values as typeof current;
return <AgentConfigForm mode="create" values={values}
onChange={patch => setValues(prev => ({ ...prev, ...patch }))} showAdapterTypeField hidePromptTemplate />;
}
try {
await act(async () => root.render(<QueryClientProvider client={queryClient}><ToastProvider><TooltipProvider>
<Fixture />
</TooltipProvider></ToastProvider></QueryClientProvider>));
await flushReact();
expect(current.codexLegacySandboxBypass).toBe(false);
for (const harness of ["claude_local", "codex_local"]) {
await clickElement(container.querySelector('[aria-label="Harness"]'));
await clickElement(document.querySelector(`[role="option"][data-value="${harness}"]`));
await flushReact();
expect(current.adapterType).toBe(harness);
expect(current).toHaveProperty("codexLegacySandboxBypass", undefined);
}
} finally {
await act(async () => root.unmount());
container.remove();
}
});
it.each(["codex_local", "paperclip_runner"])("keeps a saved %s runner during unrelated edits", async adapterType => {
const adapterConfig = { model: "gpt-5.6-sol", ...(adapterType === "paperclip_runner" ? { provider: "codex", reasoningEffort: "high" } : { modelReasoningEffort: "high" }) };
const result = await renderForm([], { adapterType, adapterConfig }, { showAdapterTypeField: true });
roots.push(result.root);
expect(result.container.querySelector('[aria-label="Harness"]')?.textContent).toContain("Codex");
await clickByText(result.container, "Advanced");
expect(result.container.querySelector('[aria-label="Runner"]')?.textContent).toContain(adapterType === "paperclip_runner" ? "Paperclip Runner" : "Legacy runner");
await act(async () => setInputValue(result.container.querySelector<HTMLInputElement>('[placeholder="Agent name"]')!, "Renamed Cody"));
await flushReact();
await clickByText(result.container, "Save");
expect(result.onSave).toHaveBeenCalledWith({ name: "Renamed Cody" });
});
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",
...(adapterType === "paperclip_runner" ? { provider: "codex", reasoningEffort: "high" } : { modelReasoningEffort: "high" }),
env: { CODEX_HOME: { type: "secret_ref", secretId: "codex-account" } },
};
const result = await renderForm([
...daytonaEnvironments(),
makeEnvironment({ id: "sandbox-2", name: "Daytona Two", driver: "sandbox", config: { provider: "daytona" } }),
], { adapterType, adapterConfig, defaultEnvironmentId: "sandbox-1" }, { showAdapterTypeField: true });
roots.push(result.root);
await clickByText(result.container, "Advanced");
const runnerLabel = adapterType === "paperclip_runner" ? "Paperclip Runner" : "Legacy runner";
await selectEnvironment(result.container, "sandbox-2");
expect(result.container.querySelector('[aria-label="Environment override"]')?.textContent).toContain("Daytona Two");
expect(result.container.querySelector('[aria-label="Runner"]')?.textContent).toContain(runnerLabel);
await clickByText(result.container, "Save");
expect(result.onSave).toHaveBeenLastCalledWith({ defaultEnvironmentId: "sandbox-2" });
await selectEnvironment(result.container, "");
expect(result.container.querySelector('[aria-label="Environment override"]')?.textContent).toContain("Default: Local");
expect(result.container.querySelector('[aria-label="Runner"]')?.textContent).toContain(runnerLabel);
await clickByText(result.container, "Save");
expect(result.onSave).toHaveBeenLastCalledWith({ defaultEnvironmentId: null });
});
it("clears a new agent's environment override to the empty draft value", async () => {
const result = await renderCreateForm(daytonaEnvironments(), { defaultEnvironmentId: "sandbox-1", runner: "legacy" });
roots.push(result.root);
await selectEnvironment(result.container, "");
expect(result.onChange).toHaveBeenLastCalledWith({ defaultEnvironmentId: "" });
});
it("submits only an explicit runner change and retains saved configuration for server translation", async () => {
const result = await renderForm([], { adapterType: "paperclip_runner", adapterConfig: { provider: "codex", model: "gpt-5.6-sol", reasoningEffort: "high", env: { CODEX_HOME: { type: "secret_ref", secretId: "codex-account" } } } });
roots.push(result.root);
await clickByText(result.container, "Advanced");
await clickElement(result.container.querySelector('[aria-label="Runner"]'));
await clickElement([...document.querySelectorAll('[role="option"]')].find(option => option.textContent?.startsWith("Legacy runner")));
await clickByText(result.container, "Save");
expect(result.onSave).toHaveBeenCalledWith({ runner: "legacy" });
});
it.each([false, true])("keeps experimental native selection gated when enableNativeRunner is %s", async enabled => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableNativeRunner: enabled });
const result = await renderForm([], { adapterType: "claude_local", adapterConfig: { model: "claude-sonnet-5" } }, { showAdapterTypeField: true });
roots.push(result.root);
await clickElement(result.container.querySelector('[aria-label="Harness"]'));
const option = [...document.querySelectorAll('[role="option"]')].find(element => element.getAttribute("data-value") === "paperclip_runner");
expect(Boolean(option)).toBe(enabled);
if (!enabled) return;
expect(document.body.textContent).toContain("Advanced");
await clickElement(option);
await clickByText(result.container, "Save");
expect(result.onSave).toHaveBeenCalledWith(expect.objectContaining({
adapterType: "paperclip_runner",
adapterConfig: expect.objectContaining({ provider: "acpx", acpxAgent: "claude" }),
}));
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 => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableNativeRunner: false });
const result = await renderForm([], { adapterType: "paperclip_runner", adapterConfig }, { showAdapterTypeField: true });
roots.push(result.root);
expect(result.container.querySelector('[aria-label="Runner"]')).toBeNull();
await act(async () => setInputValue(result.container.querySelector<HTMLInputElement>('[placeholder="Agent name"]')!, "Renamed native agent"));
await flushReact();
await clickByText(result.container, "Save");
expect(result.onSave).toHaveBeenCalledWith({ name: "Renamed native agent" });
});
beforeEach(() => {
mockAgentsApi.adapterModels.mockResolvedValue([]);
mockAgentsApi.detectModel.mockResolvedValue(null);
@@ -820,7 +948,7 @@ describe("AgentConfigForm environment selector", () => {
roots.push(result.root);
expect(result.container.textContent).not.toContain("Environment override");
expect(result.container.querySelector("select")).toBeNull();
expect(result.container.querySelector('[aria-label="Environment override"]')).toBeNull();
});
it("renders GPT-6 Astra and its model-specific reasoning efforts", async () => {
@@ -939,7 +1067,7 @@ describe("AgentConfigForm environment selector", () => {
roots.push(result.root);
const text = result.container.textContent ?? "";
const selector = result.container.querySelector("select");
const selector = await environmentOptions(result.container);
expect(text).toContain("Environment");
expect(text).toContain("Environment override");
@@ -958,7 +1086,7 @@ describe("AgentConfigForm environment selector", () => {
roots.push(result.root);
const text = result.container.textContent ?? "";
const selector = result.container.querySelector("select");
const selector = await environmentOptions(result.container);
expect(text).toContain("Environment override");
expect(selector?.textContent).toContain("E2B · sandbox");
@@ -972,7 +1100,7 @@ describe("AgentConfigForm environment selector", () => {
roots.push(result.root);
const text = result.container.textContent ?? "";
const selector = result.container.querySelector("select");
const selector = await environmentOptions(result.container);
expect(text).toContain("Environment override");
expect(selector?.textContent).toContain("E2B · sandbox");
@@ -994,7 +1122,7 @@ describe("AgentConfigForm environment selector", () => {
roots.push(result.root);
const text = result.container.textContent ?? "";
const selector = result.container.querySelector("select");
const selector = await environmentOptions(result.container);
expect(text).toContain("Environment override");
expect(selector?.textContent).toContain("Default: Local");
@@ -1014,7 +1142,7 @@ describe("AgentConfigForm environment selector", () => {
]);
roots.push(result.root);
const selector = result.container.querySelector("select");
const selector = await environmentOptions(result.container);
expect(selector?.textContent).toContain("Default: Paperclip Computer");
expect(selector?.textContent).toContain("Paperclip Computer");
@@ -1191,6 +1319,33 @@ describe("AgentConfigForm environment selector", () => {
expect(result.container.textContent).toContain("Network unavailable");
});
it("moves through model options with the same keyboard controls as the harness picker", async () => {
const curated = [
{ id: "claude-fable-5-1", label: "Claude Fable 5.1" },
{ id: "claude-opus-5-5", label: "Claude Opus 5.5" },
{ id: "claude-haiku-4-5", label: "Claude Haiku 4.5" },
{ id: "claude-opus-4-8", label: "Claude Opus 4.8" },
];
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
try {
await act(() => root.render(<TooltipProvider>
<ModelDropdown models={curated} value="" onChange={() => {}} open onOpenChange={() => {}} allowDefault={false} required groupByProvider={false} preserveOrder />
</TooltipProvider>));
const search = document.body.querySelector("input")!;
await act(() => { search.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true })); });
expect(document.activeElement?.textContent).toBe(curated[0].label);
await act(() => { document.activeElement?.dispatchEvent(new KeyboardEvent("keydown", { key: "End", bubbles: true })); });
expect(document.activeElement?.textContent).toBe(curated.at(-1)?.label);
await act(() => { document.activeElement?.dispatchEvent(new KeyboardEvent("keydown", { key: "Home", bubbles: true })); });
expect(document.activeElement?.textContent).toBe(curated[0].label);
} finally {
await act(() => root.unmount());
container.remove();
}
});
it("hides the Login button before Test and shows it after the adapter_auth_missing check for a Codex sandbox", async () => {
mockAgentsApi.testEnvironment.mockResolvedValue(AUTH_MISSING_RESULT);
const result = await renderCodexSandbox();
@@ -1203,6 +1358,29 @@ describe("AgentConfigForm environment selector", () => {
expect(findButton(result.container, "Sign in")).toBeTruthy();
});
it.each([
["auth_required", false], ["auth_required", true], ["failed", false],
] as const)("gates native Codex sandbox login on the shared auth check (%s, %s)", async (diagnostic, authentication) => {
mockAgentsApi.testEnvironment.mockResolvedValue({
adapterType: "paperclip_runner", status: "fail", testedAt: new Date(0).toISOString(),
checks: [
{ code: `codex_hello_probe_${diagnostic}`, level: "error", message: "Native Codex setup failed." },
...(authentication ? [{ code: "adapter_auth_missing", level: "error", message: "The selected Codex account needs authentication." }] : []),
],
});
const result = await renderCodexSandbox({ adapterType: "paperclip_runner", adapterConfig: { provider: "codex" } });
roots.push(result.root);
expect(findButton(result.container, "Sign in")).toBeFalsy();
await runTest(result.container);
expect(Boolean(findButton(result.container, "Sign in"))).toBe(authentication);
if (authentication) {
await startLogin(result.container);
expect(mockAgentsApi.startAdapterAuthLogin).toHaveBeenCalledWith("company-1", "codex_local", { environmentId: "sandbox-1" });
} else {
expect(mockAgentsApi.startAdapterAuthLogin).not.toHaveBeenCalled();
}
});
it("hides the Codex login for a provider without the login pseudo-terminal capability", async () => {
// The Codex device login runs on a real pseudo-terminal, so it needs a
// provider that advertises the login pseudo-terminal capability. E2B reports
@@ -2348,15 +2526,7 @@ describe("AgentConfigForm environment selector", () => {
await runTest(result.container);
expect(findButton(result.container, "Sign in")).toBeTruthy();
const select = result.container.querySelector("select");
await act(async () => {
if (select) {
const setter = Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, "value")?.set;
setter?.call(select, "");
select.dispatchEvent(new Event("change", { bubbles: true }));
}
});
await flushReact();
await selectEnvironment(result.container, "");
expect(findButton(result.container, "Sign in")).toBeFalsy();
});
+118 -174
View File
@@ -1,4 +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 { aiRoutingHarness } from "@paperclipai/shared";
import { AiConnectionField } from "./ai-connections/AiConnectionField";
import { aiConnectionBindingSchema, aiRuntimeConnectionBindingSchema } from "@paperclipai/shared";
@@ -38,16 +41,11 @@ import { DEFAULT_CURSOR_LOCAL_MODEL } from "@paperclipai/adapter-cursor-local";
import { DEFAULT_GEMINI_LOCAL_MODEL } from "@paperclipai/adapter-gemini-local";
import { DEFAULT_KIMI_LOCAL_MODEL } from "@paperclipai/adapter-kimi-local";
import { DEFAULT_OPENCODE_LOCAL_MODEL } from "@paperclipai/adapter-opencode-local";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { NativeSelect, selectTriggerClassName } from "@/components/ui/select";
import { NativeSelect, SelectPopover } from "@/components/ui/select";
import { AdapterMark } from "./AdapterMark";
import { FolderOpen, Heart, ChevronDown, X, Copy, Check, ExternalLink, Loader2, TriangleAlert, Bug } from "lucide-react";
import { FolderOpen, Heart, X, Copy, Check, ExternalLink, Loader2, TriangleAlert, Bug } from "lucide-react";
import { asBoolean, asFiniteNumber, asObject, cn } from "../lib/utils";
import { copyTextToClipboard } from "../lib/clipboard";
import {
@@ -191,6 +189,7 @@ function isOverlayDirty(o: AgentConfigOverlay): boolean {
return (
Object.keys(o.identity).length > 0 ||
o.adapterType !== undefined ||
o.runner !== undefined ||
Object.keys(o.adapterConfig).length > 0 ||
Object.keys(o.heartbeat).length > 0 ||
Object.keys(o.debug).length > 0 ||
@@ -246,6 +245,7 @@ export function subtractPersistedOverlay(
);
return {
identity: subtractGroup(current.identity, persisted.identity),
...(current.runner !== undefined && current.runner !== persisted.runner ? { runner: current.runner } : {}),
...(current.adapterType !== undefined && current.adapterType !== persisted.adapterType
? { adapterType: current.adapterType }
: {}),
@@ -606,8 +606,29 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
const adapterType = isCreate
? props.values.adapterType
: overlay.adapterType ?? props.agent.adapterType;
const effectiveConfig = isCreate ? props.values.adapterSchemaValues ?? {} : { ...config, ...overlay.adapterConfig };
const harnessType = agentHarnessType(adapterType, effectiveConfig);
const isCodexHarness = harnessType === "codex_local";
const rawCurrentDefaultEnvironmentId = isCreate
? props.values.defaultEnvironmentId ?? ""
: eff("identity", "defaultEnvironmentId", props.agent.defaultEnvironmentId ?? "");
const currentDefaultEnvironmentId = useMemo(() => {
if (!rawCurrentDefaultEnvironmentId) return "";
const selected = environments.find((environment) => environment.id === rawCurrentDefaultEnvironmentId) ?? null;
return selected?.driver === "local" ? "" : rawCurrentDefaultEnvironmentId;
}, [environments, rawCurrentDefaultEnvironmentId]);
const runnerDiscovery = useQuery({
queryKey: queryKeys.adapters.availability(selectedCompanyId, rawCurrentDefaultEnvironmentId || null),
queryFn: () => adaptersApi.list({ companyId: selectedCompanyId!, environmentId: rawCurrentDefaultEnvironmentId || null }),
enabled: Boolean(selectedCompanyId),
});
const codexRunnerAvailability = runnerDiscovery.data?.find(item => item.type === "codex_local");
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")
: adapterType;
const getCapabilities = useAdapterCapabilities();
const adapterCaps = getCapabilities(adapterType);
const adapterCaps = getCapabilities(isCodexHarness ? harnessType : adapterType);
const isDotRunner = adapterType === "paperclip_runner" && (isCreate ? props.values.adapterSchemaValues?.provider : eff("adapterConfig", "provider", config.provider)) === "openai_dot";
const isLocal = !isDotRunner && (adapterCaps.supportsInstructionsBundle || adapterCaps.supportsSkills || adapterCaps.supportsLocalAgentJwt);
@@ -619,9 +640,10 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
&& !hideHostPaths
&& shouldShowLegacyWorkingDirectoryField({ isCreate, adapterConfig: config });
const uiAdapter = useMemo(() => getUIAdapter(adapterType), [adapterType]);
const fieldsUiAdapter = useMemo(() => getUIAdapter(fieldsAdapterType), [fieldsAdapterType]);
const supportedEnvironmentDrivers = useMemo(
() => new Set(supportedEnvironmentDriversForAdapter(adapterType)),
[adapterType],
() => new Set(supportedEnvironmentDriversForAdapter(fieldsAdapterType)),
[fieldsAdapterType],
);
const val = isCreate ? props.values : null;
const set = isCreate
@@ -774,14 +796,6 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
invalidateUserSecretDefinitions();
};
const rawCurrentDefaultEnvironmentId = isCreate
? val!.defaultEnvironmentId ?? ""
: eff("identity", "defaultEnvironmentId", props.agent.defaultEnvironmentId ?? "");
const currentDefaultEnvironmentId = useMemo(() => {
if (!rawCurrentDefaultEnvironmentId) return "";
const selected = environments.find((environment) => environment.id === rawCurrentDefaultEnvironmentId) ?? null;
return selected?.driver === "local" ? "" : rawCurrentDefaultEnvironmentId;
}, [environments, rawCurrentDefaultEnvironmentId]);
const currentDefaultEnvironment = useMemo(
() => environments.find((environment) => environment.id === currentDefaultEnvironmentId) ?? null,
[currentDefaultEnvironmentId, environments],
@@ -904,8 +918,9 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
).data?.provider === "openrouter" ? "openrouter" : runnerProvider;
const connectionModels = useConnectionModels(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 modelQueryKey = selectedCompanyId
? queryKeys.agents.adapterModels(selectedCompanyId, adapterType, currentDefaultEnvironmentId || null, modelProvider)
? queryKeys.agents.adapterModels(selectedCompanyId, modelAdapterType, currentDefaultEnvironmentId || null, modelProvider)
: ["agents", "none", "adapter-models", adapterType];
const {
data: fetchedModels,
@@ -913,7 +928,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
isLoading: fetchingModels,
} = useQuery({
queryKey: modelQueryKey,
queryFn: () => agentsApi.adapterModels(selectedCompanyId!, adapterType, {
queryFn: () => agentsApi.adapterModels(selectedCompanyId!, modelAdapterType, {
environmentId: currentDefaultEnvironmentId || null,
provider: modelProvider,
}),
@@ -954,7 +969,8 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
agentId: isCreate ? undefined : props.agent.id,
mode,
isCreate,
adapterType,
adapterType: fieldsAdapterType,
hideRunnerHarness: isCodexHarness,
values: isCreate ? props.values : null,
set: isCreate ? (patch: Partial<CreateConfigValues>) => props.onChange(patch) : null,
config,
@@ -979,7 +995,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
const renderAdapterFields = (section: AdapterConfigSection) => (
<>
{adapterType === "claude_local" && <ClaudeLocalAdvancedFields {...adapterFieldProps} section={section} />}
<uiAdapter.ConfigFields {...adapterFieldProps} section={section} hideModel={isLocal} />
<fieldsUiAdapter.ConfigFields {...adapterFieldProps} section={section} hideModel={isLocal} />
</>
);
// Popover states
@@ -1088,9 +1104,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 });
return testAgentSetup({ companyId: selectedCompanyId, adapterType, providerAdapter, adapterConfig, agentId, aiConnection, environmentId, ...(isCodexHarness && (isCreate ? runner !== "auto" : overlay.runner !== undefined) ? { runner } : {}) });
}
return agentsApi.testEnvironment(selectedCompanyId, adapterType, { adapterConfig, agentId, aiConnection, environmentId });
return agentsApi.testEnvironment(selectedCompanyId, adapterType, { adapterConfig, agentId, aiConnection, environmentId, ...(isCodexHarness && (isCreate ? runner !== "auto" : overlay.runner !== undefined) ? { runner } : {}) });
},
});
const [testActionPending, setTestActionPending] = useState(false);
@@ -1137,7 +1153,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
resetTestEnvironmentRef.current();
setTestActionError(null);
clearClaudeLoginClaimRef.current();
}, [adapterType, effectiveLoginEnvironmentId]);
}, [adapterType, runner, effectiveLoginEnvironmentId, runner === "auto" ? codexRunnerAvailability?.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
@@ -1148,7 +1164,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
const adapterSupportsSandboxLogin = adapterCaps.login != null;
const testResult = testEnvironment.data;
const testResultSupportsSandboxLogin =
testResult != null && getCapabilities(testResult.adapterType).login != null;
testResult != null && getCapabilities(isCodexHarness ? harnessType : testResult.adapterType).login != null;
const authMissingCheck =
testResult && testResultSupportsSandboxLogin
? testResult.checks.find((check) => check.code === ADAPTER_AUTH_MISSING_CHECK_CODE) ?? null
@@ -1243,7 +1259,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
// environment id, so both are present here.
login:
showAdapterLogin && selectedCompanyId && effectiveLoginEnvironmentId
? { companyId: selectedCompanyId, adapterType, environmentId: effectiveLoginEnvironmentId }
? { companyId: selectedCompanyId, adapterType: isCodexHarness ? harnessType : adapterType, environmentId: effectiveLoginEnvironmentId }
: null,
});
return () => {
@@ -1271,7 +1287,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
setRefreshingModels(true);
setRefreshModelsError(null);
try {
const refreshed = await agentsApi.adapterModels(selectedCompanyId, adapterType, { refresh: true, environmentId: currentDefaultEnvironmentId || null, provider: modelProvider });
const refreshed = await agentsApi.adapterModels(selectedCompanyId, modelAdapterType, { refresh: true, environmentId: currentDefaultEnvironmentId || null, provider: modelProvider });
queryClient.setQueryData(modelQueryKey, refreshed);
} catch (error) {
setRefreshModelsError(error instanceof Error ? error.message : "Failed to refresh adapter models.");
@@ -1281,7 +1297,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
}
const thinkingEffortKey =
adapterType === "codex_local"
isCodexHarness
? "modelReasoningEffort"
: adapterType === "cursor"
? "mode"
@@ -1290,7 +1306,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
: adapterType === "grok_local" ? "reasoningEffort"
: adapterType === "pi_local" ? "thinking" : "effort";
const thinkingEffortOptions =
adapterType === "codex_local"
isCodexHarness
? codexReasoningEffortOptions(currentModelId, "Auto").map((option) => ({
id: option.value,
label: option.label,
@@ -1311,11 +1327,11 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
: claudeThinkingEffortOptions;
const currentThinkingEffort = isCreate
? val!.thinkingEffort
: adapterType === "codex_local"
: isCodexHarness
? eff(
"adapterConfig",
"modelReasoningEffort",
String(config.modelReasoningEffort ?? config.reasoningEffort ?? ""),
thinkingEffortKey,
String(config[thinkingEffortKey] ?? config.reasoningEffort ?? config.modelReasoningEffort ?? ""),
)
: adapterType === "cursor"
? eff("adapterConfig", "mode", String(config.mode ?? ""))
@@ -1324,7 +1340,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
: eff("adapterConfig", thinkingEffortKey, String(config[thinkingEffortKey] ?? ""));
const showThinkingEffort = adapterType !== "gemini_local"
&& adapterType !== "cursor_cloud"
&& adapterType !== "paperclip_runner";
&& (adapterType !== "paperclip_runner" || isCodexHarness);
const codexSearchEnabled = adapterType === "codex_local"
? (isCreate ? Boolean(val!.search) : eff("adapterConfig", "search", Boolean(config.search)))
: false;
@@ -1563,25 +1579,24 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
<div className={cn(cards ? "border border-border rounded-lg p-4 space-y-3" : "px-4 pb-3 space-y-3")}>
<Field label="Environment override">
<div className="space-y-2">
<select
className={inputClass}
<SelectPopover
aria-label="Environment override"
value={currentDefaultEnvironmentId}
onChange={(event) => {
const nextValue = event.target.value;
onValueChange={(nextValue) => {
if (isCreate) {
set!({ defaultEnvironmentId: nextValue });
return;
}
mark("identity", "defaultEnvironmentId", nextValue || null);
}}
>
<option value="">Default: {inheritedEnvironmentLabel}</option>
{environmentOptions.map((environment) => (
<option key={environment.id} value={environment.id}>
{environmentDisplayLabel(environment)}
</option>
))}
</select>
options={[
{ value: "", label: `Default: ${inheritedEnvironmentLabel}` },
...environmentOptions.map((environment) => ({
value: environment.id,
label: environmentDisplayLabel(environment),
})),
]}
/>
</div>
</Field>
</div>
@@ -1610,18 +1625,19 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
</div>
<div className={cn(cards ? "border border-border rounded-lg p-4 space-y-3" : "px-4 pb-3 space-y-3")}>
{showAdapterTypeField && (
<Field label="Adapter type" hint={help.adapterType}>
<Field label="Harness" hint={help.adapterType}>
<AdapterTypeDropdown
value={isDotRunner ? "openai_dot" : adapterType}
value={isDotRunner ? "openai_dot" : isCodexHarness ? harnessType : adapterType}
disabledTypes={adapterPickerDisabledTypes}
openAiDotEnabled={experimentalSettings?.enableOpenAiDot === true}
nativeRunnerEnabled={experimentalSettings?.enableNativeRunner === true}
onChange={(choice) => {
const dot = choice === "openai_dot";
const t = dot ? "paperclip_runner" : choice;
if (isCreate) {
// Reset all adapter-specific fields to defaults when switching adapter type
const { adapterType: _at, ...defaults } = defaultCreateValues;
const nextValues: CreateConfigValues = { ...defaults, adapterType: t };
const nextValues: CreateConfigValues = { ...defaults, adapterType: t, runner: undefined };
if (t === "codex_local") {
nextValues.dangerouslyBypassSandbox =
DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX;
@@ -1647,6 +1663,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
setOverlay((prev) => ({
...prev,
adapterType: t,
runner: t === "codex_local" ? "auto" : undefined,
adapterConfig: {
model:
t === "gemini_local"
@@ -1665,12 +1682,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
modelReasoningEffort: "",
variant: "",
mode: "",
...(t === "codex_local"
? {
dangerouslyBypassApprovalsAndSandbox:
DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX,
}
: dot
...(dot
? { provider: "openai_dot", lifecycleMode: "per_turn", allowUnmeteredProvider: false, dotAttachmentAccess: false, dotWorkspaceAccess: false }
: t === "paperclip_runner"
? {
@@ -1707,7 +1719,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
<AdapterLoginPanel
key={`${adapterType}:${effectiveLoginEnvironmentId}`}
companyId={selectedCompanyId!}
adapterType={adapterType}
adapterType={isCodexHarness ? harnessType : adapterType}
environmentId={effectiveLoginEnvironmentId!}
onStored={isCreate ? handleClaudeLoginStored : handleClaudeLoginStoredEdit}
onApplyStored={
@@ -1749,8 +1761,8 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
loadingModels={connectionModels?.isLoading ?? fetchingModels}
value={currentModelId}
onChange={(v) => {
const supportedEfforts = setupEfforts(adapterType, v);
const clearUnsupportedEffort = ["codex_local", "claude_local", "grok_local"].includes(adapterType)
const supportedEfforts = setupEfforts(isCodexHarness ? harnessType : adapterType, v);
const clearUnsupportedEffort = ["codex_local", "claude_local", "grok_local"].includes(isCodexHarness ? harnessType : adapterType)
&& Boolean(currentThinkingEffort)
&& !supportedEfforts.includes(String(currentThinkingEffort));
if (isCreate) {
@@ -1816,7 +1828,10 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
onChange={(v) =>
isCreate
? set!({ thinkingEffort: v })
: mark("adapterConfig", thinkingEffortKey, v || undefined)
: (() => {
mark("adapterConfig", thinkingEffortKey, v || undefined);
if (isCodexHarness) mark("adapterConfig", thinkingEffortKey === "reasoningEffort" ? "modelReasoningEffort" : "reasoningEffort", undefined);
})()
}
open={thinkingEffortOpen}
onOpenChange={setThinkingEffortOpen}
@@ -1877,6 +1892,11 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
onToggle={() => setConfigurationAdvancedOpen(!configurationAdvancedOpen)}
>
<div className="space-y-3">
{isCodexHarness && <CodexRunnerSelect value={runner}
pending={runnerDiscovery.isFetching} error={runnerDiscovery.error?.message} onRetry={() => { void runnerDiscovery.refetch(); }}
defaultRunner={codexRunnerAvailability?.defaultRunner}
supportedRunners={codexRunnerAvailability?.supportedRunners}
onChange={next => isCreate ? set!({ runner: next }) : setOverlay(prev => ({ ...prev, runner: next }))} />}
{isLocal && (<> {/*
The command names a binary on the execution host, so the
managed-sandbox-only policy hides it: the platform-managed image
@@ -3679,77 +3699,32 @@ export function AdapterTypeDropdown({
onChange,
disabledTypes,
openAiDotEnabled = false,
nativeRunnerEnabled = false,
}: {
value: string;
onChange: (type: string) => void;
disabledTypes: Set<string>;
openAiDotEnabled?: boolean;
nativeRunnerEnabled?: boolean;
}) {
const [open, setOpen] = useState(false);
const selectedDisplay = getAdapterDisplay(value);
const adapterList = useMemo(
() =>
[...listAdapterOptions((type) => adapterLabels[type] ?? getAdapterLabel(type)),
...(openAiDotEnabled ? [{ value: "openai_dot", label: "OpenAI Dot", experimental: true, comingSoon: false }] : []),
].filter(
(item) => !disabledTypes.has(item.value),
),
[disabledTypes, openAiDotEnabled],
);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
data-size="default"
className={cn(selectTriggerClassName, "w-full")}
>
<span className="inline-flex min-w-0 items-center gap-1.5">
<span aria-hidden="true" className="inline-flex shrink-0">
<AdapterMark type={value} className="size-4" />
</span>
<span className="truncate">{adapterLabels[value] ?? getAdapterLabel(value)}</span>
{selectedDisplay.experimental && <ExperimentalBadge />}
</span>
<ChevronDown className="size-4 opacity-50" />
</button>
</PopoverTrigger>
<PopoverContent className="w-(--radix-popover-trigger-width) p-1" align="start">
{adapterList.map((item) => (
<button
key={item.value}
type="button"
disabled={item.comingSoon}
className={cn(
"flex items-center justify-between w-full px-2 py-1.5 text-sm rounded",
item.comingSoon
? "opacity-40 cursor-not-allowed"
: "hover:bg-accent/50",
item.value === value && !item.comingSoon && "bg-accent",
)}
onClick={() => {
if (!item.comingSoon) {
onChange(item.value);
setOpen(false);
}
}}
>
<span className="inline-flex items-center gap-1.5">
<span aria-hidden="true" className="inline-flex shrink-0">
<AdapterMark type={item.value} className="size-4" />
</span>
<span>{item.label}</span>
{item.experimental && <ExperimentalBadge />}
</span>
{item.comingSoon && (
<span className="text-(length:--text-nano) text-muted-foreground">Coming soon</span>
)}
</button>
))}
</PopoverContent>
</Popover>
);
const adapterList = useMemo(() =>
[...listAdapterOptions(type => adapterLabels[type] ?? getAdapterLabel(type)),
...(openAiDotEnabled ? [{ value: "openai_dot", label: "OpenAI Dot", experimental: true, comingSoon: false }] : []),
].filter(item => (item.value !== "paperclip_runner" || nativeRunnerEnabled) && !disabledTypes.has(item.value))
.sort((a, b) => Number(a.value === "paperclip_runner") - Number(b.value === "paperclip_runner")),
[disabledTypes, openAiDotEnabled, nativeRunnerEnabled]);
return <SelectPopover aria-label="Harness" value={value} onValueChange={onChange}
displayValue={<span className="inline-flex min-w-0 items-center gap-1.5">
<span aria-hidden="true" className="inline-flex shrink-0"><AdapterMark type={value} className="size-4" /></span>
<span className="truncate">{adapterLabels[value] ?? getAdapterLabel(value)}</span>
{selectedDisplay.experimental && <ExperimentalBadge />}
</span>}
options={adapterList.map(item => ({ value: item.value, disabled: item.comingSoon,
group: item.value === "paperclip_runner" ? "Advanced" : undefined,
label: <span className="inline-flex items-center gap-1.5"><span aria-hidden="true" className="inline-flex shrink-0"><AdapterMark type={item.value} className="size-4" /></span><span>{item.label}</span>{item.experimental && <ExperimentalBadge />}</span>,
suffix: item.comingSoon ? <span className="text-(length:--text-nano) text-muted-foreground">Coming soon</span> : undefined,
}))} />;
}
function ExperimentalBadge() {
@@ -3917,29 +3892,17 @@ export function ModelDropdown({
return (
<Field label="Model" hint={help.model}>
<Popover
<SelectPopover aria-label="Model" value={value}
displayValue={<span className={cn("truncate", !value && "text-muted-foreground")}>
{selected?.label ?? (value || (allowDefault ? (defaultLabel ?? "Default") : required ? "Select model (required)" : "Select model"))}
</span>}
open={open}
onOpenChange={(nextOpen) => {
onOpenChange(nextOpen);
if (!nextOpen) setModelSearch("");
}}
>
<PopoverTrigger asChild>
<button
type="button"
data-size="default"
className={cn(selectTriggerClassName, "w-full")}
>
<span className={cn("truncate", !value && "text-muted-foreground")}>
{selected
? selected.label
: value
|| (allowDefault ? (defaultLabel ?? "Default") : required ? "Select model (required)" : "Select model")}
</span>
<ChevronDown className="size-4 opacity-50" />
</button>
</PopoverTrigger>
<PopoverContent className="w-(--radix-popover-trigger-width) p-1" align="start">
<div role="listbox" aria-label="Model">
<div className="relative mb-1">
<input
className="w-full px-2 py-1.5 pr-6 text-xs bg-transparent outline-none border-b border-border placeholder:text-muted-foreground/50"
@@ -3998,6 +3961,7 @@ export function ModelDropdown({
{value && (!models.some((m) => m.id === value) || promotedModelIds.has(value)) && (
<button
type="button"
role="option" aria-selected
className={cn(
"flex items-center w-full px-2 py-1.5 text-sm rounded bg-accent/50",
)}
@@ -4016,6 +3980,7 @@ export function ModelDropdown({
{detectedModel && detectedModel !== value && (
<button
type="button"
role="option" aria-selected={false}
className={cn(
"flex items-center w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50",
)}
@@ -4040,6 +4005,7 @@ export function ModelDropdown({
<button
key={`detected-${candidate}`}
type="button"
role="option" aria-selected={false}
className={cn(
"flex items-center w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50",
)}
@@ -4061,6 +4027,7 @@ export function ModelDropdown({
{allowDefault && (
<button
type="button"
role="option" aria-selected={!value}
className={cn(
"flex items-center gap-2 w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50",
!value && "bg-accent",
@@ -4076,6 +4043,7 @@ export function ModelDropdown({
{canCreateManualModel && (
<button
type="button"
role="option" aria-selected={false}
className="flex items-center justify-between gap-2 w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50"
onClick={() => {
onChange(manualModel);
@@ -4098,6 +4066,7 @@ export function ModelDropdown({
<button
type="button"
key={m.id}
role="option" aria-selected={m.id === value}
className={cn(
"flex items-center w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50",
m.id === value && "bg-accent",
@@ -4124,8 +4093,8 @@ export function ModelDropdown({
</div>
)}
</div>
</PopoverContent>
</Popover>
</div>
</SelectPopover>
</Field>
);
}
@@ -4143,36 +4112,11 @@ function ThinkingEffortDropdown({
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const selected = options.find((option) => option.id === value) ?? options[0];
return (
<Field label="Thinking effort" hint={help.thinkingEffort}>
<Popover open={open} onOpenChange={onOpenChange}>
<PopoverTrigger asChild>
<button className="inline-flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1.5 text-sm hover:bg-accent/50 transition-colors w-full justify-between">
<span className={cn(!value && "text-muted-foreground")}>{selected?.label ?? "Auto"}</span>
<ChevronDown className="h-3 w-3 text-muted-foreground" />
</button>
</PopoverTrigger>
<PopoverContent className="w-(--radix-popover-trigger-width) p-1" align="start">
{options.map((option) => (
<button
key={option.id || "auto"}
className={cn(
"flex items-center justify-between w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50",
option.id === value && "bg-accent",
)}
onClick={() => {
onChange(option.id);
onOpenChange(false);
}}
>
<span>{option.label}</span>
{option.id ? <span className="text-xs text-muted-foreground font-mono">{option.id}</span> : null}
</button>
))}
</PopoverContent>
</Popover>
</Field>
);
return <Field label="Thinking effort" hint={help.thinkingEffort}>
<SelectPopover aria-label="Thinking effort" value={value} onValueChange={onChange}
open={open} onOpenChange={onOpenChange}
options={options.map(option => ({ value: option.id, label: option.label,
suffix: option.id ? <span className="text-xs text-muted-foreground font-mono">{option.id}</span> : undefined,
}))} />
</Field>;
}
Loaded 100 of 129 files, more files were not shown because too many files have changed in this diff. Show more