Merge current upstream and regenerate cursor migration after admission retention

This commit is contained in:
Dotta committed 2026-11-03 17:54:11 -06:00
commit d8cd3be0ae
531 files changed
+11374 -2665

No files matched your search

@@ -32,6 +32,9 @@ available tools and records before asking the user for missing information:
- Which endpoint is authorized: verified fix and green PR, or also rollout and
recovery. Preserve the requested work and success criteria throughout.
Use the [bounded run diagnostics reference](../../../skills/paperclip/references/run-diagnostics.md)
for the repository's existing run, event, log, and recovery reads.
Use authorized diagnostic access. If company scoping blocks a read, use an
already-authorized diagnostic path or label the missing evidence; do not bypass
the boundary.
@@ -111,6 +114,10 @@ red-green proof from a test that was first run after the fix.
## 4. Repair the cause and prove the safety assumptions
Before changing a failing guard, name the invariant it protects. If existing runtime
checks already establish compatibility, remove redundant prerequisites instead of
adding qualification machinery; retain authority and integrity checks.
Fix the responsible producer or shared transition at the narrowest correct scope.
Check sibling callers/adapters that share that contract; generalize only as far as
the evidence requires. Avoid historical string allowlists, swallowed errors,
+26 -6
View File
@@ -162,6 +162,12 @@ Confirmations whose source work is still syncing show “Preparing approval…
disable acceptance until the server reports readiness. Refresh that state automatically;
rejection and revision remain available. Live tool reviews keep their own approval flow.
Confirmation and checkbox confirmation prompts use the shared Markdown renderer.
Links, lists, emphasis, code and paragraph breaks remain readable in the task
composer, classic cards and expanded receipts.
Prompt images appear as references and diagrams as source text. Checkbox groups
use the rendered prompt as their accessible label.
Pending questions, confirmations, and other task-thread inputs appear in a separate
card directly above the ordinary composer. The composer stays available for new
messages while the card is open. Questions use their compact history entry as the
@@ -439,21 +445,23 @@ width tokens and configurable column order. Review the complete composition in
level. Task IDs and names use natural, case-insensitive ordering; timestamps use
their raw value. Tags sort by their
alphabetized names; missing values stay last in both directions. Equal values
retain their order. The initial collection uses Task ID ascending as its
default order, with no active header, direction arrow or persistent highlight.
A header becomes active only after the user sorts by it; the first click sorts
ascending.
retain their order. Task lists default to Last updated descending, with the
Last updated header active and its downward arrow visible. Parent tasks stay
before their descendants; roots and siblings use newest-first update order.
With nesting disabled, all tasks use that order in one flat list. Explicit
URL sorting and saved user sorting retain precedence over the default.
Subtask lists use the same default while keeping checklist controls available.
- A horizontal ellipsis stays pinned to the right edge of the header, outside
horizontal scrolling. Its “Columns” flyout lists all nine columns in their current order
with checkmarks on the right. Toggling columns updates both header and rows
immediately and keeps the flyout open. Keep at least one column visible; nesting
controls remain available even when Task is hidden. Reset defaults shows only
Task, Task ID, and Last updated and restores the default column order.
Reset also clears the active sort, direction arrow and persistent header highlight.
Reset also restores Last updated descending and its active header.
Other columns remain available in the menu; hiding and re-enabling a column
preserves its position until reset.
Hiding the sorted column clears the explicit sort and restores the default
collection order without highlighting another header.
collection order. Last updated shows the active sort when that column is visible.
- The refresh's compact flyout uses the shared DropdownMenu primitives. The
Paper reference defines a 200px surface with an 8px radius, 12px content
padding, 32px items separated by 4px, regular-weight muted labels and 14px
@@ -627,6 +635,18 @@ and full page consume the same `TaskList` collection.
Sidebar links exercise the memory router; destination pages, creation API
behavior, production preferences and production routes are outside this preview.
Coordinator adopts the same four-option menu with Inbox as its initial and
fallback view. Its last-used view is stored per user across organizations;
explicit view URLs take precedence, and organization-filter links without a
view open All tasks. Unread uses the server's viewer read state. Coordinator's
Needs attention view retains its canonical pending-decision task feed.
Status filters preserve Inbox and Unread. Unread loads accessible read ancestors
as hierarchy context, without counting them as unread matches.
Its sidebar groups Chat, Tasks and Needs you with space after New task and
before More, and keeps global search beside the organization menu (below it
in the collapsed rail).
Recents appears only when it contains entries, without an empty state.
### Reuse collection behavior as well as appearance
`useTaskListCollection` in `ui/src/hooks/` is the shared presentation controller
+14 -4
View File
@@ -773,17 +773,27 @@ path enforces a project maximum.
and a partial unique index allows one unsettled attempt per run. The row keeps
the context revision, the nullable primary project as provenance (no foreign
key) and the settlement identity, which is set together with `settled_at`.
`admitted_project_ids` is the complete admitted project set, sorted, kept
after settlement and immutable (a trigger rejects any change). It has no
project foreign key, so settled history never blocks project deletion and
keeps the deleted project's ID. `{}` is a known empty set. Null means
unknown and is allowed only on settled rows: migration `0428` filled it from
the charges of unsettled rows, but settled `0426` rows had already lost
their set. A retained provider session resumes with the previous set ∪ the
current references; an unknown previous set requires a fresh context, never
an empty or primary-only guess.
- `execution_admission_project_charges` stores one row per distinct admitted
project, including unlimited projects. An admission without projects has no
rows. Composite `(company_id, …)` foreign keys keep admissions and projects in
one company. Rows exist only while the admission is unsettled: settlement
deletes the whole set in one transaction, and `(company_id, project_id)` is
indexed for active counts, so lookups scale with active work, not history.
one company. Rows are the only active capacity and exist only while the
admission is unsettled: settlement deletes the whole set in one transaction,
and `(company_id, project_id)` is indexed for active counts, so lookups
scale with active work, not history.
Archiving a project changes no charge. Deleting a project with a charge returns
`409 project_execution_admission_pending`; the foreign key is the backstop.
Deleting an agent refuses unsettled admissions on its runs and removes settled
ones. Company deletion removes the company's admissions before its runs and
ones, with their sets, together with the runs. Company deletion removes the company's admissions before its runs and
projects.
The proposed global lock order for the admission transaction is: company row
+8 -1
View File
@@ -1186,7 +1186,8 @@ process per layer. Each layer reads the earlier layers as declarations:
| `below` | Files the largest import cycle depends on, and unrelated files |
| `cycle` | The largest import cycle |
| `above` | Files that depend on the cycle |
| `tests` | Test files |
| `tests` | Test files outside `src/__tests__` |
| `test-tree-0` to `test-tree-2` | `src/__tests__`, in shards |
The generated configs and declarations go in
`server/node_modules/.cache/paperclip-typecheck`. You do not maintain a layer list.
@@ -1195,6 +1196,12 @@ The server typecheck builds only the Runner's TypeScript output. Use
`pnpm --filter @paperclipai/paperclip-runner build` when a local check needs the
Runner binary.
`src/__tests__/tsconfig.json` holds the options for the test-tree layers, so an
editor and the typecheck report the same errors. Server tests reach Daytona plugin
source, but the root install does not install that plugin's dependencies. So this
config maps `@daytonaio/sdk` to `server/scripts/typecheck-stubs/daytona-sdk.d.ts`.
The plugin's own project checks that source against the real package.
If the memory available to the task is limited, use this command:
```sh
+7
View File
@@ -1374,6 +1374,13 @@ Invites tab does not hide this Cloud action.
## 10.3 Agents
Saving an existing agent's configuration with a healthy saved AI account checks
current account access, installation, provider/model compatibility, and environment
selection without allocating a computer or running a provider hello test. Use
**Test** to verify the runtime. Unverified legacy-account adoption, task connection
adoption, and Copilot's account-specific model checks retain their live validation.
A configuration save confirms persistence; it does not certify runtime readiness.
The Harness / Runtime configuration page offers **Upgrade runner** for existing
legacy Claude, Codex, and Grok Build agents when the target environment selects Paperclip
Runner by default. The action uses the normal authorized agent PATCH with
+5 -3
View File
@@ -1247,8 +1247,10 @@ This is trusted controller timing policy, outside the immutable Pi wrapper/profi
closure; it does not change profile 13, runtime 1.0.0, or the explicit model.
The credential-free closed-startup regression requires the real
`session_ensure_failed` rejection within 60 seconds, with no prompt, no provider
identity and confirmed cleanup. Its child watchdog is 80 seconds (20 seconds for
cleanup after admission), inside a 90-second test timeout. Failed evidence stays
`provider_authentication` rejection within 60 seconds, with no prompt, no provider
identity and confirmed cleanup. Pi reports the missing credential with ACP's
authentication-required error, so the rejection names that typed category instead
of the generic `session_ensure_failed`. Its child watchdog is 80 seconds (20
seconds for cleanup after admission), inside a 90-second test timeout. Failed evidence stays
in its reported temporary directory. These margins do not extend production
command deadlines or qualify a target platform without a fresh run.
+7
View File
@@ -24,6 +24,10 @@ Creation, installation, message waiting, and success use the same production
components and server evidence as the own-app flow. A click or OAuth return does
not constitute successful configuration.
Reopening a completed Slack setup retains its Slack handoff. **Done** opens the
saved connection settings without repeating activation or reactivating a paused
bot. Unfinished setups still complete their normal server verification first.
## Cloud deployment prerequisites
Managed setup is disabled by default. Qualification requires all of:
@@ -78,6 +82,9 @@ Provider-side app removal remains explicit through the saved Slack management UR
permissions therefore pass through installation again before activation.
- Expired or revoked manager grants require reconnection. Losing managed
availability pauses setup; it never changes the saved ownership method.
A saved draft that needs workspace reauthorization shows **Reconnect Slack
workspace** on Connect, even before its app has been created. This button
opens Slack authorization and returns to the same saved agent setup.
- Slack rate limits are grouped by manager/workspace/method. Provider `Retry-After`
is persisted across matching grants in the company, survives restarts, and blocks
early retries. Other tenant instances must observe Slack's limits independently;
+8
View File
@@ -54,6 +54,14 @@ runner startup and provider startup. Shell operations acquire credentials for
the active run through that broker. They do not require a persistent GitHub CLI
login or inherit an ambient host broker capability.
Claude through native ACPX retains the controller-projected broker URL and
session/bridge capabilities alongside its managed launcher paths. Both isolated
and preconfigured homes use this transport for shell `git` and `gh` operations.
These capabilities stay out of persisted session environment data. Raw GitHub
tokens remain confined to each managed command; ambient host capabilities and
task-supplied broker overrides are excluded. A working connector tool alone does
not prove that the terminal received its broker transport.
Without a managed identity, local commits default to
`Paperclip <noreply@paperclip.ing>`. The launcher writes this token-free default
to a private per-operation global Git configuration file, removed on exit.
+122 -1
View File
@@ -920,6 +920,23 @@ recovery path with the specific scan cause and an operator action. Generic
stranded-work recovery must not grant another budget for these errors. This
does not automatically replay historical generic `setup_failed` runs.
Fresh remote workspace preparation timeouts use that same bounded retry budget.
The workspace owner re-reads its company-scoped authority after the timeout. It
must still have the same writer and epoch, with no location, saved version, or
unsaved task work. Only the initial directory/clone operation qualifies; restoring
saved work, acquiring additional repositories, provisioning, and saving do not.
The failed allocation is not reused by a successor. Normal lease cleanup and
fresh admission retain their existing ownership and execution gates. A lost
preparation reply therefore cannot cause a successor to overwrite that allocation.
Provider work must not have started and the run must not have retained native
execution identity. For that persisted bootstrap-only timeout, a successor may
claim the still-unpublished reservation after every prior lease is terminal and
successfully cleaned up, including providers whose release returns no receipt.
This grants no provider-stop evidence: published or saved work, retained execution
identity, unknown failures, active leases, and incomplete cleanup still require
their normal reconciliation and termination proof. Unknown historical setup errors remain
outside this policy, and generic recovery cannot renew its exhausted budget.
### Runner authentication and ACP startup handshake bounds
The native Runner waits up to the ten-second connection setup budget for the
@@ -949,6 +966,18 @@ A new user message can continue a terminal native run whose process fields were
The task thread exposes the guarded Retry action for failed or timed-out conversation runs and native preparation cancelled before provider startup when the never-started proof is verified. The server projects this eligibility on the recovery notice and rechecks it on Retry. Cleanup quarantine retains its inspection path, and non-conversation reconciliation gates remain enforced. Pending decisions, active execution, pause, budget, dependency, and ownership gates remain in force. A refused Retry reports its reason inline and remains available for another attempt. When a newer linked-human GitHub message was saved but its admission was skipped by a budget or agent-availability gate, ordinary task Retry selects that unstarted message before considering the older failed run. The existing chat task-Retry owner snapshots the complete pending batch, including eligible skipped input, into a new admission generation bound to the canonical processed deliveries, comments, principal, session generation, admitted instruction snapshots, and task Retry intent. It retains the original skipped receipt and failed result unchanged. A terminal failed publication of that old result also remains history for a distinct selected request; pending or uncertain publication still holds work, and the old output is never replayed. Board Retry refusals expose only stable allowlisted authority categories, never source payloads, identifiers, or credential fingerprints. Admission rechecks source integrity and current access, then uses the normal new-user-turn continuation proof: a committed failed result can remain history only after controller, successor, process, workspace, and exact remote-termination guards pass. Unknown ownership stays deferred; Retry cannot certify it. The existing chat retry queue owns delivery and bounded infrastructure retries; a committed receipt settles a lost acknowledgment without another execution. An accepted saved-input receipt discharges only its matching unadmitted-input gate, allowing later genuine conversation or tree-resume wakes without replaying the skipped request.
A deliberate Retry also binds an already completed file-preservation record to
that intent. Other recovery holds for the same source run can then use its saved
snapshot when selecting a fresh workspace. This binding preserves the recovery
generation, attempt budget, saved files, and stop checks; it does not repeat file
recovery or replay the failed turn. An accepted Retry that remains deferred shows
its persisted admission reason through a fixed public message. The task view
performs bounded follow-up reads after the click so asynchronous admission can
explain its wait without a page reload or idle polling.
Newer saved input keeps its own receipt while the existing Retry explanation stays
visible. Terminal waits direct the user to send new input and hide the Retry action
that cannot continue that attempt.
A continuation cancelled by the queued-run gate while waiting for review did not start a provider. Its matching dispatch-gate receipt and unclaimed execution fields keep this deliberate wait out of unknown-action reconciliation. Pending review and decision gates still apply. If an older server already created a recovery hold for this exact state, Retry or a new user message can request one fresh turn after the server verifies there is no provider, coordinator, or unfinished cleanup. Saved user input is reconsidered through the same admission path; the cancelled turn is not replayed.
Other legacy queued-run gate cancellations also stay out of unknown-action reconciliation when the terminal run has no retained execution authority, session, workspace recovery, resources, or execution events. The same proof removes the effect of an old false recovery hold only for its exact company, task, and run; the audit row remains. Started or conflicting work retains its hold. Review waits retain their guarded Retry path. New work still passes the current approval, dependency, budget, pause, and ownership gates.
@@ -1576,7 +1605,24 @@ unknown operations or partial transcripts do not. Replacement uses a fresh
session and retires only the exact predecessor's obsolete recovery hold while
recording the proof and successor lineage. Retained provider files are not edited.
Bootstrap retries, exact-checkpoint resumes, and fresh replacement sessions share three total provider attempts, including the original attempt. Linked run IDs, controller restarts, and duplicate wakes do not reset this budget. Automatic attempts retain the 30-second delay. Replacement scheduling and predecessor lineage commit together, with one successor per predecessor and admission through the normal task locks, authorization, pause, approval, and budget gates.
Bootstrap retries, exact-checkpoint resumes, and fresh replacement sessions share three total provider attempts, including the original attempt. Linked run IDs, controller restarts, and duplicate wakes do not reset this budget. Exact-checkpoint resumes retain the 30-second delay. Replacement scheduling and predecessor lineage commit together, with one successor per predecessor and admission through the normal task locks, authorization, pause, approval, and budget gates.
A bootstrap retry requires durable evidence that no provider session, provider
event, or checkpoint exists. A runner report that `session.open` failed or was
rejected qualifies, because that command runs before any prompt. An
indeterminate `session.open` outcome does not qualify. Bootstrap retries back
off: about 30 seconds after the first attempt and about 60 seconds after the
second, with jitter that is persisted with the failure. Before it schedules a
bootstrap retry, the executor looks for a known provider limit for the agent.
Evidence is a `rate_limit` or `quota_exhausted` provider failure on the startup
failure itself, or on an agent run that started in the last day, or a recorded
usage-limit error code. The retry then waits until the provider's reported
reset time, or until one hour after the limit failure when no reset time is
known. A later successful run of the agent clears older evidence. A limit never
holds a retry for more than one day. Limit evidence only delays a retry; it does
not grant retry authority or change the budget. When the budget is exhausted,
the task is blocked and its recovery action states that provider startup failed
before any provider work began.
A failed, unowned native sandbox retry can outlive an explicit Retry that already
completed successfully. Startup recovery and ordinary retry admission retire that
@@ -1610,6 +1656,45 @@ and plugin tasks. The running session then owns authority;
the old receipt must not conflict with a later warm run's journal identity.
Failed admission retains the receipt. Normal suspension writes a new receipt.
A per-turn sandbox turn can finish through recovery without a Runner
suspension receipt or failover backup on the controller. For example, its
post-turn checkpoint copy can fail on a full sandbox disk. The controller
journal from the database then cannot prove that the old Runner suspended.
When the next run starts in a new or replacement sandbox, admission archives
that old authority as a continuity break, and the run starts a new Runner.
All of these must be true:
- The prior run is terminal and its coordinator committed a result.
- The coordinator has no lease owner and no recovery state.
- The prior controller is proven stopped.
- The journal has a delivered terminal, only completed commands, and an
answer for each tool input.
- No local Runner lifecycle evidence exists for the old owner.
- Each quarantined root for the scope passes the same journal and local
Runner evidence checks for the same owner.
The provider session then follows the normal resume-or-replace policy. A
resumed sandbox, a warm session, a plugin task, a restart claim, or any
missing proof keeps the existing `prior_owner_terminal_state_indeterminate`
rejection.
Resume source selection skips a failed follow-up that never started provider
work. Such a run holds only the provider checkpoint that it inherited. Without
the skip, a retry selects it, and controller recovery fails with
`native_controller_recovery_unsupported:metadata_format` because the run has no
controller checkpoint. The skip requires all of these:
- The run failed with a bootstrap receipt that says provider work did not start.
- Its coordinator is `terminal_failure`, with no result, lease owner, or
controller process.
- It has no process identity, provider event, controller checkpoint, run
result, or controller recovery pointer.
- Its checkpoint is bound to this run and session and has no cursor.
- Its checkpoint names the same provider session as the next selected source.
Otherwise, the run stays a progress barrier. Runtime selection and controller
recovery use the same selector, so both then use the last committed owner.
A repository-free fresh plugin task directory remains the task's directory on
ordinary follow-ups. Reuse requires its exact company, task, workspace, provider
owner, fresh-attempt identity and path. Mode-only isolation settings do not turn
@@ -1749,6 +1834,19 @@ An interrupted conversation does not permanently block its task. For local conve
Shutdown, process loss, and provider failure use the existing durable failure retry counter and delay. Ordinary failure recovery permits at most two automatic retries in a failure chain. Accepted-interaction infrastructure recovery retains its existing bounded policy. Repeated scheduler visits reuse the same successor; restarting the server does not reset the counter. After exhaustion, automatic attempts stop. A new explicit user message can start a fresh run and failure budget. Productive max-turn continuation and confirmed workspace waits keep their separate existing semantics.
If shutdown interrupts Paperclip Runner preparation before native execution was
selected, recovery verifies that no provider, process launch, native result or
coordinator exists. The previous controller must have settled or lost its lease.
Required workspace recovery must have an exact saved-file receipt and stopped
leases before the task can continue. Once these checks pass, the server retires
its own unchanged bootstrap-failure block and schedules one bounded retry.
Startup also discovers this obligation when file recovery completed before the
restart. A pending Retry or saved message owns its continuation instead of
starting a competing automatic attempt. Retry uses the same preparation proof
and preserves the recovered workspace. Current task ownership, human blocks,
pauses, approvals, budgets and the existing retry limit remain effective.
Operator Stops retain their explicit continuation requirements.
Graceful shutdown interrupts an owned legacy adapter through its cancellation
control, then waits within one shared 30-second window for provider stop and
adapter settlement across all affected runs. A run that exceeds that wait stays
@@ -1860,6 +1958,20 @@ A source that already stopped and was released remains fenced until its export s
An operator Stop waits for provider termination. Remote sandbox providers may return a stopped/deleted receipt after their control-plane operation completes. Paperclip binds that receipt to the company, run, and exact lease; successful file cleanup, a terminal run row, or an in-sandbox shutdown event is not sufficient. Legacy conversational runs and native runs with an audited run-only Stop receive their shared cancellation acknowledgement after all remote leases have confirmed termination. A native interrupt acknowledgement alone does not establish containment. Workspace preservation remains a separate obligation and its recovery hold survives this acknowledgement. Stop alone never creates a continuation. A user message queued during remote cleanup is reconsidered when the provider confirms termination; it still passes normal admission and adopts pending comment IDs in order. Once stopped, the next explicit wake uses the same queue. A compatible saved ACP session can resume, and an unavailable or incompatible session can start fresh with the full task context. Run credentials and scratch paths remain scoped to the new run. A subtree pause requires Resume; a message does not bypass it.
Native workspace finalization can complete after an operator Stop has cancelled
its run. The accepted provider result and saved-file receipts remain available,
but late finalization preserves the run's cancellation status, finish time and
errors, together with its cancelled source wake. Retry exhaustion has the same
rule: workspace or status-decision failures retain cancellation while recording
their bounded finalization failure and existing file-recovery obligations.
Failure metadata merges into the current run without replacing concurrent
cancellation or accounting receipts. Task status remains a separate
server-arbiter decision over the accepted result. A valid completed result can
finish the task after run-only Stop; failed or incomplete work keeps its normal
nonterminal disposition. Stop does not reopen an already committed task. This
does not restore the cancelled run's API write authority. Finalization starts no
second provider execution and does not replace termination or accounting receipts.
Cancelled runs and runs with a recorded pending stop lose run-scoped API write authority, while reads remain available for diagnostics. This applies to ordinary tasks as well as conversations. Task and interaction mutations recheck that authority in the write transaction, so a cancelled run cannot overwrite a recovery disposition with a late Done or resolve an interaction after revocation. A task handoff that intentionally stops its own run can commit only with a server receipt tied to that exact request. Direct sandbox CLI adapters such as Grok register cancellation before preparation and keep ownership until host-owned termination is verified, including a sandbox acquired before adapter registration. A failed stop does not acknowledge cancellation or abandon an outstanding remote command. Interrupted workspace restore failures remain recorded for recovery; a stop receipt proves termination, not successful file restoration.
For native conversations, an authenticated user message sent after the previous run finishes can retire its execution recovery holds and start a fresh turn. Hold retirement and the new run are atomic. The previous transcript, tool outcomes, and recovery history remain intact. This starts a new conversation; it does not replay tool calls with unknown outcomes.
@@ -2346,6 +2458,15 @@ pending and continue to block a new run; a longer wait does not certify an outco
The one-hour client deadline does not renew with the Runner lease. If the client
deadline expires while Runner still owns a call, its effect remains unknown
until authoritative reconciliation. Expiry permits neither replay nor attachment.
The OpenCode bridge emits a closed finish-input diagnostic through its existing
optional diagnostic sink. It records the effective offered schema's SHA-256 and,
for at most eight rejected inputs per bridge, the argument root and continuation
presence/type enums. A SHA-256 of the existing call identity permits correlation;
a closed root/continuation/other category distinguishes the failing boundary.
It records no raw call identity, argument values, field names, schema text or
validator messages. A failed sink does not change admission or cancellation.
These facts distinguish a schema/input-shape rejection from a pending MCP call;
they do not identify the submitted value in older attempts without that evidence.
An adopted transport can receive authenticated queued semantic calls before
the driver publishes its recovered session and tool handler. It holds those
calls until the handler is installed, then rechecks the original authority and
+79
View File
@@ -0,0 +1,79 @@
# Negotiated domain deadlines
The `domain_deadlines_v1` catalog extends the existing owned idle-sleep handoff.
Cloud requests `scheduledWake=domain-deadlines-v1` and signs the fixed catalog in
`idleCapabilities.deadlineCatalog` on its stack-bound `task-drain:read` assertion.
A query parameter or an ordinary credential cannot grant this capability.
The response uses idle-safety version 3 with a scheduled proof of version 2,
kind `domain_deadlines`, catalog `domain_deadlines_v1`, opaque generation and
canonical UTC `nextWakeAt`. Every requested catalog also returns an outer
`deadlineProof: { catalog: "domain_deadlines_v1", generation }`, including when
there is no future alarm. Any scheduled proof must use that same generation.
Cloud rejects an omitted receipt, an older ingress-only version 3 response, and
a changed no-alarm generation on the final read. Legacy requests and versions
keep their prior rules.
The proof combines existing routine and issue-monitor obligations with:
- Eligible agent interval timers, using the same actionable-work, organization,
daily-run and daily-cost policy as ordinary heartbeat dispatch. Inspection
does not claim a timer or advance `lastHeartbeatAt`. Restore and worktree
scheduling overrides remain conservative blockers.
- Plugin jobs with a valid future `nextRunAt`, matching current declaration and
an exact worker-owned quiet catalog. An explicitly manual-only declaration
adds no timer. Missing worker/deadline, unfamiliar status, unfinished run or
settlement, and catalog changes refuse sleep.
- Retained finalized provider traces at their original 24-hour expiry. Active
capture/import, expired data and unfinished object deletion remain work.
Capture expiry and deletion retain their ordinary owner; sleep never extends
retention or changes reveal authorization.
- Personal GitHub grant continuity, using the owner's token-expiry-minus-one-hour
and 30-day access-refresh rules. Broker enrollment alone is insufficient:
exact ingress binding and actual grant deadline must both be proven. Missing
credentials, inactive/missing owner-scoped vault definitions or current token
versions, refresh leases, binding synchronization and unresolved retry records
remain blockers. Inspection reads credential ownership/version metadata and
never resolves plaintext token material.
Pending watchdog first-run grace (15 seconds) and automatic merge-confirmation
checks (five minutes) remain awake. They provide no useful safe sleep window
under the unchanged Cloud minimum of five minutes. Their cadence is preserved;
there is no merge observation migration or invented future timestamp. Grok
bindings and unfinished Grok routine recovery remain outside this catalog.
`grok-bot/routine.ts` owns `recoveryRequiredAt`, stale `sending` dispatches
(after 30 seconds), and accepted dispatch `expiresAt` (created with a ten-minute
lifetime). These are authorization/recovery obligations, not ordinary recurring
routine timestamps, and their bindings remain blocked. No alarm exemption is
inferred from the shared table name or a revoked UI connection.
Each inspection reads one repeatable-read snapshot under the owned admission
hold. Its generation binds the process, owner and expiry, passive state,
ordinary routine/monitor proof, exact domain rows and plugin worker identities.
Any changed state, cancellation, moved deadline, owner replacement or restart
invalidates the old proof. Unknown or malformed states fail closed.
The timer owner batches UTC-day run counts, exact decimal cost sums and actionable-work inputs. Credential ownership and selected current/previous version metadata use bounded chunks; they never read credential material or value hashes. Managed GitHub OAuth user and agent grants retain their owner deadlines. Other grant shapes grant no continuity exemption.
The additional domain projection is bounded to 64 data reads, a two-second monotonic budget for
starting further reads and 10,000 rows per family, while reserving 60 seconds of hold headroom. Overflow refuses sleep;
it never truncates the proof or changes a schedule. These limits supplement the
transaction's statement timeout. A query already in progress may still take its five-second statement timeout;
SQL execution can therefore finish about seven seconds after the projection
starts, plus bounded local projection/driver overhead. No subsequent data query
starts after budget exhaustion. This is not a strict two-second wall-clock
promise. The final hold and generation checks remain mandatory.
Cloud persists one existing registry alarm under its current sleep lease before
final proof validation and provider stop. It compares every proof field on the
final read and checks the time window again. The worker wakes 120 seconds before
the actual obligation, retries failed/deferred wakes after 30 seconds and keeps
its existing expiring claims and replacement fences. No tenant timer definitions
or new per-timer tables are stored in Cloud.
After wake, ordinary domain services retain authorization, budgets, concurrency,
atomic claims, cancellation and missed-work policy. Cloud wakes the stack; it
does not dispatch a business effect. Disable new registrations for rollback
while continuing to serve existing alarms, or wake their stacks before removing
the alarm service. Host support must precede enabling app exemptions. Local
unit/PostgreSQL evidence establishes the handoff logic, not deployed host or
Neon suspension, measured startup margins or fleet savings.
+9 -4
View File
@@ -1009,10 +1009,15 @@ a new terminal run transition. The event keeps the actual observed run status,
the adapter and the company, agent, task and run references. It uses fixed error
text and includes no message bodies, prompts or raw run diagnostics. Working,
scheduled-retry, resolved and user-action states do not produce this event.
Repeated reads of the same company/task/run/intent are suppressed by a bounded
process-local cache of the latest 1,000 reports. A server restart or cache eviction
can report an old failure again. This task-state event can accompany a separate
terminal run-failure report; the `failure_source` tag distinguishes them.
Repeated reads of the same company/task/run/intent are suppressed after the SDK
receives a successful HTTP delivery response. The process retains the latest
1,000 confirmed reports. An SDK capture ID alone does not confirm delivery:
processing backpressure, network errors and rate limits can discard the event.
Unconfirmed captures wait 60 seconds before a later task read can try again;
this pending cache also holds at most 1,000 entries and starts no background
timer. A server restart or cache eviction can report an old failure again.
This task-state event can accompany a separate terminal run-failure report;
the `failure_source` tag distinguishes them.
The versioned `paperclip` context supplies company and agent IDs and relative
API paths for the saved run, events, log, and optional task. A valid HTTP(S)
@@ -45,3 +45,60 @@ yet make the whole tenant eligible for enrollment.
bindings, archived sources, overlap/writer races, and unavailable sources.
- Unrelated tenants, production services, and agent task execution remain outside
the migration's mutation scope.
## Legacy recovery storage transition
The migration owner must distinguish file location from recovery completion.
An offline import can preserve a terminal failed run's local workspace without
claiming that its remote files were recovered. The snapshot and migration
receipt bind the original run and sync descriptor; the failed run, provider
allocation, recovery action, and unresolved state stay recorded.
After publication, the native sync owner must reject copyback to the old local
folder. Explicit recovery still validates the exact provider allocation and
stop evidence, then preserves remote files as an immutable recovery attachment
instead of merging into retired local authority. New remote allocations attach
those verified recovery copies through the existing attachment owner. Missing
or ambiguous remote sources remain unresolved; migration never authorizes
replaying the old agent turn or destroying its allocation.
Publication requires the offline deployment fence, terminal unowned runs,
unchanged descriptors and task bindings, verified local snapshot bytes, and a
single transaction publishing the migration receipt and remote authority.
Failed runs with result-less `observed` or `retryable_failure` coordinators can
also be preserved after their leases expire and the offline deployment fence is
in force. Their coordinator phase, checkpoint, and unresolved copyback remain
intact. Publication journals the original coordinator binding, releases only
that exact expired lease with a recovery-history receipt, and installs a run-scoped
admission fence: a replacement cannot automatically replay that old turn against
the removed local authority. Explicit file recovery and a new task continuation
use the retained sources and remote workspace. This is not successful recovery,
provider termination evidence, or permission to delete the original allocation.
After independently verified sandbox preservation, the task accepts the exact
migrated snapshot receipt for continuation without requiring a fresh workspace.
For older runs without a synchronization descriptor, the migration journals
all declared recovery lease/allocation/directory identities under row locks.
Continuation requires the unchanged complete set and a verified snapshot for
every source. Missing or ambiguous source identity remains held; a partial
archive cannot certify recovery. The recovery journey covers two allocations,
removal of the old filesystem, and the files appearing in the next remote turn.
Accepted results with unfinished copyback remain blockers. Once the original
sync reference is finalized and its successful `workspace_finalize` operation
receipt exists, assessment and status effects can continue from their
existing database coordinator independently of the file storage migration.
Offline maintenance stops writers; it does not silently change workspace
semantics or retire task ownership. `parallel_shared_writers_unsupported` needs
an operator-approved transition to serialized remote workspace writes.
`task_writer_bound` needs the task lifecycle owner to settle its exact checkout
and execution binding, even if the run row is terminal. `task_binding_required`
needs provenance recovery or a separately approved durable archival path for an
orphaned workspace; the importer never invents a task owner. These records stay
on the original volume and block detach. The batch reports its first failed
workspace through the phase/error receipt; it does not claim to aggregate every
possible blocker in advance or skip blocked rows.
Controller leases, in-flight recovery, active writers, and uncertain workspace
operations remain blockers. Tests must prove migration leaves failure state
intact, rejects stale copyback even if the old folder exists, and preserves
recovery source validation and company boundaries.
+21
View File
@@ -239,6 +239,27 @@ chunks whose identities cannot be verified block publication as
does not establish completeness. Publication checks that the original reference,
completeness metadata, and source cursor have not changed. Keep the original
volume and resolve blocked inventory before cutover.
The offline ephemeral-enrollment process can also preserve unfinalized native
local files as read-only `s3_archive` logs. This path requires
`PAPERCLIP_EPHEMERAL_MIGRATION=true`, all old deployments stopped, no queued or
running execution anywhere in the instance, and expired writer/finalizer leases.
The global execution check runs at page boundaries; per-run ownership and
metadata checks still fence every publication. New execution fails the page and
enrollment, while verified copies remain available for a safe retry.
It copies exact surviving bytes to a separate `<run>.archive` journal, checks
that the local source did not change, and verifies the remote SHA-256 before
switching the run's reference. It supports terminal `interrupted` runs as well.
It does not treat an unproven mirror as an original file or ignore a recorded
size/digest mismatch.
An archive preserves historical evidence; it does not certify a complete native
transcript. The run's status, canonical events, and original completion metadata
remain unchanged. Archive readers do not append missing native events, and
writers cannot reopen or modify the archive. Its source cursor remains zero.
The normal background importer retains its strict native coverage checks.
Rollbacks after enrollment require readers that support `s3_archive`.
An interrupted trace import keeps an `s3-import-v1:` reference: reads still use
the original local sidecars, while deletion and expiry retain responsibility for
the staged object prefix. The automatic worker resumes the verified switch after restart or retry.
+22 -2
View File
@@ -100,8 +100,18 @@ adapters extract these fields; arbitrary exception objects, request bodies,
headers, environment variables, and stacks are excluded. Unknown categories retain
their explanation. The cause stays in company-scoped run records and the run log;
it is not added to first-party Telemetry, Observability attributes, or Sentry
failure reports. The reportable run error keeps its existing generic text. Startup
receipts retain it across controller restart before any provider turn exists.
failure reports. The reportable run error keeps its existing generic text, except
that an ACPX startup rejection keeps Runner's closed rejection text and its
classification. Startup receipts retain the cause across controller restart before
any provider turn exists.
Every ACPX session ensure rejection carries a startup cause. When the provider
returned no ACP error, the cause keeps the rejection's own redacted message with
category `unknown`. A provider can type the failure with ACP's
authentication-required code, or with a known error type or `errorKind` in the ACP
error data. The cause then uses that category, and the rejection classification is
`provider_<category>`, such as `provider_quota_exhausted`, instead of
`session_ensure_failed`.
Completed OpenCode builtin `bash` parts preserve the numeric process exit from
`state.metadata.exit` in `tool.execution.completed.payload.exitCode`. The tool
@@ -480,6 +490,16 @@ byte budgets, including truncation markers. `retrievalTruncated` directs callers
to the full adapter-bounded text in the run error or transcript. Other provider
metadata and action payloads are not copied.
When an ACP request fails with a JSON-RPC error, the engine also keeps the
provider explanation. It reads a string `data`, or the string `data.details`, or
the string `data.message`, in that order. It adds the explanation on a new line
after the error message unless the message already contains it. The combined
text passes through the same redaction and the 4,096-character title bound
before it becomes the run error, the summary, and the `acpx.error` `message`.
A numeric JSON-RPC error code appears as the optional integer `rpcCode` in
`errorMeta` and in the `acpx.error` record. Errors without these fields keep
their existing message and omit `rpcCode`.
Recovery still uses the typed failure category and the adapter's existing
classifier. Provider warnings do not become failures, and timeouts or lost
control channels keep their authoritative failure messages. These diagnostics
+7 -2
View File
@@ -49,8 +49,13 @@ They do not qualify distinct application releases or complete webserver disk los
`native-controller-recovery.integration.test.ts` separately verifies encrypted
database discovery, one-source import, idempotency, live-owner waiting, corrupt
bindings and paginated inventory. `native-session-resume-projection.test.ts`
retains the 32-candidate and newer-progress barrier checks. Snapshot transaction
bindings and paginated inventory. `native-recovered-owner-followup.test.ts`
admits a per-turn follow-up after recovery committed an owner that never saved
a suspension receipt, and keeps unsettled or ambiguous owners held. It also
retries from that owner after an earlier follow-up failed before provider work,
and keeps any follow-up with provider or controller evidence as the source.
`native-session-resume-projection.test.ts` retains the 32-candidate and
newer-progress barrier checks. Snapshot transaction
counts and stale-generation rejection are exercised by the control-plane port
tests; incomplete-publication retention is exercised by the expiration tests.
+1 -2
View File
@@ -199,8 +199,7 @@ Agent access: `available` — REST / native call_api.
Policy: Same company plus visibility/audit/cost/run permissions where the route requires
them.
Evidence: `source-only` — [implementation](../../server/src/routes/activity.ts#L224) at
the reviewed commit above.
Evidence: `source-only` — [implementation](../../server/src/routes/companies.ts).
Verification: `not-run` — exercise a listed permitted operation with actual agent
credentials/run context; inspect its result and applicable persistence/attribution.
+10
View File
@@ -219,6 +219,16 @@ environment, and consent checks as other edits. It adds no agent permission or
credential fallback. Pool and externally managed agents use their existing
configuration surfaces. Board browser verification does not qualify agent access.
Ordinary configuration PATCH requests with healthy installed AI accounts validate
current access, compatibility and environment selection without allocating a
computer or sending a provider prompt. Legacy-account adoption, task connection
adoption and Copilot model verification retain their live checks. Explicit Test
and execution keep runtime validation. This changes save latency and validation
timing; it grants no account, environment or execution authority. The isolated
PostgreSQL route fixtures in
[agent-configuration-save.test.ts](../../server/src/__tests__/agent-configuration-save.test.ts)
cover board saves and revoked-account rejection, not native-agent access.
The existing-agent upgrade notice for Claude, Codex, and Grok Build uses this configuration PATCH. It does not
grant configuration, account, or environment access. The server resolves the
saved settings under normal company/configuration authorization. Grok migration preserves
+10 -2
View File
@@ -173,9 +173,17 @@ API: `present` — representative operation IDs:
query parameters and tenant credentials cannot grant it. Its version-3 proof
permits only saved API keys and exactly bound brokered GitHub bot configuration.
Agent and board API requests authenticate under current tenant authority after
bounded host wake. Personal GitHub continuity remains blocked until the stacked
deadline proof is present. See [protocol scope](../../doc/idle-incoming-wake.md)
bounded host wake. Personal GitHub also requires the signed domain deadline
receipt and its actual stored-credential continuity proof. See [protocol scope](../../doc/idle-incoming-wake.md)
and [local auth/PostgreSQL evidence](../../server/src/__tests__/idle-incoming-wake.test.ts).
- `GET /api/instance/task-drain?idleSleepSafety=1&scheduledWake=domain-deadlines-v1`
additionally requires the signed fixed deadline catalog. The version-3
`deadlineProof` receipt is mandatory even without a future alarm. It binds
actual timer, plugin, trace expiry and credential continuity state to the
owned hold. Unknown/due/recovery work and pending watchdog/merge polling
remain blocked. It grants no business execution authority. See
[domain protocol](../../doc/idle-domain-deadlines.md); local source and
fixture checks do not establish deployed sleep or external wake.
- `GET /api/ready` and `HEAD /api/ready` — public deployment readiness only;
200 after recovery, 503 while starting, recovering, or draining. No SQL,
session resolution, company identity, or control authority.
+3
View File
@@ -81,6 +81,9 @@ idle hold. It requires signed Cloud control authority; company agents and tenant
admin tokens cannot use it. It does not grant routine management or execution
authority. See the [hosting protocol](../../doc/idle-sleep-safety.md#scheduled-routine-wake-protocol)
and [route authorization tests](../../server/src/__tests__/instance-settings-routes.test.ts).
The signed `scheduledWake=domain-deadlines-v1` catalog can combine this routine
obligation with other supported domain deadlines. Its generation receipt is
mandatory even when no future alarm remains. See [domain scope](../../doc/idle-domain-deadlines.md).
These are local contract checks, not a live provider sleep/wake qualification.
## `run-now`
+6
View File
@@ -83,6 +83,12 @@ resource allocation waits for approval.
### `agent-configuration`
On Harness / Runtime, select an installed healthy AI account or change its model
and save. The save checks current access and compatibility without starting a
computer or provider hello test. **Test** still verifies the runtime. Unverified
legacy accounts, task connection adoption and Copilot model checks retain live
validation. Verify saved configuration separately from runtime readiness.
Open `/agents/:agentId` and the configuration/instruction surfaces it exposes.
The agent nav has two sections: **Agent** (Overview, Identity at
`/agents/:agentId/identity`, Harness / Runtime, Secrets & variables,
+3 -1
View File
@@ -299,7 +299,9 @@ tests this host; it is component coverage, not an end-to-end repair.
Manual: prepare a failed run with error code `workspace_validation_failed`, a
linked source task, and a live `workspace_validation` recovery action on that
task. Open that exact run and compare its recovery diagnosis to the source task.
task. For retained-file export recovery, use a matching active source action and
terminal run; compare Recover saved files on the task and run inspector.
Open that exact run and compare its recovery diagnosis to the source task.
Use an eligible action from the run surface, return to the task,
and verify the same action state, persisted outcome, and restored execution.
Repeat with an actor who lacks authority. Verify that the run-page controls are
@@ -2537,6 +2537,91 @@ describe("shared ACPX engine runtime behavior", () => {
).toBe("second");
});
it.each(["create_runtime", "ensure_session", "configure_session", "turn"])(
"preserves sanitized JSON-RPC diagnostics when %s fails",
async (phase) => {
for (const shape of ["string", "details", "message"]) {
const root = await makeTempRoot();
const detail = "Invalid session configuration: model_context_window must be a number; private-setting-value";
const data = shape === "string" ? detail : { [shape]: detail };
const err = Object.assign(new Error("Internal error"), { code: -32603, data });
const fail = () => { throw err; };
const logs: string[] = [];
const execute = createAcpxEngineExecutor({
createRuntime: () => {
if (phase === "create_runtime") fail();
return {
...buildRuntime(),
...(phase === "ensure_session" ? { ensureSession: fail } : {}),
...(phase === "configure_session" ? { setConfigOption: fail } : {}),
...(phase === "turn" ? { startTurn: () => ({
events: (async function* () { fail(); })(),
result: Promise.resolve({ status: "completed", stopReason: "end_turn" }),
cancel: async () => {},
}) } : {}),
} as never;
},
});
const result = await execute({
runId: "run-1",
agent: { id: "agent-1", companyId: "company-1" },
runtime: {},
config: {
agent: "custom",
agentCommand: "node ./fake-acp.js",
stateDir: path.join(root, "state"),
thinkingEffort: "high",
env: { PRIVATE_SETTING: "private-setting-value" },
},
context: {},
onLog: async (_stream: string, text: string) => { logs.push(text); },
onMeta: async () => {},
} as never);
expect(result.exitCode).toBe(1);
expect(result.errorMessage).toContain("Internal error\nInvalid session configuration: model_context_window must be a number;");
expect(result.errorMessage).not.toContain("private-setting-value");
expect(result.summary).toBe(result.errorMessage);
expect(result.errorMeta).toMatchObject({ phase, rpcCode: -32603 });
const payload = JSON.parse(logs.find((text) => text.includes('"type":"acpx.error"'))!.trim());
expect(payload.message).toBe(result.errorMessage);
expect(payload.rpcCode).toBe(-32603);
}
},
);
it("does not repeat JSON-RPC details already in the message and leaves plain errors unchanged", async () => {
const run = async (err: Error) => {
const root = await makeTempRoot();
const execute = createAcpxEngineExecutor({
createRuntime: () => ({
...buildRuntime(),
ensureSession: async () => { throw err; },
}) as never,
});
return execute({
runId: "run-1",
agent: { id: "agent-1", companyId: "company-1" },
runtime: {},
config: { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir: path.join(root, "state") },
context: {},
onLog: async () => {},
onMeta: async () => {},
} as never);
};
const repeated = await run(Object.assign(
new Error("Internal error: Invalid session configuration"),
{ code: -32603, data: { details: "Invalid session configuration" } },
));
expect(repeated.errorMessage).toBe("Internal error: Invalid session configuration");
expect(repeated.errorMeta).toMatchObject({ rpcCode: -32603 });
const plain = await run(new Error("session/new failed"));
expect(plain.errorMessage).toBe("session/new failed");
expect(plain.summary).toBe("session/new failed");
expect(plain.errorMeta?.rpcCode).toBeUndefined();
});
it("enriches acpx.error diagnostics and child stderr when ensureSession rejects", async () => {
const root = await makeTempRoot();
const stateDir = path.join(root, "state");
@@ -3623,9 +3623,22 @@ async function emitAcpxFailure(input: {
childStderrTail: string | null;
}> {
const { ctx, prepared, err, phase, messageOverride, suppressChildStderrTail } = input;
const requestError = parseObject(err);
const data = parseObject(requestError.data);
const detail = typeof requestError.data === "string"
? requestError.data
: asString(data.details, asString(data.message, ""));
const rawMessage = err instanceof Error ? err.message : String(err);
const message = messageOverride ?? rawMessage;
const message = messageOverride ?? (detail
? sanitizeTerminalSessionFailure({
category: "unknown",
title: rawMessage.includes(detail) ? rawMessage : `${rawMessage}\n${detail}`,
}, prepared.env, ctx.authToken, parseObject(ctx.config.env)).title ?? "ACP request failed."
: rawMessage);
const classified = classifyError(err, phase);
if (typeof requestError.code === "number" && Number.isSafeInteger(requestError.code)) {
classified.errorMeta = { ...classified.errorMeta, rpcCode: requestError.code };
}
const childStderrTail = suppressChildStderrTail
? null
: await readChildStderrTail({ logPath: prepared.childStderrLogPath });
+1 -1
View File
@@ -55,7 +55,7 @@
},
"devDependencies": {
"@types/node": "^24.0.0",
"drizzle-kit": "^0.31.10",
"drizzle-kit": "^0.31.11",
"tsx": "^4.23.15",
"typescript": "^7.0.2",
"vitest": "^5.0.3"
@@ -5,9 +5,11 @@ import { describe, expect, it } from "vitest";
import { applyPendingMigrations, inspectMigrations } from "./client.js";
import { EMBEDDED_POSTGRES_TEST_TIMEOUT_MS, getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./test-embedded-postgres.js";
const hashMigration = async (name: string) => createHash("sha256")
.update(await readFile(new URL(`./migrations/${name}`, import.meta.url), "utf8")).digest("hex");
const migrationName = "0426_absent_guardian.sql";
const migrationHash = createHash("sha256")
.update(await readFile(new URL(`./migrations/${migrationName}`, import.meta.url), "utf8")).digest("hex");
const migrationHash = await hashMigration(migrationName);
const retainedSetMigration = "0428_retained_admitted_project_sets.sql";
const support = await getEmbeddedPostgresTestSupport();
const describePostgres = support.supported ? describe : describe.skip;
@@ -17,7 +19,8 @@ describePostgres("execution admission migration", () => {
const sql = postgres(database.connectionString, { max: 1, onnotice: () => {} });
try {
await sql`DROP TABLE execution_admission_project_charges, execution_admissions, project_task_execution_policies`;
await sql`DELETE FROM drizzle.__drizzle_migrations WHERE hash = ${migrationHash}`;
await sql`DROP FUNCTION paperclip_execution_admission_project_set_immutable`;
await sql`DELETE FROM drizzle.__drizzle_migrations WHERE hash IN (${migrationHash}, ${await hashMigration(retainedSetMigration)})`;
const companyId = randomUUID(), agentId = randomUUID(), projectId = randomUUID();
await sql`INSERT INTO companies (id, name, issue_prefix) VALUES (${companyId}, 'Upgrade', 'UPG')`;
await sql`INSERT INTO agents (id, company_id, name, adapter_type) VALUES (${agentId}, ${companyId}, 'Agent', 'process')`;
@@ -31,7 +34,7 @@ describePostgres("execution admission migration", () => {
});
const before = await contents();
expect(await inspectMigrations(database.connectionString))
.toMatchObject({ status: "needsMigrations", pendingMigrations: [migrationName] });
.toMatchObject({ status: "needsMigrations", pendingMigrations: [migrationName, retainedSetMigration] });
await applyPendingMigrations(database.connectionString);
@@ -50,4 +53,49 @@ describePostgres("execution admission migration", () => {
await database.cleanup();
}
}, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS);
it("keeps the admitted sets of shipped 0426 rows that still have charges and marks settled ones unknown", async () => {
const database = await startEmbeddedPostgresTestDatabase("paperclip-execution-admission-sets-");
const sql = postgres(database.connectionString, { max: 1, onnotice: () => {} });
try {
await sql`DROP TRIGGER paperclip_execution_admission_project_set_immutable ON execution_admissions`;
await sql`DROP FUNCTION paperclip_execution_admission_project_set_immutable`;
await sql`ALTER TABLE execution_admissions DROP CONSTRAINT execution_admissions_project_set_check, DROP COLUMN admitted_project_ids`;
await sql`DELETE FROM drizzle.__drizzle_migrations WHERE hash = ${await hashMigration(retainedSetMigration)}`;
const companyId = randomUUID(), agentId = randomUUID();
const [a, b] = [randomUUID(), randomUUID()].sort();
await sql`INSERT INTO companies (id, name, issue_prefix) VALUES (${companyId}, 'Upgrade', 'UPS')`;
await sql`INSERT INTO agents (id, company_id, name, adapter_type) VALUES (${agentId}, ${companyId}, 'Agent', 'process')`;
await sql`INSERT INTO projects (id, company_id, name) VALUES (${b}, ${companyId}, 'B'), (${a}, ${companyId}, 'A')`;
const [live, empty, settled] = [randomUUID(), randomUUID(), randomUUID()];
for (const id of [live, empty, settled]) {
const [run] = await sql`INSERT INTO heartbeat_runs (company_id, agent_id) VALUES (${companyId}, ${agentId}) RETURNING id`;
await sql`INSERT INTO execution_admissions (id, company_id, run_id, attempt, primary_project_id, context_revision,
settlement_id, settled_at) VALUES (${id}, ${companyId}, ${run.id}, 1, ${a}, 'issue:1',
${id === settled ? randomUUID() : null}, ${id === settled ? sql`now()` : null})`;
}
await sql`INSERT INTO execution_admission_project_charges (admission_id, company_id, project_id)
VALUES (${live}, ${companyId}, ${b}), (${live}, ${companyId}, ${a})`;
const sets = () => sql`SELECT id, admitted_project_ids FROM execution_admissions ORDER BY id`;
await applyPendingMigrations(database.connectionString);
const upgraded = await sets();
expect(upgraded).toEqual(expect.arrayContaining([
{ id: live, admitted_project_ids: [a, b] },
{ id: empty, admitted_project_ids: [] },
{ id: settled, admitted_project_ids: null },
]));
await sql`DELETE FROM drizzle.__drizzle_migrations WHERE hash = ${await hashMigration(retainedSetMigration)}`;
await applyPendingMigrations(database.connectionString);
expect(await sets()).toEqual(upgraded);
expect(await sql`SELECT count(*)::int AS count FROM execution_admission_project_charges`).toEqual([{ count: 2 }]);
await expect(sql`UPDATE execution_admissions SET admitted_project_ids = '{}' WHERE id = ${settled}`)
.rejects.toMatchObject({ message: "execution admission project set is immutable" });
expect(await inspectMigrations(database.connectionString)).toMatchObject({ status: "upToDate" });
} finally {
await sql.end();
await database.cleanup();
}
}, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS);
});
@@ -0,0 +1,22 @@
ALTER TABLE "execution_admissions" ADD COLUMN IF NOT EXISTS "admitted_project_ids" uuid[];--> statement-breakpoint
-- Unsettled charges are still the complete admitted set. Settled rows already lost theirs and stay unknown (null).
UPDATE "execution_admissions" AS "admission" SET "admitted_project_ids" = coalesce((
SELECT array_agg("charge"."project_id" ORDER BY "charge"."project_id") FROM "execution_admission_project_charges" AS "charge"
WHERE "charge"."company_id" = "admission"."company_id" AND "charge"."admission_id" = "admission"."id"
), '{}'::uuid[]) WHERE "admission"."settled_at" IS NULL AND "admission"."admitted_project_ids" IS NULL;--> statement-breakpoint
ALTER TABLE "execution_admissions" DROP CONSTRAINT IF EXISTS "execution_admissions_project_set_check";--> statement-breakpoint
ALTER TABLE "execution_admissions" ADD CONSTRAINT "execution_admissions_project_set_check" CHECK (case when "execution_admissions"."admitted_project_ids" is null
then "execution_admissions"."settled_at" is not null else array_position("execution_admissions"."admitted_project_ids", null) is null and cardinality("execution_admissions"."admitted_project_ids") <= 100 end);--> statement-breakpoint
CREATE OR REPLACE FUNCTION paperclip_execution_admission_project_set_immutable() RETURNS trigger
LANGUAGE plpgsql SET search_path = public, pg_temp AS $$
BEGIN
IF NEW.admitted_project_ids IS DISTINCT FROM OLD.admitted_project_ids THEN
RAISE EXCEPTION 'execution admission project set is immutable' USING ERRCODE = 'check_violation';
END IF;
RETURN NEW;
END;
$$;
--> statement-breakpoint
DROP TRIGGER IF EXISTS paperclip_execution_admission_project_set_immutable ON "execution_admissions";--> statement-breakpoint
CREATE TRIGGER paperclip_execution_admission_project_set_immutable BEFORE UPDATE OF admitted_project_ids ON "execution_admissions"
FOR EACH ROW EXECUTE FUNCTION paperclip_execution_admission_project_set_immutable();
+11 -199
View File
@@ -1,5 +1,5 @@
{
"id": "60723299-0cfe-4176-8d70-cd2ad312553b",
"id": "fb4383f6-cbe6-4aff-9fd2-6eeb69a6927a",
"prevId": "dd860bff-55de-4849-8787-58b9de729258",
"version": "7",
"dialect": "postgresql",
@@ -28957,6 +28957,12 @@
"primaryKey": false,
"notNull": true
},
"admitted_project_ids": {
"name": "admitted_project_ids",
"type": "uuid[]",
"primaryKey": false,
"notNull": false
},
"settlement_id": {
"name": "settlement_id",
"type": "uuid",
@@ -29076,6 +29082,10 @@
"execution_admissions_settlement_check": {
"name": "execution_admissions_settlement_check",
"value": "(\"execution_admissions\".\"settlement_id\" is null) = (\"execution_admissions\".\"settled_at\" is null)"
},
"execution_admissions_project_set_check": {
"name": "execution_admissions_project_set_check",
"value": "case when \"execution_admissions\".\"admitted_project_ids\" is null\n then \"execution_admissions\".\"settled_at\" is not null else array_position(\"execution_admissions\".\"admitted_project_ids\", null) is null and cardinality(\"execution_admissions\".\"admitted_project_ids\") <= 100 end"
}
},
"isRLSEnabled": false
@@ -33795,168 +33805,6 @@
"checkConstraints": {},
"isRLSEnabled": false
},
"public.heartbeat_run_event_cursors": {
"name": "heartbeat_run_event_cursors",
"schema": "",
"columns": {
"company_id": {
"name": "company_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"run_id": {
"name": "run_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"source_instance_id": {
"name": "source_instance_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"contiguous_source_seq": {
"name": "contiguous_source_seq",
"type": "bigint",
"primaryKey": false,
"notNull": true,
"default": 0
},
"pending_range_count": {
"name": "pending_range_count",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
}
},
"indexes": {},
"foreignKeys": {
"heartbeat_run_event_cursors_company_id_companies_id_fk": {
"name": "heartbeat_run_event_cursors_company_id_companies_id_fk",
"tableFrom": "heartbeat_run_event_cursors",
"tableTo": "companies",
"columnsFrom": [
"company_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"heartbeat_run_event_cursors_run_id_heartbeat_runs_id_fk": {
"name": "heartbeat_run_event_cursors_run_id_heartbeat_runs_id_fk",
"tableFrom": "heartbeat_run_event_cursors",
"tableTo": "heartbeat_runs",
"columnsFrom": [
"run_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"heartbeat_run_event_cursors_company_id_run_id_source_instance_id_pk": {
"name": "heartbeat_run_event_cursors_company_id_run_id_source_instance_id_pk",
"columns": [
"company_id",
"run_id",
"source_instance_id"
]
}
},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {
"heartbeat_run_event_cursors_nonnegative": {
"name": "heartbeat_run_event_cursors_nonnegative",
"value": "\"heartbeat_run_event_cursors\".\"contiguous_source_seq\" >= 0 and \"heartbeat_run_event_cursors\".\"pending_range_count\" >= 0"
}
},
"isRLSEnabled": false
},
"public.heartbeat_run_event_source_ranges": {
"name": "heartbeat_run_event_source_ranges",
"schema": "",
"columns": {
"company_id": {
"name": "company_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"run_id": {
"name": "run_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"source_instance_id": {
"name": "source_instance_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"start_seq": {
"name": "start_seq",
"type": "bigint",
"primaryKey": false,
"notNull": true
},
"end_seq": {
"name": "end_seq",
"type": "bigint",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"heartbeat_run_event_source_ranges_cursor_fk": {
"name": "heartbeat_run_event_source_ranges_cursor_fk",
"tableFrom": "heartbeat_run_event_source_ranges",
"tableTo": "heartbeat_run_event_cursors",
"columnsFrom": [
"company_id",
"run_id",
"source_instance_id"
],
"columnsTo": [
"company_id",
"run_id",
"source_instance_id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"heartbeat_run_event_source_ranges_company_id_run_id_source_instance_id_start_seq_pk": {
"name": "heartbeat_run_event_source_ranges_company_id_run_id_source_instance_id_start_seq_pk",
"columns": [
"company_id",
"run_id",
"source_instance_id",
"start_seq"
]
}
},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {
"heartbeat_run_event_source_ranges_positive": {
"name": "heartbeat_run_event_source_ranges_positive",
"value": "\"heartbeat_run_event_source_ranges\".\"start_seq\" >= 1 and \"heartbeat_run_event_source_ranges\".\"end_seq\" >= \"heartbeat_run_event_source_ranges\".\"start_seq\""
}
},
"isRLSEnabled": false
},
"public.heartbeat_run_events": {
"name": "heartbeat_run_events",
"schema": "",
@@ -34675,20 +34523,6 @@
"notNull": true,
"default": 1
},
"event_cursor_projection_seq": {
"name": "event_cursor_projection_seq",
"type": "bigint",
"primaryKey": false,
"notNull": true,
"default": 0
},
"event_cursor_rebuild_pending": {
"name": "event_cursor_rebuild_pending",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"native_phase": {
"name": "native_phase",
"type": "text",
@@ -35119,28 +34953,6 @@
"method": "btree",
"with": {}
},
"heartbeat_runs_event_cursor_rebuild_idx": {
"name": "heartbeat_runs_event_cursor_rebuild_idx",
"columns": [
{
"expression": "company_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"where": "\"heartbeat_runs\".\"event_cursor_rebuild_pending\" = true",
"concurrently": false,
"method": "btree",
"with": {}
},
"heartbeat_runs_company_agent_started_idx": {
"name": "heartbeat_runs_company_agent_started_idx",
"columns": [
@@ -1,6 +1,6 @@
{
"id": "cb093c1d-3e54-4ab2-8009-9c243097224c",
"prevId": "fe0b877c-d63e-46d9-8798-c7729fd13eb7",
"id": "458c1df9-c30f-4617-ac0c-751bf30cb594",
"prevId": "fb4383f6-cbe6-4aff-9fd2-6eeb69a6927a",
"version": "7",
"dialect": "postgresql",
"tables": {
@@ -28825,6 +28825,337 @@
"checkConstraints": {},
"isRLSEnabled": false
},
"public.execution_admission_project_charges": {
"name": "execution_admission_project_charges",
"schema": "",
"columns": {
"admission_id": {
"name": "admission_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"company_id": {
"name": "company_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"project_id": {
"name": "project_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
}
},
"indexes": {
"execution_admission_project_charges_project_idx": {
"name": "execution_admission_project_charges_project_idx",
"columns": [
{
"expression": "company_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "project_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"execution_admission_project_charges_company_admission_fk": {
"name": "execution_admission_project_charges_company_admission_fk",
"tableFrom": "execution_admission_project_charges",
"tableTo": "execution_admissions",
"columnsFrom": [
"company_id",
"admission_id"
],
"columnsTo": [
"company_id",
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"execution_admission_project_charges_company_project_fk": {
"name": "execution_admission_project_charges_company_project_fk",
"tableFrom": "execution_admission_project_charges",
"tableTo": "projects",
"columnsFrom": [
"company_id",
"project_id"
],
"columnsTo": [
"company_id",
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"execution_admission_project_charges_admission_id_project_id_pk": {
"name": "execution_admission_project_charges_admission_id_project_id_pk",
"columns": [
"admission_id",
"project_id"
]
}
},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.execution_admissions": {
"name": "execution_admissions",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true
},
"company_id": {
"name": "company_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"run_id": {
"name": "run_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"attempt": {
"name": "attempt",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"primary_project_id": {
"name": "primary_project_id",
"type": "uuid",
"primaryKey": false,
"notNull": false
},
"context_revision": {
"name": "context_revision",
"type": "text",
"primaryKey": false,
"notNull": true
},
"admitted_project_ids": {
"name": "admitted_project_ids",
"type": "uuid[]",
"primaryKey": false,
"notNull": false
},
"settlement_id": {
"name": "settlement_id",
"type": "uuid",
"primaryKey": false,
"notNull": false
},
"settled_at": {
"name": "settled_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"execution_admissions_company_id_uq": {
"name": "execution_admissions_company_id_uq",
"columns": [
{
"expression": "company_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
},
"execution_admissions_run_attempt_uq": {
"name": "execution_admissions_run_attempt_uq",
"columns": [
{
"expression": "run_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "attempt",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
},
"execution_admissions_live_run_uq": {
"name": "execution_admissions_live_run_uq",
"columns": [
{
"expression": "run_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"where": "\"execution_admissions\".\"settled_at\" is null",
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"execution_admissions_company_id_companies_id_fk": {
"name": "execution_admissions_company_id_companies_id_fk",
"tableFrom": "execution_admissions",
"tableTo": "companies",
"columnsFrom": [
"company_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"execution_admissions_run_id_heartbeat_runs_id_fk": {
"name": "execution_admissions_run_id_heartbeat_runs_id_fk",
"tableFrom": "execution_admissions",
"tableTo": "heartbeat_runs",
"columnsFrom": [
"run_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {
"execution_admissions_attempt_check": {
"name": "execution_admissions_attempt_check",
"value": "\"execution_admissions\".\"attempt\" > 0"
},
"execution_admissions_settlement_check": {
"name": "execution_admissions_settlement_check",
"value": "(\"execution_admissions\".\"settlement_id\" is null) = (\"execution_admissions\".\"settled_at\" is null)"
},
"execution_admissions_project_set_check": {
"name": "execution_admissions_project_set_check",
"value": "case when \"execution_admissions\".\"admitted_project_ids\" is null\n then \"execution_admissions\".\"settled_at\" is not null else array_position(\"execution_admissions\".\"admitted_project_ids\", null) is null and cardinality(\"execution_admissions\".\"admitted_project_ids\") <= 100 end"
}
},
"isRLSEnabled": false
},
"public.project_task_execution_policies": {
"name": "project_task_execution_policies",
"schema": "",
"columns": {
"project_id": {
"name": "project_id",
"type": "uuid",
"primaryKey": true,
"notNull": true
},
"company_id": {
"name": "company_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"max_concurrent_tasks": {
"name": "max_concurrent_tasks",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"project_task_execution_policies_company_project_fk": {
"name": "project_task_execution_policies_company_project_fk",
"tableFrom": "project_task_execution_policies",
"tableTo": "projects",
"columnsFrom": [
"company_id",
"project_id"
],
"columnsTo": [
"company_id",
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {
"project_task_execution_policies_maximum_check": {
"name": "project_task_execution_policies_maximum_check",
"value": "\"project_task_execution_policies\".\"max_concurrent_tasks\" is null or \"project_task_execution_policies\".\"max_concurrent_tasks\" > 0"
}
},
"isRLSEnabled": false
},
"public.execution_workspace_repositories": {
"name": "execution_workspace_repositories",
"schema": "",
@@ -29999,6 +30330,22 @@
"concurrently": false,
"method": "btree",
"with": {}
},
"execution_workspace_snapshots_imported_children_idx": {
"name": "execution_workspace_snapshots_imported_children_idx",
"columns": [
{
"expression": "(\"metadata\"->'importedWorkspaceChildren') jsonb_path_ops",
"asc": true,
"isExpression": true,
"nulls": "last"
}
],
"isUnique": false,
"where": "\"execution_workspace_snapshots\".\"metadata\" ? 'importedWorkspaceChildren'",
"concurrently": false,
"method": "gin",
"with": {}
}
},
"foreignKeys": {},
@@ -33458,6 +33805,168 @@
"checkConstraints": {},
"isRLSEnabled": false
},
"public.heartbeat_run_event_cursors": {
"name": "heartbeat_run_event_cursors",
"schema": "",
"columns": {
"company_id": {
"name": "company_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"run_id": {
"name": "run_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"source_instance_id": {
"name": "source_instance_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"contiguous_source_seq": {
"name": "contiguous_source_seq",
"type": "bigint",
"primaryKey": false,
"notNull": true,
"default": 0
},
"pending_range_count": {
"name": "pending_range_count",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
}
},
"indexes": {},
"foreignKeys": {
"heartbeat_run_event_cursors_company_id_companies_id_fk": {
"name": "heartbeat_run_event_cursors_company_id_companies_id_fk",
"tableFrom": "heartbeat_run_event_cursors",
"tableTo": "companies",
"columnsFrom": [
"company_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"heartbeat_run_event_cursors_run_id_heartbeat_runs_id_fk": {
"name": "heartbeat_run_event_cursors_run_id_heartbeat_runs_id_fk",
"tableFrom": "heartbeat_run_event_cursors",
"tableTo": "heartbeat_runs",
"columnsFrom": [
"run_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"heartbeat_run_event_cursors_company_id_run_id_source_instance_id_pk": {
"name": "heartbeat_run_event_cursors_company_id_run_id_source_instance_id_pk",
"columns": [
"company_id",
"run_id",
"source_instance_id"
]
}
},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {
"heartbeat_run_event_cursors_nonnegative": {
"name": "heartbeat_run_event_cursors_nonnegative",
"value": "\"heartbeat_run_event_cursors\".\"contiguous_source_seq\" >= 0 and \"heartbeat_run_event_cursors\".\"pending_range_count\" >= 0"
}
},
"isRLSEnabled": false
},
"public.heartbeat_run_event_source_ranges": {
"name": "heartbeat_run_event_source_ranges",
"schema": "",
"columns": {
"company_id": {
"name": "company_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"run_id": {
"name": "run_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"source_instance_id": {
"name": "source_instance_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"start_seq": {
"name": "start_seq",
"type": "bigint",
"primaryKey": false,
"notNull": true
},
"end_seq": {
"name": "end_seq",
"type": "bigint",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"heartbeat_run_event_source_ranges_cursor_fk": {
"name": "heartbeat_run_event_source_ranges_cursor_fk",
"tableFrom": "heartbeat_run_event_source_ranges",
"tableTo": "heartbeat_run_event_cursors",
"columnsFrom": [
"company_id",
"run_id",
"source_instance_id"
],
"columnsTo": [
"company_id",
"run_id",
"source_instance_id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"heartbeat_run_event_source_ranges_company_id_run_id_source_instance_id_start_seq_pk": {
"name": "heartbeat_run_event_source_ranges_company_id_run_id_source_instance_id_start_seq_pk",
"columns": [
"company_id",
"run_id",
"source_instance_id",
"start_seq"
]
}
},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {
"heartbeat_run_event_source_ranges_positive": {
"name": "heartbeat_run_event_source_ranges_positive",
"value": "\"heartbeat_run_event_source_ranges\".\"start_seq\" >= 1 and \"heartbeat_run_event_source_ranges\".\"end_seq\" >= \"heartbeat_run_event_source_ranges\".\"start_seq\""
}
},
"isRLSEnabled": false
},
"public.heartbeat_run_events": {
"name": "heartbeat_run_events",
"schema": "",
@@ -34176,6 +34685,20 @@
"notNull": true,
"default": 1
},
"event_cursor_projection_seq": {
"name": "event_cursor_projection_seq",
"type": "bigint",
"primaryKey": false,
"notNull": true,
"default": 0
},
"event_cursor_rebuild_pending": {
"name": "event_cursor_rebuild_pending",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"native_phase": {
"name": "native_phase",
"type": "text",
@@ -34606,6 +35129,28 @@
"method": "btree",
"with": {}
},
"heartbeat_runs_event_cursor_rebuild_idx": {
"name": "heartbeat_runs_event_cursor_rebuild_idx",
"columns": [
{
"expression": "company_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"where": "\"heartbeat_runs\".\"event_cursor_rebuild_pending\" = true",
"concurrently": false,
"method": "btree",
"with": {}
},
"heartbeat_runs_company_agent_started_idx": {
"name": "heartbeat_runs_company_agent_started_idx",
"columns": [
@@ -62448,6 +62993,12 @@
"primaryKey": false,
"notNull": false
},
"cleanup_verified_at": {
"name": "cleanup_verified_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
+9 -2
View File
@@ -2987,8 +2987,15 @@
{
"idx": 428,
"version": "7",
"when": 1793740838275,
"tag": "0428_neat_vivisector",
"when": 1793743828770,
"tag": "0428_retained_admitted_project_sets",
"breakpoints": true
},
{
"idx": 429,
"version": "7",
"when": 1793749837646,
"tag": "0429_deep_prima",
"breakpoints": true
}
]
@@ -24,7 +24,10 @@ export const projectTaskExecutionPolicies = pgTable("project_task_execution_poli
/**
* One admission attempt of a run. A run can be admitted again after a settled
* attempt, so the run ID is not the admission identity. Primary project is
* provenance only; the charges below are the admitted project set.
* provenance only. `admittedProjectIds` is the complete admitted set, sorted and
* immutable (trigger), kept after settlement without project foreign keys. Null
* means unknown: settled before the set was recorded. Charges below are the
* active capacity only.
*/
export const executionAdmissions = pgTable("execution_admissions", {
id: uuid("id").primaryKey(),
@@ -33,6 +36,7 @@ export const executionAdmissions = pgTable("execution_admissions", {
attempt: integer("attempt").notNull(),
primaryProjectId: uuid("primary_project_id"),
contextRevision: text("context_revision").notNull(),
admittedProjectIds: uuid("admitted_project_ids").array(),
settlementId: uuid("settlement_id"),
settledAt: timestamp("settled_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
@@ -42,6 +46,8 @@ export const executionAdmissions = pgTable("execution_admissions", {
liveRun: uniqueIndex("execution_admissions_live_run_uq").on(t.runId).where(sql`${t.settledAt} is null`),
attemptCheck: check("execution_admissions_attempt_check", sql`${t.attempt} > 0`),
settlementCheck: check("execution_admissions_settlement_check", sql`(${t.settlementId} is null) = (${t.settledAt} is null)`),
projectSetCheck: check("execution_admissions_project_set_check", sql`case when ${t.admittedProjectIds} is null
then ${t.settledAt} is not null else array_position(${t.admittedProjectIds}, null) is null and cardinality(${t.admittedProjectIds}) <= 100 end`),
}));
/** One active charge per distinct admitted project. Settlement deletes the whole set; a charged project cannot be deleted. */
@@ -1 +1 @@
[{"annotations":{"exposure":"always","operationId":"get_task_context","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read the active task and actor, including the exact approved Markdown revision when this issue has an accepted plan.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"get_task_context","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"get_task_history","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read bounded comments on the active task.","inputSchema":{"additionalProperties":false,"properties":{"limit":{"default":50,"maximum":200,"minimum":1,"type":"integer"}},"required":[],"type":"object"},"name":"get_task_history","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"list_documents","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"List revisioned documents on the active task.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"list_documents","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"read_document","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read the current revision of one active-task document.","inputSchema":{"additionalProperties":false,"properties":{"key":{"description":"Stable issue-document key.","maxLength":120,"minLength":1,"type":"string"}},"required":["key"],"type":"object"},"name":"read_document","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"list_document_revisions","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read bounded revision history for one active-task document.","inputSchema":{"additionalProperties":false,"properties":{"key":{"description":"Stable issue-document key.","maxLength":120,"minLength":1,"type":"string"},"limit":{"default":50,"maximum":200,"minimum":1,"type":"integer"}},"required":["key"],"type":"object"},"name":"list_document_revisions","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"report_progress","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Append a durable progress comment to the active task.","inputSchema":{"additionalProperties":false,"properties":{"body":{"description":"Multiline progress update.","maxLength":20000,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","body"],"type":"object"},"name":"report_progress","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"answer_status_question","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Append the answer to a status-only wake without changing task disposition.","inputSchema":{"additionalProperties":false,"properties":{"body":{"description":"Concise status answer.","maxLength":20000,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","body"],"type":"object"},"name":"answer_status_question","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"write_document","requiredClaimLine truncated
[{"annotations":{"exposure":"always","operationId":"get_task_context","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read the active task and actor, including the exact approved Markdown revision when this issue has an accepted plan.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"get_task_context","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"get_task_history","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read bounded comments on the active task.","inputSchema":{"additionalProperties":false,"properties":{"limit":{"default":50,"maximum":200,"minimum":1,"type":"integer"}},"required":[],"type":"object"},"name":"get_task_history","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"list_documents","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"List revisioned documents on the active task.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"list_documents","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"read_document","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read the current revision of one active-task document.","inputSchema":{"additionalProperties":false,"properties":{"key":{"description":"Stable document identifier, not a filename or path (for example reconciliation, not reconciliation.md). After trimming whitespace and lowercasing: 1–64 characters, starting with a letter or digit and containing only a–z, 0–9, _ or -. The raw input is limited to 120 characters.","maxLength":120,"minLength":1,"pattern":"^[\\u0009-\\u000d\\u0020\\u00a0\\u1680\\u2000-\\u200a\\u2028\\u2029\\u202f\\u205f\\u3000\\ufeff]*[A-Za-z0-9\\u212a][A-Za-z0-9_\\u212a-]{0,63}[\\u0009-\\u000d\\u0020\\u00a0\\u1680\\u2000-\\u200a\\u2028\\u2029\\u202f\\u205f\\u3000\\ufeff]*$","type":"string"}},"required":["key"],"type":"object"},"name":"read_document","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"list_document_revisions","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read bounded revision history for one active-task document.","inputSchema":{"additionalProperties":false,"properties":{"key":{"description":"Stable document identifier, not a filename or path (for example reconciliation, not reconciliation.md). After trimming whitespace and lowercasing: 1–64 characters, starting with a letter or digit and containing only a–z, 0–9, _ or -. The raw input is limited to 120 characters.","maxLength":120,"minLength":1,"pattern":"^[\\u0009-\\u000d\\u0020\\u00a0\\u1680\\u2000-\\u200a\\u2028\\u2029\\u202f\\u205f\\u3000\\ufeff]*[A-Za-z0-9\\u212a][A-Za-z0-9_\\u212a-]{0,63}[\\u0009-\\u000d\\u0020\\u00a0\\u1680\\u2000-\\u200a\\u2028\\u2029\\u202f\\u205f\\u3000\\ufeff]*$","type":"string"},"limit":{"default":50,"maximum":200,"minimum":1,"type":"integer"}},"required":["key"],"type":"object"},"name":"list_document_revisions","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"report_progress","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Append a durable progress comment to the active task.","inputSchema":{"additionalProperties":false,"properties":{"body":{"description":"Multiline progress update.","maxLength":20000,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","body"],"type":"object"},"name":"report_progress","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"answer_status_question","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Append the answer to a status-only wake without changing task disposition.","inputSchema":{"additionalProperties":false,"properties":{"body":{"description":"Concise status answeLine truncated
@@ -336,9 +336,10 @@
"additionalProperties": false,
"properties": {
"key": {
"description": "Stable issue-document key.",
"description": "Stable document identifier, not a filename or path (for example reconciliation, not reconciliation.md). After trimming whitespace and lowercasing: 1–64 characters, starting with a letter or digit and containing only a–z, 0–9, _ or -. The raw input is limited to 120 characters.",
"maxLength": 120,
"minLength": 1,
"pattern": "^[\\u0009-\\u000d\\u0020\\u00a0\\u1680\\u2000-\\u200a\\u2028\\u2029\\u202f\\u205f\\u3000\\ufeff]*[A-Za-z0-9\\u212a][A-Za-z0-9_\\u212a-]{0,63}[\\u0009-\\u000d\\u0020\\u00a0\\u1680\\u2000-\\u200a\\u2028\\u2029\\u202f\\u205f\\u3000\\ufeff]*$",
"type": "string"
}
},
@@ -371,9 +372,10 @@
"additionalProperties": false,
"properties": {
"key": {
"description": "Stable issue-document key.",
"description": "Stable document identifier, not a filename or path (for example reconciliation, not reconciliation.md). After trimming whitespace and lowercasing: 1–64 characters, starting with a letter or digit and containing only a–z, 0–9, _ or -. The raw input is limited to 120 characters.",
"maxLength": 120,
"minLength": 1,
"pattern": "^[\\u0009-\\u000d\\u0020\\u00a0\\u1680\\u2000-\\u200a\\u2028\\u2029\\u202f\\u205f\\u3000\\ufeff]*[A-Za-z0-9\\u212a][A-Za-z0-9_\\u212a-]{0,63}[\\u0009-\\u000d\\u0020\\u00a0\\u1680\\u2000-\\u200a\\u2028\\u2029\\u202f\\u205f\\u3000\\ufeff]*$",
"type": "string"
},
"limit": {
@@ -699,9 +701,10 @@
"type": "string"
},
"key": {
"description": "Stable issue-document key.",
"description": "Stable document identifier, not a filename or path (for example reconciliation, not reconciliation.md). After trimming whitespace and lowercasing: 1–64 characters, starting with a letter or digit and containing only a–z, 0–9, _ or -. The raw input is limited to 120 characters.",
"maxLength": 120,
"minLength": 1,
"pattern": "^[\\u0009-\\u000d\\u0020\\u00a0\\u1680\\u2000-\\u200a\\u2028\\u2029\\u202f\\u205f\\u3000\\ufeff]*[A-Za-z0-9\\u212a][A-Za-z0-9_\\u212a-]{0,63}[\\u0009-\\u000d\\u0020\\u00a0\\u1680\\u2000-\\u200a\\u2028\\u2029\\u202f\\u205f\\u3000\\ufeff]*$",
"type": "string"
},
"title": {
@@ -24,7 +24,7 @@
"prpVersion": 1,
"nativeExecutionVersion": 1,
"catalogVersion": 1,
"catalogSha256": "sha256:d1886239c2dc902879b3e1738ca39c7efe93a39a3ece937a7390f720e5bda18c",
"catalogSha256": "sha256:ea55aec60ac7737359115227cc0a5e9000bf0098d1e3f92d4281661cac290868",
"driverContractVersion": 1,
"driverKind": "paperclip-deterministic",
"driverVersion": "1.0.0"
@@ -185,7 +185,7 @@
},
{
"path": "fixtures/evals/native-execution-seeded.json",
"sha256": "319fdf4c80abbd24073c4f1bd041d4b183e6e4660bd473762c9765007c479ae0",
"sha256": "5ce8623132b60eb3e38214ebb75b98172c8a5cf177791a7a43ef75fe66c89db5",
"expectation": "accept",
"compatibilityCase": "canonical"
},
@@ -14,6 +14,7 @@ use crate::local_runner::LocalRunnerError;
use crate::process_supervisor::{
BoundedLogBuffer, ProcessOutput, SupervisedProcess, VerifiedProcessLaunch,
};
use crate::provider_failure::validated_provider_failure;
use crate::stable_identity::{is_stable_id, DURABLE_STABLE_ID_CHARS, SHORT_STABLE_ID_CHARS};
pub const ACPX_SIDECAR_MAX_FRAME_BYTES: usize = 1024 * 1024;
@@ -908,6 +909,24 @@ fn stderr_diagnostic_categories(value: &str) -> BTreeSet<&'static str> {
categories
}
// A typed ensure rejection names its provider-failure category, so a limit at
// session start is distinguishable from other startup failures. Only the
// schema-validated enum crosses here, never the cause's text.
fn typed_provider_classification(error: &ResponseError) -> Option<&'static str> {
let failure = validated_provider_failure(error.provider_failure.clone())?;
Some(match failure.get("category")?.as_str()? {
"rate_limit" => "provider_rate_limit",
"quota_exhausted" => "provider_quota_exhausted",
"authentication" => "provider_authentication",
"permission" => "provider_permission",
"model_unavailable" => "provider_model_unavailable",
"invalid_request" => "provider_invalid_request",
"overloaded" => "provider_overloaded",
"timeout" => "provider_timeout",
_ => return None,
})
}
fn response_error_classification(error: &ResponseError) -> &'static str {
match error.code.as_str() {
"GROK_EFFORT_ACKNOWLEDGEMENT_MISSING" => return "grok_effort_acknowledgement_missing",
@@ -955,7 +974,9 @@ fn response_error_classification(error: &ResponseError) -> &'static str {
"SESSION_CONFIG_OPTION_REPLAY_FAILED" => return "session_config_option_replay_failed",
"CLAUDE_ACP_SESSION_CREATE_TIMEOUT" => return "claude_session_create_timeout",
"ACPX_SESSION_HANDSHAKE_TIMEOUT" => return "session_handshake_timeout",
"ACPX_SESSION_ENSURE_FAILED" => return "session_ensure_failed",
"ACPX_SESSION_ENSURE_FAILED" => {
return typed_provider_classification(error).unwrap_or("session_ensure_failed")
}
"ACPX_SESSION_ENSURE_TYPE_ERROR" => return "session_ensure_type_error",
"ACPX_SESSION_ENSURE_NON_ERROR" => return "session_ensure_non_error",
"ACP_SESSION_INIT_FAILED" => return "acp_session_init_failed",
@@ -1188,6 +1209,55 @@ mod tests {
}
}
#[test]
fn session_ensure_names_only_a_typed_provider_category() {
let ensure = |provider_failure: Option<Value>| ResponseError {
code: "ACPX_SESSION_ENSURE_FAILED".to_owned(),
message: "private-token-canary".to_owned(),
retryable: false,
provider_failure,
};
let cause = |category: &str| {
json!({ "schema": "paperclip.provider-failure.v1", "provider": "anthropic", "phase": "startup",
"category": category, "message": "private-token-canary", "retryability": "unknown" })
};
for (category, expected) in [
("quota_exhausted", "provider_quota_exhausted"),
("rate_limit", "provider_rate_limit"),
("authentication", "provider_authentication"),
("unknown", "session_ensure_failed"),
] {
assert_eq!(
response_error_classification(&ensure(Some(cause(category)))),
expected
);
}
assert_eq!(
response_error_classification(&ensure(None)),
"session_ensure_failed"
);
// A cause outside the schema cannot select a classification.
let mut invented = cause("quota_exhausted");
invented["category"] = json!("private-token-canary");
assert_eq!(
response_error_classification(&ensure(Some(invented))),
"session_ensure_failed"
);
let mut extra = cause("quota_exhausted");
extra["headers"] = json!({ "authorization": "private-token-canary" });
assert_eq!(
response_error_classification(&ensure(Some(extra))),
"session_ensure_failed"
);
// Other closed codes keep their own classification.
let mut admission = ensure(Some(cause("quota_exhausted")));
admission.code = "ACPX_ADMISSION_CREDENTIAL".to_owned();
assert_eq!(
response_error_classification(&admission),
"admission_credential"
);
}
#[test]
fn classifies_only_allowlisted_internal_sidecar_failures() {
let error = |code: &str, message: &str| ResponseError {
@@ -256,17 +256,21 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
write_json(&mut stdout, &success(id + 1, command, &request))?;
}
"remote-error" => {
let mut error = json!({
"code": request["params"]["errorCode"].as_str().unwrap_or("Q7Z9"),
"message": "violet-circuit-4821",
"retryable": false,
});
if let Some(cause) = request["params"].get("providerFailure") {
error["providerFailure"] = cause.clone();
}
write_json(
&mut stdout,
&json!({
"protocolVersion": GENERATED_ACPX_SIDECAR_PROTOCOL_VERSION,
"id": id,
"ok": false,
"error": {
"code": request["params"]["errorCode"].as_str().unwrap_or("Q7Z9"),
"message": "violet-circuit-4821",
"retryable": false,
},
"error": error,
}),
)?;
}
@@ -247,6 +247,35 @@ fn preserves_closed_admission_categories_over_the_sidecar_pipe() {
transport.shutdown().unwrap();
}
#[test]
fn session_ensure_rejection_carries_its_provider_cause_outside_the_error_text() {
let mut transport = transport("remote-error", Duration::from_secs(1));
let cause = |category: &str| {
json!({ "schema": "paperclip.provider-failure.v1", "provider": "anthropic", "phase": "startup",
"category": category, "message": "You've hit your limit [REDACTED] amber-cause-5512",
"retryability": "requires_action" })
};
for (category, classification) in [
("quota_exhausted", "provider_quota_exhausted"),
("unknown", "session_ensure_failed"),
] {
let error = transport
.request(
GeneratedAcpxSidecarCommand::SessionOpen,
json!({ "errorCode": "ACPX_SESSION_ENSURE_FAILED", "providerFailure": cause(category) }),
)
.unwrap_err();
let message = error.to_string();
assert!(message.contains(&format!(
"retryable=false, classification={classification})"
)));
assert!(!message.contains("amber-cause-5512"));
assert!(!message.contains("violet-circuit-4821"));
assert_eq!(error.provider_failure(), Some(cause(category)));
}
transport.shutdown().unwrap();
}
#[test]
fn redacts_sidecar_stderr_when_the_process_exits() {
let mut transport = transport("exit-secret", Duration::from_secs(1));
@@ -6,6 +6,9 @@ import Ajv2020 from "ajv/dist/2020.js";
import { describe, expect, it } from "vitest";
import { createTaskAction } from "../protocol-actions/create-task.js";
import { createProjectAction } from "../protocol-actions/create-project.js";
import { readDocumentAction } from "../protocol-actions/read-document.js";
import { writeDocumentAction } from "../protocol-actions/write-document.js";
import { listDocumentRevisionsAction } from "../protocol-actions/list-document-revisions.js";
import { handoffCaseAction } from "../protocol-actions/handoff-case.js";
import {
@@ -21,6 +24,26 @@ const packageRoot = resolve(
);
describe("semantic action catalog", () => {
it.each([readDocumentAction, writeDocumentAction, listDocumentRevisionsAction])(
"$id uses the same normalized key contract on every tool surface",
(action) => {
const ajv = new Ajv2020({ strict: true });
const schemas = [
action.live.descriptor.inputSchema.properties.key,
action.scenario.descriptor.inputSchema.properties.key,
paperclipSemanticAction(action.id)!.inputSchema.properties!.key,
];
for (const schema of schemas) {
const validate = ajv.compile(schema);
for (const key of ["reconciliation", "Reconciliation", "K", " ".repeat(56) + "a".repeat(64)]) {
expect(validate(key), key).toBe(true);
}
for (const key of ["reconciliation.md", "../document", "a".repeat(65), " ".repeat(57) + "a".repeat(64)]) {
expect(validate(key), key).toBe(false);
}
}
},
);
it("compiles case handoff identity constraints in strict consumers and rejects malformed IDs", () => {
const ajv = new Ajv2020({ allErrors: true, allowUnionTypes: true, strict: true });
for (const schema of [handoffCaseAction.live.descriptor.inputSchema, paperclipSemanticAction("handoff_case")!.inputSchema]) {
@@ -1,3 +1,4 @@
import { taskDocumentKeySchema } from "../protocol-actions/task-document-key-schema.js";
import { getWorkspaceAction } from "../protocol-actions/get-workspace.js";
import { handoffCaseAction } from "../protocol-actions/handoff-case.js";
import { createCaseAction, readCaseAction, updateCaseAction } from "../protocol-actions/case-data.js";
@@ -156,7 +157,7 @@ const descriptors: readonly PaperclipSemanticActionDescriptor[] = [
operationId: "read_document",
title: "Read task document",
description: "Read the current revision of one active-task document.",
inputSchema: object({ key: text("Stable issue-document key.", 120) }, [
inputSchema: object({ key: taskDocumentKeySchema }, [
"key",
]),
}),
@@ -166,7 +167,7 @@ const descriptors: readonly PaperclipSemanticActionDescriptor[] = [
description: "Read bounded revision history for one active-task document.",
inputSchema: object(
{
key: text("Stable issue-document key.", 120),
key: taskDocumentKeySchema,
limit: { type: "integer", minimum: 1, maximum: 200, default: 50 },
},
["key"],
@@ -220,7 +221,7 @@ const descriptors: readonly PaperclipSemanticActionDescriptor[] = [
inputSchema: object(
{
...idempotency,
key: text("Stable issue-document key.", 120),
key: taskDocumentKeySchema,
title: text("Document title.", 300),
body: text("Markdown document body.", 200_000),
baseRevisionId: nullableText(
@@ -11,6 +11,7 @@ import {
canonicalNativeRuntimeContextDigest,
composeNativeSystemInstructions,
nativeRuntimePromptDigest,
type NativeRuntimeContextSnapshot,
} from "./runtime-context.js";
const input: NativeExecutionInputV1 = {
@@ -81,6 +82,7 @@ describe("NativeExecutionInputV1", () => {
runtimeContext: { ...context, aggregateDigest: canonicalNativeRuntimeContextDigest(context) },
}));
const recovered = parseNativeExecutionInput(persisted);
if (!("runtimeContext" in recovered)) throw new Error("Expected a runtime context");
expect(recovered.runtimeContext).toEqual(persisted.runtimeContext);
expect(parseNativeExecutionInput(recovered)).toEqual(recovered);
},
@@ -88,7 +90,7 @@ describe("NativeExecutionInputV1", () => {
it("parses v3 immutable runtime context without changing the model task envelope", () => {
const digest = "0".repeat(64);
const context = {
const context: Omit<NativeRuntimeContextSnapshot, "aggregateDigest"> = {
prompt: { revision: PAPERCLIP_EXECUTION_PROMPT_REVISION, text: PAPERCLIP_EXECUTION_PROMPT, digest: nativeRuntimePromptDigest() },
instructions: {
entryPath: "AGENTS.md",
@@ -101,7 +103,7 @@ describe("NativeExecutionInputV1", () => {
bundle: { schema: NATIVE_RUNTIME_ASSET_SCHEMA, digest, manifestDigest: digest, rootPath: "/runtime/skills/research", fileCount: 2, totalBytes: 42 },
}],
mcp: { assignmentSetId: "sha256:test", digest, bindingId: "native-mcp:run-1" },
} as const;
};
const preconfigured = {
...input, schema: "paperclip.native-execution-input.v7", homeMode: "preconfigured",
provider: { kind: "codex", model: null, approvalPolicy: "never" },
@@ -129,6 +131,7 @@ describe("NativeExecutionInputV1", () => {
runtimeContext: { ...context, aggregateDigest: canonicalNativeRuntimeContextDigest(context) },
});
expect(parsed.schema).toBe("paperclip.native-execution-input.v3");
if (!("runtimeContext" in parsed)) throw new Error("Expected a runtime context");
const envelope = buildNativeModelEnvelope(parsed);
expect(envelope.task).toEqual(buildNativeModelEnvelope(input).task);
expect(envelope.completionContract).toEqual(buildNativeModelEnvelope(input).completionContract);
@@ -277,7 +280,7 @@ describe("NativeExecutionInputV1", () => {
});
it("deserializes pre-provider Codex state as Codex", () => {
const legacy = structuredClone(input) as Record<string, unknown>;
const legacy: Record<string, unknown> = { ...structuredClone(input) };
delete legacy.provider;
expect(parseNativeExecutionInput(legacy).provider).toEqual({
kind: "codex",
@@ -448,9 +451,7 @@ describe("NativeExecutionInputV1", () => {
});
it.each([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40] as const)("decodes closed Copilot profile history at version %s without admitting a future version", (agentProfileVersion) => {
const { qualificationModel: _qualificationModel, reportedModelId: _reportedModelId,
permissionPolicy, qualificationStatus: _qualificationStatus, modelPolicy: _modelPolicy,
...snapshot } = QUALIFIED_ACPX_PROFILES.copilot;
const { permissionPolicy, qualificationStatus: _qualificationStatus, ...snapshot } = QUALIFIED_ACPX_PROFILES.copilot;
const provider = {
kind: "acpx", agent: "copilot", model: "gpt-5.6-luna", permissionPolicy,
profile: { ...snapshot, agentProfileVersion },
@@ -474,11 +475,10 @@ describe("NativeExecutionInputV1", () => {
it.each(Object.values(QUALIFIED_ACPX_PROFILES))(
"admits the current $agent profile through the native execution boundary",
(declaration) => {
const { qualificationModel, reportedModelId: _reported, permissionPolicy,
modelPolicy: _modelPolicy, qualificationStatus: _status, ...profile } = declaration;
const { permissionPolicy, qualificationStatus: _status, ...profile } = declaration;
const provider = {
kind: "acpx", agent: declaration.agent,
model: qualificationModel || "explicit-provider-model",
model: "explicit-provider-model",
permissionPolicy, profile,
...(declaration.agent === "pi" ? { piThinkingLevel: "low" } : {}),
};
@@ -493,7 +493,7 @@ describe("NativeExecutionInputV1", () => {
);
it("defaults legacy lifecycle state to per-turn and validates warm timeouts", () => {
const legacy = structuredClone(input) as Record<string, unknown>;
const legacy: Record<string, unknown> = { ...structuredClone(input) };
delete (legacy.session as Record<string, unknown>).lifecyclePolicy;
expect(parseNativeExecutionInput(legacy).session.lifecyclePolicy).toEqual({
mode: "per_turn",
@@ -566,11 +566,11 @@ describe("NativeExecutionInputV2 planning", () => {
})).toMatchObject({ task: { workMode: "planning" }, executionMode: "default" });
});
const cloudContext = {
const cloudContext: Omit<NativeRuntimeContextSnapshot, "aggregateDigest"> = {
prompt: { revision: PAPERCLIP_EXECUTION_PROMPT_REVISION, text: PAPERCLIP_EXECUTION_PROMPT, digest: nativeRuntimePromptDigest() },
instructions: { entryPath: "AGENTS.md", bundle: { schema: NATIVE_RUNTIME_ASSET_SCHEMA, digest: "0".repeat(64), manifestDigest: "0".repeat(64), rootPath: "/runtime/instructions", fileCount: 1, totalBytes: 1 } },
skills: [], mcp: { assignmentSetId: "none", digest: "0".repeat(64), bindingId: null },
} as const;
};
const cloudPlanning = {
...planning, schema: "paperclip.native-execution-input.v5",
provider: { kind: "cursor_cloud", model: "explicit-cloud-model", cursorCloud: { schema: "paperclip.cursor-cloud.config.v1", repos: [{ url: "https://github.com/example/fixture" }] } },
@@ -639,12 +639,12 @@ describe.each(["ask", "skill_test"])("NativeExecutionInputV2 %s mode", (workMode
describe("native task context ownership", () => {
function currentInput() {
const digest = "0".repeat(64);
const context = {
const context: Omit<NativeRuntimeContextSnapshot, "aggregateDigest"> = {
prompt: { revision: PAPERCLIP_EXECUTION_PROMPT_REVISION, text: PAPERCLIP_EXECUTION_PROMPT, digest: nativeRuntimePromptDigest() },
instructions: { entryPath: "AGENTS.md", bundle: { schema: NATIVE_RUNTIME_ASSET_SCHEMA, digest, manifestDigest: digest, rootPath: "/runtime/instructions", fileCount: 1, totalBytes: 1 } },
skills: [],
mcp: { assignmentSetId: "none", digest, bindingId: null },
} as const;
};
return parseNativeExecutionInput({
...input,
schema: NATIVE_EXECUTION_INPUT_SCHEMA,
@@ -656,8 +656,7 @@ describe("native task context ownership", () => {
});
}
const { qualificationModel: _copilotQualificationModel, reportedModelId: _copilotReportedModelId,
permissionPolicy: _copilotPermissionPolicy, qualificationStatus: _copilotQualificationStatus, modelPolicy: _copilotModelPolicy,
const { permissionPolicy: _copilotPermissionPolicy, qualificationStatus: _copilotQualificationStatus,
...copilotSnapshot } = QUALIFIED_ACPX_PROFILES.copilot;
it.each([
@@ -690,6 +689,7 @@ describe("native task context ownership", () => {
} : {}),
}));
const recovered = parseNativeExecutionInput(persisted);
if (!("runtimeContext" in recovered)) throw new Error("Expected a runtime context");
expect(recovered.runtimeContext).toEqual(context);
expect(recovered.provider).toEqual(provider);
expect(recovered.session.driverKind).toBe(driverKind);
@@ -759,6 +759,7 @@ describe("native task context ownership", () => {
const fullResume = buildNativeModelEnvelope(parsed, { resumedSession: true });
expect(fullResume.schema).toBe(envelopeSchema);
if (!("task" in fullResume)) throw new Error("Expected a full resume envelope");
expect(fullResume.task.prompt).toBe(parsed.task.prompt);
expect(fullResume.task.prompt).not.toContain(guidance);
@@ -796,6 +797,7 @@ describe("native task context ownership", () => {
completionContract: { ...execution.completionContract, contract },
completionSources: { promptSha256: createHash("sha256").update(execution.task.prompt).digest("hex"), contractRevision: contract.revision, criteria: [{ id: "objective", source }] },
});
if (completeInput.schema !== NATIVE_EXECUTION_INPUT_SCHEMA) throw new Error("Expected a v5 execution input");
const before = structuredClone(completeInput.completionContract);
const envelope = buildNativeModelEnvelope(completeInput);
expect(envelope.completionContract.criteria).toEqual([
@@ -815,7 +817,7 @@ describe("native task context ownership", () => {
describe("Claude effort input v9", () => {
const { permissionPolicy: _permission, qualificationModel: _model, reportedModelId: _reported, ...profile } = QUALIFIED_ACPX_PROFILES.claude;
const { permissionPolicy: _permission, ...profile } = QUALIFIED_ACPX_PROFILES.claude;
const digest = "0".repeat(64);
const context = { prompt: { revision: PAPERCLIP_EXECUTION_PROMPT_REVISION, text: PAPERCLIP_EXECUTION_PROMPT, digest: nativeRuntimePromptDigest() },
instructions: { entryPath: "AGENTS.md", bundle: { schema: NATIVE_RUNTIME_ASSET_SCHEMA, digest, manifestDigest: digest, rootPath: "/runtime/instructions", fileCount: 1, totalBytes: 1 } },
@@ -856,7 +858,7 @@ describe("Claude effort input v9", () => {
});
describe("Grok effort input v10", () => {
const { permissionPolicy: _permission, qualificationModel: _model, reportedModelId: _reported, ...profile } = QUALIFIED_ACPX_PROFILES.grok;
const { permissionPolicy: _permission, ...profile } = QUALIFIED_ACPX_PROFILES.grok;
const digest = "0".repeat(64);
const context = { prompt: { revision: PAPERCLIP_EXECUTION_PROMPT_REVISION, text: PAPERCLIP_EXECUTION_PROMPT, digest: nativeRuntimePromptDigest() },
instructions: { entryPath: "AGENTS.md", bundle: { schema: NATIVE_RUNTIME_ASSET_SCHEMA, digest, manifestDigest: digest, rootPath: "/runtime/instructions", fileCount: 1, totalBytes: 1 } },
@@ -49,6 +49,56 @@ describe("Codex ACPX runtime adapter", () => {
});
expect(runtime.startTurn).not.toHaveBeenCalled();
});
it("keeps a redacted explanation for an ensure rejection that carries no ACP error", async () => {
const runtime = fakeRuntime();
const options = openOptions(fakeCommand());
options.profile = { ...options.profile, agent: "claude" };
options.launchEnvironment = { ANTHROPIC_API_KEY: "fixture-anthropic-secret" };
const rejection = new Error("Claude Code exited during startup for fixture-anthropic-secret (sk-ant-api03-fixtureprivate)");
vi.mocked(runtime.ensureSession).mockRejectedValue(rejection);
const failure = await openCodexAcpxRuntime(options, { createRegistry: () => registry(), createStore: () => store(),
createRuntime: () => runtime }).catch((error: unknown) => error);
expect(failure).toBe(rejection);
expect(failure).toMatchObject({ code: "ACPX_SESSION_ENSURE_FAILED", providerFailure: { provider: "anthropic",
phase: "startup", category: "unknown", message: "Claude Code exited during startup for [REDACTED] ([REDACTED])" } });
expect(JSON.stringify((failure as { providerFailure: unknown }).providerFailure)).not.toMatch(/fixture-anthropic-secret|fixtureprivate/);
});
it("classifies an ACP authentication-required ensure rejection", async () => {
const runtime = fakeRuntime();
const message = "Authentication required: This integration does not support using claude.ai subscriptions.";
vi.mocked(runtime.ensureSession).mockRejectedValue(Object.assign(new Error(message), { code: -32000 }));
const options = openOptions(fakeCommand());
options.profile = { ...options.profile, agent: "claude" };
await expect(openCodexAcpxRuntime(options, { createRegistry: () => registry(), createStore: () => store(),
createRuntime: () => runtime })).rejects.toMatchObject({ code: "ACPX_SESSION_ENSURE_FAILED",
providerFailure: { category: "authentication", message, phase: "startup", retryability: "requires_action" } });
});
it("attaches a provider cause to a non-error ensure rejection", async () => {
const runtime = fakeRuntime();
vi.mocked(runtime.ensureSession).mockRejectedValue({ code: -32603, message: "Internal error", data: { errorKind: "billing_error" } });
const options = openOptions(fakeCommand());
options.profile = { ...options.profile, agent: "claude" };
await expect(openCodexAcpxRuntime(options, { createRegistry: () => registry(), createStore: () => store(),
createRuntime: () => runtime })).rejects.toMatchObject({ code: "ACPX_SESSION_ENSURE_NON_ERROR",
providerFailure: { category: "quota_exhausted", providerCode: "billing_error", phase: "startup" } });
});
it("does not report Runner cancellation as a provider explanation", async () => {
const runtime = fakeRuntime();
const controller = new AbortController();
const reason = new Error("runtime admission cancelled");
const rejection = new Error("ACP connection closed");
vi.mocked(runtime.ensureSession).mockImplementation(async () => {
controller.abort(reason);
throw rejection;
});
const options = openOptions(fakeCommand());
options.signal = controller.signal;
await expect(openCodexAcpxRuntime(options, { createRegistry: () => registry(), createStore: () => store(),
createRuntime: () => runtime })).rejects.toBe(reason);
await vi.waitFor(() => expect(rejection).toHaveProperty("code", "ACPX_SESSION_ENSURE_FAILED"));
expect(rejection).not.toHaveProperty("providerFailure");
expect(reason).not.toHaveProperty("providerFailure");
});
it.each([false, true])("acknowledges configured Claude effort before admission (restoring=%s)", async restoring => {
const runtime = fakeRuntime();
const child = fakeChild();
@@ -5,6 +5,7 @@ import { resolveGrokDisableWebSearch } from "./grok-web-search.js";
import { resolveGrokEffort, createGrokEffortAdmission } from "./grok-effort.js";
import { resolveClaudeEffort, createClaudeEffortAdmission } from "./claude-effort.js";
import { acpxProviderFailure } from "./provider-failure.js";
import type { ProviderFailure } from "../../contracts/provider-failure.generated.js";
import { createPiThinkingAdmission, resolvePiThinkingLevel } from "./pi-thinking.js";
import { usesPreconfiguredHome } from "../preconfigured-home.js";
import type { ChildProcess } from "node:child_process";
@@ -600,11 +601,12 @@ export async function openQualifiedAcpxRuntime(
return ensuredHandle;
}) : ensuredSession)
.catch((error: unknown) => {
const providerFailure = acpxProviderFailure(error, options.profile.agent, "startup", options.launchEnvironment);
if (providerFailure && error instanceof Error) {
Object.defineProperty(error, "providerFailure", { value: providerFailure, enumerable: false });
}
throw classifySessionEnsureFailure(error);
// The closed code below cannot say why the provider session did not
// start, so every ensure rejection keeps its redacted explanation. An
// abort reason is Runner cancellation, not a provider explanation.
const providerFailure = acpxProviderFailure(error, options.profile.agent, "startup",
options.launchEnvironment, options.signal?.aborted !== true);
throw classifySessionEnsureFailure(error, providerFailure);
});
let lateCleanup: Promise<void> | null = null;
try {
@@ -706,20 +708,21 @@ export async function openQualifiedAcpxRuntime(
/** Backward-compatible name retained for existing Codex-only consumers. */
export const openCodexAcpxRuntime = openQualifiedAcpxRuntime;
function classifySessionEnsureFailure(error: unknown): Error {
if (error instanceof Error) {
const details = error as Error & Record<string, unknown>;
if (typeof details.code !== "string" || details.code.length === 0) {
details.code =
error instanceof TypeError
? "ACPX_SESSION_ENSURE_TYPE_ERROR"
: "ACPX_SESSION_ENSURE_FAILED";
}
return error;
function classifySessionEnsureFailure(error: unknown, providerFailure: ProviderFailure | null): Error {
const failure = error instanceof Error ? error
: Object.assign(new Error("ACPX session ensure rejected a non-error"), { code: "ACPX_SESSION_ENSURE_NON_ERROR" });
const details = failure as Error & Record<string, unknown>;
if (typeof details.code !== "string" || details.code.length === 0) {
details.code =
failure instanceof TypeError
? "ACPX_SESSION_ENSURE_TYPE_ERROR"
: "ACPX_SESSION_ENSURE_FAILED";
}
return Object.assign(new Error("ACPX session ensure rejected a non-error"), {
code: "ACPX_SESSION_ENSURE_NON_ERROR",
});
// Keep a cause that an inner layer already attached.
if (providerFailure && !Object.hasOwn(failure, "providerFailure")) {
Object.defineProperty(failure, "providerFailure", { value: providerFailure, enumerable: false });
}
return failure;
}
function raceRuntimeHandshakeWithAbort<T>(
@@ -14,11 +14,13 @@ describe("ACPX launch environment", () => {
expect(() => createSanitizedAcpxSpawnInput({[key]:value, [CONFIGURED_ENVIRONMENT_KEYS]:JSON.stringify([key])}, "claude")).toThrow("configured environment");
}
});
it("keeps explicit Claude managed-command selectors without forwarding GitHub credentials", () => {
it("keeps explicit Claude managed-command transport without forwarding GitHub credentials", () => {
const helpers = { PATH: "/managed:/usr/bin", BASH_ENV: "/managed/.bashrc", ZDOTDIR: "/managed",
GH_CONFIG_DIR: "/managed", PAPERCLIP_GITHUB_LAUNCHER_DIR: "/managed" };
GH_CONFIG_DIR: "/managed", PAPERCLIP_GITHUB_LAUNCHER_DIR: "/managed",
PAPERCLIP_GITHUB_BROKER_URL: "http://127.0.0.1:3210", PAPERCLIP_GITHUB_BROKER_TOKEN: "session-capability",
PAPERCLIP_GITHUB_BRIDGE_TOKEN: "bridge-capability" };
const source = { ...helpers, GH_TOKEN: "host-secret", GITHUB_TOKEN: "host-secret",
PAPERCLIP_GITHUB_BROKER_TOKEN: "host-capability", NODE_OPTIONS: "--require=untrusted" };
PAPERCLIP_GIT_TOKEN: "host-secret", NODE_OPTIONS: "--require=untrusted" };
expect(createSanitizedAcpxSpawnInput(source, "claude").env).toEqual(helpers);
for (const [key, value] of Object.entries(helpers)) vi.stubEnv(key, value);
const ambient = createSanitizedAcpxSpawnInput(undefined, "claude").env;
@@ -27,6 +29,12 @@ describe("ACPX launch environment", () => {
expect(ambient).not.toHaveProperty(key);
expect(sibling).not.toHaveProperty(key);
}
const { PAPERCLIP_GITHUB_LAUNCHER_DIR: _launcher, ...withoutLauncher } = source;
expect(createSanitizedAcpxSpawnInput(withoutLauncher, "claude").env).toEqual({ PATH: helpers.PATH });
for (const [key, value] of Object.entries(helpers).filter(([key]) => key.startsWith("PAPERCLIP_GITHUB_"))) {
expect(configuredEnvironmentProjection({ [key]: value })).not.toHaveProperty(key);
expect(() => createSanitizedAcpxSpawnInput({ [key]: value, [CONFIGURED_ENVIRONMENT_KEYS]: JSON.stringify([key]) }, "claude")).toThrow("configured environment");
}
});
it("does not inherit ambient prepared storage", () => {
vi.stubEnv("PAPERCLIP_RUNNER_PREPARED_HARNESS_HOME", "/ambient/claude");
@@ -153,7 +153,8 @@ export function createSanitizedAcpxSpawnInput(
// The environment owner supplies helper paths, not a replacement provider executable.
...(agent === "claude" && environment !== undefined ? ["MCP_TOOL_TIMEOUT"] : []),
...(agent === "claude" && environment?.PAPERCLIP_GITHUB_LAUNCHER_DIR?.trim()
? ["BASH_ENV", "ZDOTDIR", "GH_CONFIG_DIR", "PAPERCLIP_GITHUB_LAUNCHER_DIR"] : []),
? ["BASH_ENV", "ZDOTDIR", "GH_CONFIG_DIR", "PAPERCLIP_GITHUB_LAUNCHER_DIR",
"PAPERCLIP_GITHUB_BROKER_URL", "PAPERCLIP_GITHUB_BROKER_TOKEN", "PAPERCLIP_GITHUB_BRIDGE_TOKEN"] : []),
...(usesPreconfiguredHome(source) ? [...PRECONFIGURED_HOME_KEYS, "PAPERCLIP_RUNNER_HOME_MODE", "ACPX_CLAUDE_INCLUDE_USER_SETTINGS"] : []),
...(agent === "claude" && environment?.PAPERCLIP_RUNNER_PREPARED_HARNESS_HOME
? [...PRECONFIGURED_HOME_KEYS, "PAPERCLIP_RUNNER_PREPARED_HARNESS_HOME"] : []),
@@ -0,0 +1,100 @@
import { execFile } from "node:child_process";
import { mkdir, mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises";
import { createServer } from "node:http";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { promisify } from "node:util";
import { afterEach, expect, it } from "vitest";
import { githubLauncherSource } from "../../../../adapter-utils/src/github-launcher.js";
import { resolveQualifiedAcpxProfile } from "./qualified-profiles.js";
import { createAcpxRecoveryBinding } from "./recovery-identity.js";
import { prepareAcpxRuntimeSandbox } from "./runtime-sandbox.js";
const execute = promisify(execFile);
const cleanups: Array<() => Promise<unknown>> = [];
afterEach(async () => {
for (const cleanup of cleanups.splice(0).reverse()) await cleanup();
});
it.skipIf(process.platform === "win32").each(["isolated", "prepared", "preconfigured"])("authenticates Claude shell commands through its session broker (%s home)", async homeMode => {
const root = await realpath(await mkdtemp(join(tmpdir(), "paperclip-acpx-github-")));
cleanups.push(() => rm(root, { recursive: true, force: true }));
const workspace = join(root, "workspace");
const runtimeDirectory = join(root, "runtime");
const managed = join(root, "managed");
const real = join(root, "real");
const home = join(root, "home");
await Promise.all([workspace, runtimeDirectory, managed, real, home].map(path => mkdir(path)));
await mkdir(join(home, ".claude"));
const binding = await createAcpxRecoveryBinding({
runtimeDirectory,
normalizedSessionId: "github-session",
workingDirectory: workspace,
profile: resolveQualifiedAcpxProfile("claude", "claude-sonnet-5"),
requestedModel: "claude-sonnet-5",
permissionMode: "approve-reads",
});
let identity: "first" | "second" | null = "first";
let requests = 0;
const server = createServer((req, res) => {
req.resume();
requests++;
if (req.method !== "POST" || req.url !== "/runtime-tools/github/credentials"
|| req.headers.authorization !== "Bearer fixture-bridge"
|| req.headers["x-paperclip-github-capability"] !== "fixture-session") {
res.writeHead(403).end();
return;
}
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify(identity
? { status: "available", env: { GH_TOKEN: `fixture-operation-${identity}` } }
: { status: "unavailable", reason: "Fixture connection revoked" }));
});
await new Promise<void>(resolve => server.listen(0, "127.0.0.1", resolve));
cleanups.push(() => new Promise<void>(resolve => { server.closeAllConnections(); server.close(() => resolve()); }));
const url = `http://127.0.0.1:${(server.address() as { port: number }).port}`;
const path = [managed, real, dirname(process.execPath), "/usr/bin", "/bin"].join(":");
await writeFile(join(managed, ".bashrc"), `export PATH='${path}'\n`);
const executable = `#!/usr/bin/env node
const token = process.env.GH_TOKEN;
const identity = token === 'fixture-operation-first' ? 'first' : token === 'fixture-operation-second' ? 'second' : 'anonymous';
console.log(identity);
`;
for (const program of ["git", "gh"]) {
await writeFile(join(managed, program), githubLauncherSource(), { mode: 0o700 });
await writeFile(join(real, program), executable, { mode: 0o700 });
}
const environment = {
PATH: path,
BASH_ENV: join(managed, ".bashrc"),
ZDOTDIR: managed,
GH_CONFIG_DIR: managed,
PAPERCLIP_GITHUB_LAUNCHER_DIR: managed,
PAPERCLIP_GITHUB_BROKER_URL: url,
PAPERCLIP_GITHUB_BROKER_TOKEN: "fixture-session",
PAPERCLIP_GITHUB_BRIDGE_TOKEN: "fixture-bridge",
GH_TOKEN: "ambient-must-not-leak",
GITHUB_TOKEN: "ambient-must-not-leak",
...(homeMode === "preconfigured" ? { HOME: home, PAPERCLIP_RUNNER_HOME_MODE: "preconfigured" } : {}),
...(homeMode === "prepared" ? { PAPERCLIP_RUNNER_PREPARED_HARNESS_HOME: join(home, ".claude") } : {}),
};
const sandbox = await prepareAcpxRuntimeSandbox({ binding, agent: "claude", environment });
const commands = "git ls-remote https://github.com/example/fixture; gh api user";
for (const currentIdentity of ["first", "second", null] as const) {
identity = currentIdentity;
const result = await execute("/bin/bash", ["-c", commands], { cwd: workspace, env: sandbox.launchEnvironment, timeout: 10_000 });
expect(result.stdout.trim().split("\n")).toEqual([identity ?? "anonymous", identity ?? "anonymous"]);
if (identity) expect(result.stderr).toBe("");
else expect(result.stderr).toContain("Fixture connection revoked");
}
expect(requests).toBe(6);
expect(sandbox.launchEnvironment.GH_TOKEN).toBeUndefined();
expect(sandbox.launchEnvironment.GITHUB_TOKEN).toBeUndefined();
expect(JSON.stringify(sandbox.persistedEnvironment)).not.toMatch(/fixture-(?:session|bridge|operation)|PAPERCLIP_GITHUB_BROKER|PAPERCLIP_GITHUB_BRIDGE/);
const recovered = await prepareAcpxRuntimeSandbox({ binding, agent: "claude", environment });
identity = "first";
expect((await execute("/bin/bash", ["-c", commands], { cwd: workspace, env: recovered.launchEnvironment, timeout: 10_000 })).stdout.trim().split("\n")).toEqual(["first", "first"]);
expect(requests).toBe(8);
expect(await readFile(join(managed, "gh"), "utf8")).not.toContain("fixture-session");
});
@@ -17,27 +17,41 @@ function acpError(value: unknown, depth = 0): Record<string, unknown> | null {
return null;
}
// Provider error types, plus the `errorKind` values Claude ACP attaches to
// ACP error data so clients need not match message text.
const CATEGORIES = new Map<string, ProviderFailure["category"]>([
["authentication_error", "authentication"], ["permission_error", "permission"], ["overloaded_error", "overloaded"],
["rate_limit_error", "rate_limit"], ["billing_error", "quota_exhausted"], ["invalid_request_error", "invalid_request"],
["insufficient_quota", "quota_exhausted"], ["model_not_found", "model_unavailable"],
["authentication_failed", "authentication"], ["oauth_org_not_allowed", "authentication"],
["account_on_hold", "quota_exhausted"], ["rate_limit", "rate_limit"], ["overloaded", "overloaded"],
["invalid_request", "invalid_request"],
]);
// ACP reserves this JSON-RPC code for "Authentication required".
const ACP_AUTH_REQUIRED = -32000;
/**
* `ownMessage` keeps an error's own message when it carries no ACP error. Use
* it only where every rejection belongs to the provider: a failed terminal
* result or a provider session ensure.
*/
export function acpxProviderFailure(error: unknown, agent: string, phase: ProviderFailure["phase"],
environment: NodeJS.ProcessEnv = {}, terminal = false): ProviderFailure | null {
environment: NodeJS.ProcessEnv = {}, ownMessage = false): ProviderFailure | null {
const acp = acpError(error);
const data = record(acp?.data);
const detail = record(data.error);
const fallback = terminal ? record(error) : {};
const fallback = ownMessage ? record(error) : {};
const message = typeof detail.message === "string" ? detail.message
: typeof data.details === "string" ? data.details
: typeof acp?.message === "string" ? acp.message : fallback.message;
if (typeof message !== "string") return null;
const status = data.status ?? data.statusCode ?? detail.status
?? (agent === "claude" ? Number(/^API error: (\d{3})\b/.exec(message)?.[1]) : undefined);
const code = detail.type ?? detail.code ?? data.errorType;
const categories: Record<string, ProviderFailure["category"]> = {
authentication_error: "authentication", permission_error: "permission", overloaded_error: "overloaded",
rate_limit_error: "rate_limit", billing_error: "quota_exhausted", invalid_request_error: "invalid_request",
insufficient_quota: "quota_exhausted", model_not_found: "model_unavailable",
};
const code = detail.type ?? detail.code ?? data.errorType ?? data.errorKind;
const category = (typeof code === "string" ? CATEGORIES.get(code) : undefined)
?? (acp?.code === ACP_AUTH_REQUIRED ? "authentication" : undefined);
return createProviderFailure({ provider: agent === "claude" ? "anthropic" : agent === "codex" ? "openai" : agent,
phase, message, httpStatus: status, providerCode: code, requestId: data.request_id,
category: typeof code === "string" ? categories[code] : undefined, environment });
phase, message, httpStatus: status, providerCode: code, requestId: data.request_id, category, environment });
}
export function acpxTerminalResult(result: unknown, agent: string, environment: NodeJS.ProcessEnv = {}): Record<string, unknown> {
@@ -0,0 +1,97 @@
import { createHash } from "node:crypto";
import { afterEach, expect, it, vi } from "vitest";
import { startOpenCodeMcpBridge, type OpenCodeMcpBridge } from "./mcp-bridge.js";
const bridges: OpenCodeMcpBridge[] = [];
afterEach(async () => { await Promise.all(bridges.splice(0).map(bridge => bridge.close())); });
const report = {
reportedWorkDisposition: "done", summary: "Completed the synthetic request.",
completionClaim: { contractRevision: "1", objectiveSatisfied: true,
criteria: [{ criterionId: "objective", status: "satisfied", evidenceRefs: [] }], remainingWork: [] },
evidence: [], verification: [],
};
async function rpc(bridge: OpenCodeMcpBridge, id: number, method: string, params?: unknown) {
return (await fetch(bridge.url, { method: "POST", headers: {
Authorization: `Bearer ${bridge.secret}`, "Content-Type": "application/json",
}, body: JSON.stringify({ jsonrpc: "2.0", id, method, params }) })).json();
}
it("binds closed rejection shapes to the actual offered finish schema without recording values", async () => {
const messages: string[] = [], handler = vi.fn(async () => ({ accepted: true }));
const bridge = await startOpenCodeMcpBridge({ handler, onDiagnostic: message => messages.push(message) });
bridges.push(bridge);
const listed = await rpc(bridge, 1, "tools/list");
const schema = listed.result.tools.find((tool: { name: string }) => tool.name === "paperclip_finish").inputSchema;
const digest = createHash("sha256").update(JSON.stringify(schema)).digest("hex");
for (const [index, input] of [report, { ...report, continuation: null }].entries()) {
const result = await rpc(bridge, index + 2, "tools/call", { name: "paperclip_finish", arguments: input });
expect(result.result.isError).not.toBe(true);
}
for (const [index, continuation] of ["PRIVATE_INPUT_VALUE", [], 42, false].entries()) {
const rejected = await rpc(bridge, index + 4, "tools/call", {
name: "paperclip_finish", arguments: { ...report, continuation },
});
expect(rejected.result.isError).toBe(true);
}
const unrelated = await rpc(bridge, 8, "tools/call", {
name: "paperclip_finish", arguments: { ...report, summary: 42, continuation: null, PRIVATE_FIELD: "PRIVATE_INPUT_VALUE" },
});
expect(unrelated.result.isError).toBe(true);
expect(handler).toHaveBeenCalledTimes(2);
expect(messages.map(message => JSON.parse(message))).toEqual([
{ schema: "paperclip.opencode.finish-input-diagnostic/v1", stage: "offered", schemaSha256: digest },
...["string", "array", "number", "boolean"].map((continuationShape, index) => ({
schema: "paperclip.opencode.finish-input-diagnostic/v1", stage: "input_rejected",
schemaSha256: digest, callIdSha256: createHash("sha256").update(String(index + 4)).digest("hex"),
validationCategory: "continuation", rootShape: "object", continuationShape,
})),
{ schema: "paperclip.opencode.finish-input-diagnostic/v1", stage: "input_rejected",
schemaSha256: digest, callIdSha256: createHash("sha256").update("8").digest("hex"),
validationCategory: "other", rootShape: "object", continuationShape: "null" },
]);
expect(JSON.stringify(messages)).not.toMatch(/PRIVATE_INPUT_VALUE|PRIVATE_FIELD|Completed|criteria|continuation.*must|instancePath/);
});
it("reports the effective supplied schema and bounds diagnostics without admitting rejected inputs", async () => {
const messages: string[] = [], handler = vi.fn();
const inputSchema = { type: "object", required: ["continuation"], properties: { continuation: { type: "object" } } };
const bridge = await startOpenCodeMcpBridge({
tools: [{ name: "paperclip_finish", inputSchema }], handler,
onDiagnostic: message => messages.push(message),
});
bridges.push(bridge);
for (let index = 0; index < 12; index++) {
const rejected = await rpc(bridge, index, "tools/call", {
name: "paperclip_finish", arguments: index === 0 ? [] : { continuation: null },
});
expect(rejected.result.isError).toBe(true);
}
expect(handler).not.toHaveBeenCalled();
expect(messages).toHaveLength(9);
expect(JSON.parse(messages[0]!)).toMatchObject({ schemaSha256: createHash("sha256").update(JSON.stringify(inputSchema)).digest("hex") });
expect(JSON.parse(messages[1]!)).toMatchObject({ rootShape: "array", continuationShape: "omitted", validationCategory: "root" });
expect(JSON.parse(messages[2]!)).toMatchObject({ rootShape: "object", continuationShape: "null", validationCategory: "continuation" });
expect(messages.every(message => Buffer.byteLength(message) < 448)).toBe(true);
});
it("keeps diagnostics optional and nonfatal while preserving cancellation", async () => {
const aborted = vi.fn();
let started!: () => void;
const begun = new Promise<void>(resolve => { started = resolve; });
const bridge = await startOpenCodeMcpBridge({ timeoutMs: null,
onDiagnostic: () => { throw new Error("unavailable diagnostic sink"); },
handler: ({ signal }) => new Promise((_resolve, reject) => {
signal.addEventListener("abort", () => { aborted(); reject(new Error("cancelled")); });
started();
}),
});
bridges.push(bridge);
const pending = rpc(bridge, 1, "tools/call", { name: "paperclip_finish", arguments: report });
await begun;
await fetch(bridge.url, { method: "POST", headers: { Authorization: `Bearer ${bridge.secret}`, "Content-Type": "application/json" },
body: JSON.stringify({ jsonrpc: "2.0", method: "notifications/cancelled", params: { requestId: 1 } }) });
expect((await pending).result.isError).toBe(true);
expect(aborted).toHaveBeenCalledOnce();
});
@@ -1,4 +1,4 @@
import { randomBytes, timingSafeEqual } from "node:crypto";
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
import { Ajv2020, type ValidateFunction } from "ajv/dist/2020.js";
@@ -35,6 +35,7 @@ export interface OpenCodeMcpBridgeOptions {
privateToolTimeoutMs?: number;
maxBodyBytes?: number;
secret?: string;
onDiagnostic?: (message: string) => void;
}
export interface OpenCodeMcpBridge {
@@ -43,6 +44,12 @@ export interface OpenCodeMcpBridge {
close(): Promise<void>;
}
type FinishInputRejection = {
input: unknown;
callId: string;
validationCategory: "root" | "continuation" | "other";
};
const DEFAULT_TIMEOUT_MS = 30_000;
const DEFAULT_MAX_BODY_BYTES = 1_048_576;
// Native app results/pages are capped at 500 KB before reaching this bridge.
@@ -66,6 +73,25 @@ export async function startOpenCodeMcpBridge(
const privateTools = normalizeTools(options.privateTools ?? [], false);
const admittedTools = [...tools, ...privateTools.filter((candidate) => !tools.some((tool) => tool.name === candidate.name))];
const validators = compileValidators(admittedTools);
const finishSchema = admittedTools.find(tool => tool.name === PRP_COMPLETION_TOOL_NAME)?.inputSchema;
const finishSchemaSha256 = finishSchema && options.onDiagnostic
? createHash("sha256").update(JSON.stringify(finishSchema)).digest("hex") : undefined;
let rejectionDiagnostics = 0;
const diagnoseFinishInput = (rejection?: FinishInputRejection) => {
if (!finishSchemaSha256 || !options.onDiagnostic) return;
if (rejection && rejectionDiagnostics++ >= 8) return;
const diagnostic = {
schema: "paperclip.opencode.finish-input-diagnostic/v1",
stage: rejection ? "input_rejected" : "offered", schemaSha256: finishSchemaSha256,
...(rejection ? {
callIdSha256: createHash("sha256").update(rejection.callId).digest("hex"),
validationCategory: rejection.validationCategory,
rootShape: inputShape(rejection.input),
continuationShape: inputShape(isRecord(rejection.input) ? rejection.input.continuation : undefined),
} : {}),
};
try { options.onDiagnostic(JSON.stringify(diagnostic)); } catch { /* Optional diagnostics never change admission. */ }
};
const calls = new Map<string, { fingerprint: string; promise: Promise<unknown> }>();
const controllers = new Map<string, AbortController>();
const server = createServer((request, response) => {
@@ -77,6 +103,7 @@ export async function startOpenCodeMcpBridge(
calls,
controllers,
handler: options.handler,
diagnoseFinishInput,
timeoutMs: options.timeoutMs === null ? null : options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
privateToolTimeoutMs: options.privateToolTimeoutMs ?? options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
privateToolNames: new Set(privateTools.map((tool) => tool.name)),
@@ -95,6 +122,7 @@ export async function startOpenCodeMcpBridge(
await closeServer(server);
throw new Error("OpenCode MCP bridge failed to bind a loopback port");
}
diagnoseFinishInput();
return {
url: `http://127.0.0.1:${address.port}/mcp`,
secret,
@@ -105,6 +133,17 @@ export async function startOpenCodeMcpBridge(
};
}
function inputShape(input: unknown): "omitted" | "null" | "array" | "object" | "string" | "number" | "boolean" | "other" {
if (input === undefined) return "omitted";
if (input === null) return "null";
if (Array.isArray(input)) return "array";
if (typeof input === "object") return "object";
if (typeof input === "string") return "string";
if (typeof input === "number") return "number";
if (typeof input === "boolean") return "boolean";
return "other";
}
function normalizeTools(
input: readonly Readonly<Record<string, unknown>>[],
includeTerminalTools = true,
@@ -151,6 +190,7 @@ async function handleRequest(
privateToolTimeoutMs: number;
privateToolNames: ReadonlySet<string>;
maxBodyBytes: number;
diagnoseFinishInput: (rejection?: FinishInputRejection) => void;
},
): Promise<void> {
setSecurityHeaders(response);
@@ -236,6 +276,12 @@ async function handleRequest(
const args = params.arguments ?? {};
const validator = context.validators.get(tool);
if (validator === undefined || !validator(args)) {
if (tool === PRP_COMPLETION_TOOL_NAME) {
const errors = validator?.errors ?? [];
const validationCategory = errors.some(error => error.instancePath === "/continuation" || error.instancePath.startsWith("/continuation/"))
? "continuation" : errors.some(error => error.instancePath === "") ? "root" : "other";
context.diagnoseFinishInput({ input: params.arguments, callId, validationCategory });
}
const detail = validator?.errors?.map((error) => `${error.instancePath || "/"} ${error.message ?? "is invalid"}`).join("; ");
writeRpc(response, id, {
isError: true,
@@ -2340,6 +2340,7 @@ describe("OpenCodeServerDriver", () => {
const continuation = { kind, summary: "Wait for the saved task path.", idempotencyKey: "task-wait:saved" };
const reads: string[] = [];
const accepted: unknown[] = [];
const diagnostics: string[] = [];
const driver = new OpenCodeServerDriver({
model: "openrouter/deepseek/deepseek-v4-flash-0731", runtimeDirectory: root, command: fixture,
environment: { PATH: process.env.PATH, OPENROUTER_API_KEY: "fixture-key" },
@@ -2349,11 +2350,13 @@ describe("OpenCodeServerDriver", () => {
completionContinuation: eligible ? continuation : null };
},
completionFeedback: async result => { accepted.push(result); return "Accepted monitor wait."; },
onDiagnostic: message => diagnostics.push(message),
});
const session = await driver.openSession({ runId: "monitor-default", normalizedSessionId: "monitor-default", workingDirectory: workspace });
try {
await session.startTurn({ message: { role: "user", text: "monitor-default" } });
const events = await collectTurnEvents(session.events());
expect(diagnostics).toContainEqual(expect.stringContaining('"schema":"paperclip.opencode.finish-input-diagnostic/v1","stage":"offered"'));
expect(reads).toEqual(["get_task_context"]);
if (eligible) {
expect(accepted).toMatchObject([{ reportedWorkDisposition: "yielded", continuation }]);
@@ -2436,6 +2436,7 @@ async function startRuntime(input: {
const bridge = await startOpenCodeMcpBridge({
tools: input.options.dynamicTools,
handler: input.dispatch,
onDiagnostic: input.options.onDiagnostic,
...(input.options.durableToolRequests ? { timeoutMs: null } : {}),
}).catch(async (error) => {
await removeIsolatedHome(isolatedHome).catch(() => undefined);
@@ -22,6 +22,40 @@ describe("provider failure boundary", () => {
expect(acpxProviderFailure(new Error("internal runtime failure"), "claude", "startup")).toBeNull();
});
it("classifies typed ACP errors and leaves untyped ones unknown", () => {
const rejected = (code: number, message: string, data?: unknown) =>
Object.assign(new Error(message), { code, ...(data === undefined ? {} : { data }) });
// Claude ACP attaches the SDK error kind to ACP error data.
expect(acpxProviderFailure(rejected(-32603, "Internal error", { errorKind: "billing_error" }), "claude", "startup"))
.toMatchObject({ category: "quota_exhausted", providerCode: "billing_error", message: "Internal error", retryability: "requires_action" });
expect(acpxProviderFailure(rejected(-32603, "Internal error", { errorKind: "rate_limit" }), "claude", "startup"))
.toMatchObject({ category: "rate_limit", providerCode: "rate_limit" });
// A JSON provider body becomes ACP error data on the agent side.
expect(acpxProviderFailure(rejected(-32603, "Internal error",
{ type: "error", error: { type: "rate_limit_error", message: "Usage limit reached." } }), "claude", "startup"))
.toMatchObject({ category: "rate_limit", providerCode: "rate_limit_error", message: "Usage limit reached." });
// ACP's reserved authentication-required code, as Pi and Claude raise it.
const auth = "Authentication required: Bind the configured OpenRouter API credential to this Paperclip runtime.";
expect(acpxProviderFailure(rejected(-32000, auth, { authMethods: [{ id: "openrouter" }] }), "pi", "startup"))
.toEqual({ schema: "paperclip.provider-failure.v1", provider: "pi", phase: "startup", category: "authentication",
message: auth, retryability: "requires_action" });
for (const data of [{ details: "Query closed before response received" }, { errorKind: "server_error" }, { errorKind: "constructor" }]) {
expect(acpxProviderFailure(rejected(-32603, "Internal error", data), "claude", "startup"))
.toMatchObject({ category: "unknown", retryability: "unknown" });
}
});
it("keeps a plain error's own redacted message only when asked", () => {
const secret = "fixture-launch-secret";
const error = new Error(`Claude Code initialization failed for ${secret} with sk-ant-api03-fixtureprivatecredential`);
expect(acpxProviderFailure(error, "claude", "startup", { ANTHROPIC_API_KEY: secret })).toBeNull();
const failure = acpxProviderFailure(error, "claude", "startup", { ANTHROPIC_API_KEY: secret }, true);
expect(failure).toMatchObject({ provider: "anthropic", phase: "startup", category: "unknown",
message: "Claude Code initialization failed for [REDACTED] with [REDACTED]" });
expect(JSON.stringify(failure)).not.toMatch(/fixture-launch-secret|fixtureprivatecredential/);
expect(acpxProviderFailure(new Error(""), "claude", "startup", {}, true)).toBeNull();
});
it("keeps the ACPX terminal cause when the raw result exceeds its payload limit", () => {
const terminal = acpxTerminalResult({ status: "failed", error: { ...acp,
data: { ...acp.data, responseBody: "private-body".repeat(7000) } } }, "claude");
+1
View File
@@ -1,5 +1,6 @@
export * from "./configured-environment.js";
export * from "./catalog/index.js";
export { taskDocumentKeySchema } from "./protocol-actions/task-document-key-schema.js";
export { CURSOR_DISTRIBUTION_PINS } from "./drivers/acpx/generated-profiles.js";
export { KIMI_DISTRIBUTION_CLOSURE_SHA256 } from "./drivers/acpx/kimi-closure-pins.js";
export * from "./contracts/control-plane-port.js";
@@ -9909,6 +9909,24 @@ it.each(["RPC failed: Private provider explanation.", "failed to start ACPX prov
expect(failure).toHaveProperty("runnerCommandResult.result.message", message);
expect((failure as Error).message).not.toContain(providerFailure.message);
expect(JSON.stringify(failure)).not.toContain(providerFailure.message);
// Runner composes the ACPX rejection from closed codes, so its classification stays reportable.
if (message.startsWith("failed to start ACPX provider")) expect((failure as Error).message).toContain(message);
else expect((failure as Error).message).toContain('"message":"Provider request failed"');
});
it("reports a generic failure when an ACPX rejection does not match Runner's closed text", async () => {
const providerFailure = { schema: "paperclip.provider-failure.v1", provider: "anthropic", phase: "startup",
category: "unknown", message: "Private provider explanation.", retryability: "unknown" };
const message = "failed to start ACPX provider: ACPX sidecar command session.open was rejected (retryable=false, classification=session_ensure_failed) Private provider explanation.";
let failure: unknown;
try {
await waitForRunnerCommand({ type: "session.open", deadline: Date.now() + 1000, abortOnClose: true,
isClosed: () => false, throwIfFailed: () => {},
command: () => ({ status: "failed", result: { result: { code: "command_execution_failed", message, providerFailure } } }),
runnerHasExited: async () => false, failureCode: () => "startup_failed" });
} catch (error) { failure = error; }
expect((failure as Error).message).toContain('"message":"Provider request failed"');
expect((failure as Error).message).not.toContain("Private provider explanation.");
});
@@ -8114,6 +8114,14 @@ export interface RunnerCommandFailure extends Error {
readonly runnerCommandResult: unknown;
}
// Runner composes this whole ACPX rejection from closed codes; provider text
// travels only in providerFailure. Keep its classification in the reported error.
function closedAcpxRejection(message: unknown): string | null {
return typeof message === "string"
&& /^failed to start ACPX provider: ACPX sidecar command [a-z.]+ was rejected \(retryable=(?:true|false), classification=[a-z0-9_]+\)$/.test(message)
? message : null;
}
/** Shared polling path keeps startup cancellation separate from owned cleanup commands. */
export async function waitForRunnerCommand(input: {
type: string;
@@ -8135,7 +8143,8 @@ export async function waitForRunnerCommand(input: {
if (command !== undefined && command.status !== "pending") {
const detail = record(record(command.result).result);
const reportedResult = readProviderFailure(detail.providerFailure)
? { ...record(command.result), result: { code: detail.code, message: "Provider request failed" } } : command.result;
? { ...record(command.result), result: { code: detail.code,
message: closedAcpxRejection(detail.message) ?? "Provider request failed" } } : command.result;
const error = new Error(`PRP command ${input.type} ${command.status}: ${JSON.stringify(reportedResult,
(key, value) => key === "providerFailure" ? undefined : value)}`) as RunnerCommandFailure;
Object.defineProperty(error, "runnerCommandResult", { value: structuredClone(command.result), enumerable: false });
@@ -1,3 +1,5 @@
import { taskDocumentKeySchema } from "./task-document-key-schema.js";
/** Canonical definition and documentation for `list_document_revisions`. */
export const listDocumentRevisionsAction = {
"id": "list_document_revisions",
@@ -62,12 +64,7 @@ export const listDocumentRevisionsAction = {
"inputSchema": {
"type": "object",
"properties": {
"key": {
"type": "string",
"description": "Stable issue-document key.",
"minLength": 1,
"maxLength": 120
},
"key": taskDocumentKeySchema,
"limit": {
"type": "integer",
"minimum": 1,
@@ -105,10 +102,7 @@ export const listDocumentRevisionsAction = {
"inputSchema": {
"type": "object",
"properties": {
"key": {
"type": "string",
"minLength": 1
}
"key": taskDocumentKeySchema
},
"required": [
"key"
@@ -1,3 +1,5 @@
import { taskDocumentKeySchema } from "./task-document-key-schema.js";
/** Canonical definition and documentation for `read_document`. */
export const readDocumentAction = {
"id": "read_document",
@@ -62,12 +64,7 @@ export const readDocumentAction = {
"inputSchema": {
"type": "object",
"properties": {
"key": {
"type": "string",
"description": "Stable issue-document key.",
"minLength": 1,
"maxLength": 120
}
"key": taskDocumentKeySchema
},
"required": [
"key"
@@ -99,10 +96,7 @@ export const readDocumentAction = {
"inputSchema": {
"type": "object",
"properties": {
"key": {
"type": "string",
"minLength": 1
}
"key": taskDocumentKeySchema
},
"required": [
"key"
@@ -0,0 +1,9 @@
const trimWhitespace = String.raw`[\u0009-\u000d\u0020\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000\ufeff]*`;
export const taskDocumentKeySchema = {
type: "string",
description: "Stable document identifier, not a filename or path (for example reconciliation, not reconciliation.md). After trimming whitespace and lowercasing: 1–64 characters, starting with a letter or digit and containing only a–z, 0–9, _ or -. The raw input is limited to 120 characters.",
minLength: 1,
maxLength: 120,
pattern: `^${trimWhitespace}[A-Za-z0-9\\u212a][A-Za-z0-9_\\u212a-]{0,63}${trimWhitespace}$`,
} as const;
@@ -1,3 +1,5 @@
import { taskDocumentKeySchema } from "./task-document-key-schema.js";
/** Canonical definition and documentation for `write_document`. */
export const writeDocumentAction = {
"id": "write_document",
@@ -90,12 +92,7 @@ export const writeDocumentAction = {
"minLength": 1,
"maxLength": 240
},
"key": {
"type": "string",
"description": "Stable issue-document key.",
"minLength": 1,
"maxLength": 120
},
"key": taskDocumentKeySchema,
"title": {
"type": "string",
"description": "Document title.",
@@ -210,10 +207,7 @@ export const writeDocumentAction = {
"inputSchema": {
"type": "object",
"properties": {
"key": {
"type": "string",
"minLength": 1
},
"key": taskDocumentKeySchema,
"title": {
"type": "string",
"minLength": 1
@@ -55,10 +55,11 @@ if (process.argv[2] === "--pi-no-key-probe") {
const report = JSON.parse(await readFile(join(root, "report.json"), "utf8"));
assert.equal(report.closeError, null, report.closeError);
assert.match(report.error, /session\.open failed/);
assert.match(report.commandFailure?.result?.message ?? "", /retryable=false, classification=session_ensure_failed/);
assert.match(report.commandFailure?.result?.message ?? "", /retryable=false, classification=provider_authentication/);
assert.equal(report.commandFailure?.result?.providerFailure?.schema, "paperclip.provider-failure.v1");
assert.equal(report.commandFailure.result.providerFailure.provider, "pi");
assert.equal(report.commandFailure.result.providerFailure.phase, "startup");
assert.equal(report.commandFailure.result.providerFailure.category, "authentication");
assert.doesNotMatch(report.error, /timed out/);
assert.ok(report.settledMs < 60_000, `session.open took ${report.settledMs} ms`);
assert.ok(report.evidence.some(item => item.runnerExited === true && item.runnerExitCode === 0));
@@ -101,6 +101,8 @@ export interface ExecutionReconciliation {
export interface TaskInterruptionPresentation {
providerFailure?: import("../provider-failure.generated.js").ProviderFailure;
workspaceFailure?: { message: string };
/** A persisted Retry admission wait, normalized to public text by the server. */
retryWait?: { reason: string; message: string };
intentId: string | null;
responsibility: "system" | "user";
state: "working" | "retry_scheduled" | "failed" | "waiting_for_user" | "resolved";
+2 -1
View File
@@ -7,7 +7,8 @@ import type { AgentAppearance } from "../agent-appearance.js";
*/
export type TimelineActorType = "agent" | "user" | "system" | "plugin";
export type TimelineEventKind = "created" | "commented" | "approved" | "delegated" | "assigned";
/** `unassigned` records an explicit ownership clear and has no target edge. */
export type TimelineEventKind = "created" | "commented" | "approved" | "delegated" | "assigned" | "unassigned";
export type TimelineEdgeKind = "delegation" | "assignment" | "mention";
export interface WorkTimelineActor {
+15 -15
View File
@@ -426,8 +426,8 @@ importers:
specifier: ^24.0.0
version: 24.13.3
drizzle-kit:
specifier: ^0.31.10
version: 0.31.10
specifier: ^0.31.11
version: 0.31.11
tsx:
specifier: ^4.23.15
version: 4.23.15
@@ -1015,7 +1015,7 @@ importers:
version: 2.1.0(@grpc/grpc-js@1.14.5)(nice-grpc-common@2.0.4)(nice-grpc@2.1.17)
'@vercel/connect':
specifier: 2.3.3
version: 2.3.3(@chat-adapter/slack@4.39.0(patch_hash=226jj24akljccsfbw7bcxujz4u)(ai@7.0.130(zod@4.4.3))(zod@4.4.3))(@modelcontextprotocol/sdk@1.31.0(zod@4.4.3))(ai@7.0.130(zod@4.4.3))(better-auth@1.7.2(@opentelemetry/api@1.9.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(kysely@0.29.5)(pg@8.18.0)(postgres@3.4.9(patch_hash=6q4cic6qawywsyeo2spgnjamda)))(pg@8.18.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@5.0.3(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(jsdom@30.0.1(@noble/hashes@2.4.0))(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))))
version: 2.3.3(@chat-adapter/slack@4.39.0(patch_hash=226jj24akljccsfbw7bcxujz4u)(ai@7.0.130(zod@4.4.3))(zod@4.4.3))(@modelcontextprotocol/sdk@1.31.0(zod@4.4.3))(ai@7.0.130(zod@4.4.3))(better-auth@1.7.2(@opentelemetry/api@1.9.1)(drizzle-kit@0.31.11)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(kysely@0.29.5)(pg@8.18.0)(postgres@3.4.9(patch_hash=6q4cic6qawywsyeo2spgnjamda)))(pg@8.18.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@5.0.3(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(jsdom@30.0.1(@noble/hashes@2.4.0))(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))))
acorn:
specifier: 8.18.0
version: 8.18.0
@@ -1033,7 +1033,7 @@ importers:
version: 3.0.1(ajv@8.20.0)
better-auth:
specifier: 1.7.2
version: 1.7.2(@opentelemetry/api@1.9.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(kysely@0.29.5)(pg@8.18.0)(postgres@3.4.9(patch_hash=6q4cic6qawywsyeo2spgnjamda)))(pg@8.18.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@5.0.3(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(jsdom@30.0.1(@noble/hashes@2.4.0))(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)))
version: 1.7.2(@opentelemetry/api@1.9.1)(drizzle-kit@0.31.11)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(kysely@0.29.5)(pg@8.18.0)(postgres@3.4.9(patch_hash=6q4cic6qawywsyeo2spgnjamda)))(pg@8.18.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@5.0.3(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(jsdom@30.0.1(@noble/hashes@2.4.0))(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)))
chat:
specifier: 4.39.0
version: 4.39.0(ai@7.0.130(zod@4.4.3))(zod@4.4.3)
@@ -6667,8 +6667,8 @@ packages:
peerDependencies:
react: ^19.2.8
drizzle-kit@0.31.10:
resolution: {integrity: sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw==}
drizzle-kit@0.31.11:
resolution: {integrity: sha512-YCYqxTLIB2OCqf6w9/Vef13baBVbwQIPcbT4Y56AfO6B9ajZhDhD8Jkveg/hJvT/iuMloKD/IYYkyMMNhr7Kqg==}
hasBin: true
drizzle-orm@0.45.2:
@@ -7066,8 +7066,8 @@ packages:
resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==}
engines: {node: '>=10'}
get-tsconfig@4.14.0:
resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==}
get-tsconfig@4.14.3:
resolution: {integrity: sha512-++QEw4DIY7WGoukz+/+A/8dGYPT9l9yIadnmSgZ8Rjr3YVSVDipQSO9CdnJo9ePqFqUUqh+wk9uIaoiAwsiPkA==}
glob@13.0.6:
resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==}
@@ -10641,7 +10641,7 @@ snapshots:
'@esbuild-kit/esm-loader@2.6.5':
dependencies:
'@esbuild-kit/core-utils': 3.3.2
get-tsconfig: 4.14.0
get-tsconfig: 4.14.3
'@esbuild/aix-ppc64@0.25.12':
optional: true
@@ -13859,14 +13859,14 @@ snapshots:
dependencies:
execa: 5.1.1
'@vercel/connect@2.3.3(@chat-adapter/slack@4.39.0(patch_hash=226jj24akljccsfbw7bcxujz4u)(ai@7.0.130(zod@4.4.3))(zod@4.4.3))(@modelcontextprotocol/sdk@1.31.0(zod@4.4.3))(ai@7.0.130(zod@4.4.3))(better-auth@1.7.2(@opentelemetry/api@1.9.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(kysely@0.29.5)(pg@8.18.0)(postgres@3.4.9(patch_hash=6q4cic6qawywsyeo2spgnjamda)))(pg@8.18.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@5.0.3(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(jsdom@30.0.1(@noble/hashes@2.4.0))(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))))':
'@vercel/connect@2.3.3(@chat-adapter/slack@4.39.0(patch_hash=226jj24akljccsfbw7bcxujz4u)(ai@7.0.130(zod@4.4.3))(zod@4.4.3))(@modelcontextprotocol/sdk@1.31.0(zod@4.4.3))(ai@7.0.130(zod@4.4.3))(better-auth@1.7.2(@opentelemetry/api@1.9.1)(drizzle-kit@0.31.11)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(kysely@0.29.5)(pg@8.18.0)(postgres@3.4.9(patch_hash=6q4cic6qawywsyeo2spgnjamda)))(pg@8.18.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@5.0.3(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(jsdom@30.0.1(@noble/hashes@2.4.0))(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))))':
dependencies:
'@vercel/oidc': 3.8.9
optionalDependencies:
'@chat-adapter/slack': 4.39.0(patch_hash=226jj24akljccsfbw7bcxujz4u)(ai@7.0.130(zod@4.4.3))(zod@4.4.3)
'@modelcontextprotocol/sdk': 1.31.0(zod@4.4.3)
ai: 7.0.130(zod@4.4.3)
better-auth: 1.7.2(@opentelemetry/api@1.9.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(kysely@0.29.5)(pg@8.18.0)(postgres@3.4.9(patch_hash=6q4cic6qawywsyeo2spgnjamda)))(pg@8.18.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@5.0.3(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(jsdom@30.0.1(@noble/hashes@2.4.0))(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)))
better-auth: 1.7.2(@opentelemetry/api@1.9.1)(drizzle-kit@0.31.11)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(kysely@0.29.5)(pg@8.18.0)(postgres@3.4.9(patch_hash=6q4cic6qawywsyeo2spgnjamda)))(pg@8.18.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@5.0.3(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(jsdom@30.0.1(@noble/hashes@2.4.0))(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)))
'@vercel/oidc@3.2.0': {}
@@ -14143,7 +14143,7 @@ snapshots:
before-after-hook@4.0.0: {}
better-auth@1.7.2(@opentelemetry/api@1.9.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(kysely@0.29.5)(pg@8.18.0)(postgres@3.4.9(patch_hash=6q4cic6qawywsyeo2spgnjamda)))(pg@8.18.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@5.0.3(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(jsdom@30.0.1(@noble/hashes@2.4.0))(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))):
better-auth@1.7.2(@opentelemetry/api@1.9.1)(drizzle-kit@0.31.11)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(kysely@0.29.5)(pg@8.18.0)(postgres@3.4.9(patch_hash=6q4cic6qawywsyeo2spgnjamda)))(pg@8.18.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@5.0.3(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(jsdom@30.0.1(@noble/hashes@2.4.0))(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))):
dependencies:
'@better-auth/core': 1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.3)
'@better-auth/drizzle-adapter': 1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(kysely@0.29.5)(pg@8.18.0)(postgres@3.4.9(patch_hash=6q4cic6qawywsyeo2spgnjamda)))
@@ -14163,7 +14163,7 @@ snapshots:
nanostores: 1.5.3
zod: 4.4.3
optionalDependencies:
drizzle-kit: 0.31.10
drizzle-kit: 0.31.11
drizzle-orm: 0.45.2(@opentelemetry/api@1.9.1)(kysely@0.29.5)(pg@8.18.0)(postgres@3.4.9(patch_hash=6q4cic6qawywsyeo2spgnjamda))
pg: 8.18.0
react: 19.2.8
@@ -14748,7 +14748,7 @@ snapshots:
react-is: 17.0.2
tslib: 2.8.1
drizzle-kit@0.31.10:
drizzle-kit@0.31.11:
dependencies:
'@drizzle-team/brocli': 0.10.2
'@esbuild-kit/esm-loader': 2.6.5
@@ -15156,7 +15156,7 @@ snapshots:
get-stream@6.0.1: {}
get-tsconfig@4.14.0:
get-tsconfig@4.14.3:
dependencies:
resolve-pkg-maps: 1.0.0
+7 -7
View File
@@ -1,8 +1,8 @@
# Paperclip v2026.1026.0
# Paperclip v2026.1103.0
> Released: 2026-10-26
> Released: 2026-11-03
Paperclip v2026.1026.0 carries 515 commits, promoted from `2026.1023.0-beta.0`. The headline is that the Paperclip Runner is now the default for new agents: new Claude, Codex, OpenCode, and Pi agents start on the native Runner on qualified macOS and Linux hosts, existing agents get a one-click "Upgrade runner" action, and GitHub Copilot joins as a native harness. Around that: project repositories that automatically become skill sources, model-backed prompt injection prevention, agent observability to Braintrust or any OTLP endpoint, a unified Google Workspace connection, bulk edits on the Agents roster, workspaces that compact and expire themselves with encrypted restore points, and Grok Bot and Muse as experimental Runner providers. Underneath, server restarts report ready sooner, the Docker image boots from a precompiled bundle, task pages load faster, and more background services stop polling the database.
Paperclip v2026.1103.0 carries 515 commits, promoted from `2026.1023.0-beta.0`. The headline is that the Paperclip Runner is now the default for new agents: new Claude, Codex, OpenCode, and Pi agents start on the native Runner on qualified macOS and Linux hosts, existing agents get a one-click "Upgrade runner" action, and GitHub Copilot joins as a native harness. Around that: project repositories that automatically become skill sources, model-backed prompt injection prevention, agent observability to Braintrust or any OTLP endpoint, a unified Google Workspace connection, bulk edits on the Agents roster, workspaces that compact and expire themselves with encrypted restore points, and Grok Bot and Muse as experimental Runner providers. Underneath, server restarts report ready sooner, the Docker image boots from a precompiled bundle, task pages load faster, and more background services stop polling the database.
## Breaking Changes
@@ -24,7 +24,7 @@ Paperclip v2026.1026.0 carries 515 commits, promoted from `2026.1023.0-beta.0`.
- **Bulk edits on the Agents roster** — Select rows and filter by harness to change the AI connection, the default model, or both for many agents at once, with a preview and retry for failed updates; the roster's status selector is removed and paused agents show a badge instead of dimming ([#17215](https://github.com/paperclipai/paperclip/pull/17215)). The agent Overview shows who created an agent in companies with more than one person ([#16055](https://github.com/paperclipai/paperclip/pull/16055)).
- **Grok Bot and Muse, both experimental** — Grok Bot lets you invite an existing Grok Bot as an agent that receives assignments through a webhook routine and works through a dedicated agent-OAuth MCP connection, with guided setup, pairing codes, progress, questions, documents, and files flowing back into the task, and Stop and recovery controls; provider spending is not reported ([#16219](https://github.com/paperclipai/paperclip/pull/16219), [#16338](https://github.com/paperclipai/paperclip/pull/16338), [#16274](https://github.com/paperclipai/paperclip/pull/16274), [#16305](https://github.com/paperclipai/paperclip/pull/16305)). Muse pairs a personal Meta Muse through a wake-only receiver and Vault-backed OAuth with a ten-minute single-use ticket, with readiness, credential expiry, and disconnect controls in the invite dialog ([#16444](https://github.com/paperclipai/paperclip/pull/16444), [#16447](https://github.com/paperclipai/paperclip/pull/16447), [#16448](https://github.com/paperclipai/paperclip/pull/16448), [#16450](https://github.com/paperclipai/paperclip/pull/16450)).
- **Talk to an agent with Speko** — A Speko entry in the Apps catalog connects a Speko persona to an existing agent so you can talk to it by browser voice or phone from a task, with transcripts, call controls, inbound-line settings, phone callbacks, and call entries in Activity; shown while the **Chat connectors** experimental setting is on ([#15728](https://github.com/paperclipai/paperclip/pull/15728)).
- **Slack bots, refined** — Slack connection Settings are simplified around "Open Slack configuration" and a "Send test message" row, with "Use your own app" under Advanced ([#15679](https://github.com/paperclipai/paperclip/pull/15679)); plain direct messages always start or continue work even when **Require at-mention** is on for channel threads ([#16151](https://github.com/paperclipai/paperclip/pull/16151)); and the eyes reaction appears as soon as a mention is accepted ([#16491](https://github.com/paperclipai/paperclip/pull/16491)).
- **Slack bots, refined** — Slack connection Settings are simplified around "Open Slack configuration" and a "Send test message" row, with "Use your own app" under Advanced ([#15679](https://github.com/paperclipai/paperclip/pull/15679)); plain direct messages always start or continue work even when **Require at-mention** is on for channel threads ([#16151](https://github.com/paperclipai/paperclip/pull/16151)); and the eyes reaction appears as soon as a mention is accepted, including in direct messages ([#16491](https://github.com/paperclipai/paperclip/pull/16491), [#16494](https://github.com/paperclipai/paperclip/pull/16494)).
- **A sturdier GitHub review bot** — The bot keeps one main PR comment updated across pushes, ignores messages addressed to other bots, and marks stopped work accurately ([#16901](https://github.com/paperclipai/paperclip/pull/16901)); summary comments are headed with the bot's display name so several bots on one PR are distinguishable ([#16212](https://github.com/paperclipai/paperclip/pull/16212)); publication retries through GitHub 503/429 with bounded backoff and survives restarts without duplicate comments or checks ([#16284](https://github.com/paperclipai/paperclip/pull/16284), [#16883](https://github.com/paperclipai/paperclip/pull/16883)); GitHub bot tools get the gateway's 60-second maximum so `submit_review` no longer times out ([#16275](https://github.com/paperclipai/paperclip/pull/16275)); and settings show a "Reconnect GitHub" notice with reconnect-the-saved-App as the first option ([#16856](https://github.com/paperclipai/paperclip/pull/16856)).
- **More apps in the catalog** — Daytona, with personal or shared API-key credentials and 274 generated actions for sandboxes, snapshots, volumes, files, Git, and commands ([#16483](https://github.com/paperclipai/paperclip/pull/16483)); Unikraft Cloud, with actions generated from its OpenAPI specs and a per-connection default metro ([#17139](https://github.com/paperclipai/paperclip/pull/17139)); and Ditto ([#16472](https://github.com/paperclipai/paperclip/pull/16472)). A provider card with several connections collapses them behind an "N connections" row ([#16097](https://github.com/paperclipai/paperclip/pull/16097)).
- **Agent commentary in Audit** — The complaints and suggestions agents file about their tooling now have a home: Audit gains a Commentary tab with agent and type filters, text search, paging, and shareable links ([#16884](https://github.com/paperclipai/paperclip/pull/16884)), readable by every human role through a new `commentary:view` permission ([#16903](https://github.com/paperclipai/paperclip/pull/16903)).
@@ -32,11 +32,11 @@ Paperclip v2026.1026.0 carries 515 commits, promoted from `2026.1023.0-beta.0`.
## Fixes
- **Workspaces and sandboxes** — A remote sandbox whose run failed before export, whose lease was cleaned up with unexported work, or whose task was cancelled before reconstruction is kept with its files behind an explicit repair decision instead of being deleted by cleanup ([#16397](https://github.com/paperclipai/paperclip/pull/16397), [#16583](https://github.com/paperclipai/paperclip/pull/16583), [#16700](https://github.com/paperclipai/paperclip/pull/16700)); a task whose sandbox was destroyed before export offers a board-only "Continue from saved sources" action ([#16690](https://github.com/paperclipai/paperclip/pull/16690)); files left in a stopped run's sandbox are recovered automatically and saved follow-ups delivered once ([#16906](https://github.com/paperclipai/paperclip/pull/16906)); a shutdown-time workspace export keeps its ownership through the finalizer drain ([#16843](https://github.com/paperclipai/paperclip/pull/16843)); a long copy-back no longer loses its lock to PostgreSQL's idle-transaction timeout ([#16340](https://github.com/paperclipai/paperclip/pull/16340), [#16956](https://github.com/paperclipai/paperclip/pull/16956)); resuming a stopped reusable sandbox honors the environment's startup timeout instead of a fixed 60 seconds ([#16802](https://github.com/paperclipai/paperclip/pull/16802)); a single timed-out health check no longer replaces a remote Runner ([#16835](https://github.com/paperclipai/paperclip/pull/16835)); runs on repositories with thousands of historical workspaces no longer sit in "preparing" for hours ([#16372](https://github.com/paperclipai/paperclip/pull/16372), [#16334](https://github.com/paperclipai/paperclip/pull/16334), [#16342](https://github.com/paperclipai/paperclip/pull/16342), [#16963](https://github.com/paperclipai/paperclip/pull/16963)); a shared checkout no longer stays blocked by a reader whose identity is gone ([#16928](https://github.com/paperclipai/paperclip/pull/16928)) and a workspace blocked by another task's restricted worktree gets a clear configuration notice instead of an opaque 403 ([#16545](https://github.com/paperclipai/paperclip/pull/16545), [#16895](https://github.com/paperclipai/paperclip/pull/16895)); a child task inheriting a busy shared workspace waits instead of failing with a misleading secrets error ([#16371](https://github.com/paperclipai/paperclip/pull/16371)); and creating a task with the isolation selector hidden no longer fails setup ([#16220](https://github.com/paperclipai/paperclip/pull/16220)).
- **Runs, recovery, and Retry** — Tasks held behind a retained-workspace or failed-run hold get one reliable task-level Retry that preserves files, settles accepted results, or starts an independent workspace ([#16965](https://github.com/paperclipai/paperclip/pull/16965), [#17053](https://github.com/paperclipai/paperclip/pull/17053), [#16894](https://github.com/paperclipai/paperclip/pull/16894), [#16104](https://github.com/paperclipai/paperclip/pull/16104)); Stop prevents automatic continuation until you resume ([#17105](https://github.com/paperclipai/paperclip/pull/17105), [#14180](https://github.com/paperclipai/paperclip/pull/14180)); a server restart no longer strands native runs on permanent holds, repeats saved completions, or overwrites operator stops set in the meantime ([#16349](https://github.com/paperclipai/paperclip/pull/16349), [#16962](https://github.com/paperclipai/paperclip/pull/16962), [#16403](https://github.com/paperclipai/paperclip/pull/16403), [#15824](https://github.com/paperclipai/paperclip/pull/15824)); legacy runs killed or lease-lost before dispatch are retried instead of settled as failed ([#16355](https://github.com/paperclipai/paperclip/pull/16355), [#16358](https://github.com/paperclipai/paperclip/pull/16358)), a slow completion no longer fails a live controller ([#16622](https://github.com/paperclipai/paperclip/pull/16622)), and slow unrelated queries no longer cost legacy runs their lease ([#17032](https://github.com/paperclipai/paperclip/pull/17032)); a normal comment on an in-progress task no longer cancels its running retry ([#16855](https://github.com/paperclipai/paperclip/pull/16855)); messages for paused agents or for an agent other than the task owner are delivered once instead of being failed or deferred forever ([#16679](https://github.com/paperclipai/paperclip/pull/16679), [#16673](https://github.com/paperclipai/paperclip/pull/16673)); an agent stuck marked "running" is repaired after a five-minute grace period ([#16791](https://github.com/paperclipai/paperclip/pull/16791)); a Claude (ACP) run with a stalled tool call fails after 30 minutes instead of running to the four-hour timeout, and MCP calls get a 15-minute timeout ([#16671](https://github.com/paperclipai/paperclip/pull/16671), [#16360](https://github.com/paperclipai/paperclip/pull/16360)); a max-turns exhaustion is no longer misclassified as a missing model ([#13885](https://github.com/paperclipai/paperclip/pull/13885)); and a Codex "model at capacity" reply is retried as transient ([#16407](https://github.com/paperclipai/paperclip/pull/16407)).
- **Runs, recovery, and Retry** — Tasks held behind a retained-workspace or failed-run hold get one reliable task-level Retry that preserves files, settles accepted results, or starts an independent workspace ([#16965](https://github.com/paperclipai/paperclip/pull/16965), [#17053](https://github.com/paperclipai/paperclip/pull/17053), [#16894](https://github.com/paperclipai/paperclip/pull/16894), [#16104](https://github.com/paperclipai/paperclip/pull/16104)); Stop prevents automatic continuation until you resume ([#17105](https://github.com/paperclipai/paperclip/pull/17105), [#14180](https://github.com/paperclipai/paperclip/pull/14180)); a server restart no longer strands native runs on permanent holds, repeats saved completions, or overwrites operator stops set in the meantime ([#16349](https://github.com/paperclipai/paperclip/pull/16349), [#16962](https://github.com/paperclipai/paperclip/pull/16962), [#16403](https://github.com/paperclipai/paperclip/pull/16403), [#15824](https://github.com/paperclipai/paperclip/pull/15824)); legacy runs killed or lease-lost before dispatch are retried instead of settled as failed ([#16355](https://github.com/paperclipai/paperclip/pull/16355), [#16358](https://github.com/paperclipai/paperclip/pull/16358)), a slow completion no longer fails a live controller ([#16622](https://github.com/paperclipai/paperclip/pull/16622)), and slow unrelated queries no longer cost legacy runs their lease ([#17032](https://github.com/paperclipai/paperclip/pull/17032)); a normal comment on an in-progress task no longer cancels its running retry ([#16855](https://github.com/paperclipai/paperclip/pull/16855)), and stopping a queued run before it starts keeps its saved follow-up instead of running it anyway ([#16357](https://github.com/paperclipai/paperclip/pull/16357)); messages for paused agents or for an agent other than the task owner are delivered once instead of being failed or deferred forever ([#16679](https://github.com/paperclipai/paperclip/pull/16679), [#16673](https://github.com/paperclipai/paperclip/pull/16673)); an agent stuck marked "running" is repaired after a five-minute grace period ([#16791](https://github.com/paperclipai/paperclip/pull/16791)); a Claude (ACP) run with a stalled tool call fails after 30 minutes instead of running to the four-hour timeout, and MCP calls get a 15-minute timeout ([#16671](https://github.com/paperclipai/paperclip/pull/16671), [#16360](https://github.com/paperclipai/paperclip/pull/16360)); a max-turns exhaustion is no longer misclassified as a missing model ([#13885](https://github.com/paperclipai/paperclip/pull/13885)); and a Codex "model at capacity" reply is retried as transient ([#16407](https://github.com/paperclipai/paperclip/pull/16407)).
- **Chat and task UI** — Dismissing a request above the composer clears the badge, survives reload, and no longer blocks Retry ([#17218](https://github.com/paperclipai/paperclip/pull/17218)); a message to a Runner agent no longer briefly swaps its avatar for a generic "Working" row ([#16989](https://github.com/paperclipai/paperclip/pull/16989)); a consumed follow-up leaves the pending queue ([#16317](https://github.com/paperclipai/paperclip/pull/16317)); the final reply of a worktree task is no longer replaced by the "workspace ready" notice ([#16400](https://github.com/paperclipai/paperclip/pull/16400)); a file attached before the first Agent Chat message survives task creation ([#16896](https://github.com/paperclipai/paperclip/pull/16896)); task toasts in a multi-user company appear only for people involved in the task ([#16153](https://github.com/paperclipai/paperclip/pull/16153)); loading a task list no longer adds unrelated finished tasks to your inbox ([#16063](https://github.com/paperclipai/paperclip/pull/16063)); replies default to the latest agent that assigned you the task ([#15594](https://github.com/paperclipai/paperclip/pull/15594)); side panel tabs keep a steady width ([#17068](https://github.com/paperclipai/paperclip/pull/17068), [#14949](https://github.com/paperclipai/paperclip/pull/14949)); Markdown editors no longer show unsaved changes on load ([#13915](https://github.com/paperclipai/paperclip/pull/13915)); and a Slack task that yields to a scheduled continuation returns its result to the original thread ([#17045](https://github.com/paperclipai/paperclip/pull/17045)).
- **Costs and budgets** — A budget at its warning threshold renders as a warning instead of a false "AGENT HARD STOP" banner ([#16201](https://github.com/paperclipai/paperclip/pull/16201)), and budget checks no longer scale with the number of unrelated idle agents in the company ([#17046](https://github.com/paperclipai/paperclip/pull/17046)).
- **Connections and MCP** — One failed remote MCP call no longer makes every sibling tool on that connection unavailable ([#16890](https://github.com/paperclipai/paperclip/pull/16890)); a provider 403 on one chat operation no longer disables the whole Slack, GitHub, or Discord connection ([#16847](https://github.com/paperclipai/paperclip/pull/16847), [#16138](https://github.com/paperclipai/paperclip/pull/16138)); GitHub and Slack channel tools stay available on later runs of the same task ([#16806](https://github.com/paperclipai/paperclip/pull/16806)); several GitHub review-thread replies each get their own follow-up run ([#17054](https://github.com/paperclipai/paperclip/pull/17054)) and identical tool descriptors are listed once ([#16281](https://github.com/paperclipai/paperclip/pull/16281)); a large MCP catalog refresh no longer lists a tool twice or deadlocks with an OAuth callback ([#16602](https://github.com/paperclipai/paperclip/pull/16602)); assigned tool results over about 500 KB return a bounded reference and a `read_tool_result` tool instead of hanging ([#16980](https://github.com/paperclipai/paperclip/pull/16980)); legacy Codex shell commands can see explicitly assigned service secrets ([#17216](https://github.com/paperclipai/paperclip/pull/17216)); native-runner `call_api` follows the route's own permissions without an extra denylist ([#16040](https://github.com/paperclipai/paperclip/pull/16040)); and sandboxed agents can read run history, attachments, skill files, and recovery records and send the bridge token as `X-API-Key` ([#16575](https://github.com/paperclipai/paperclip/pull/16575), [#16892](https://github.com/paperclipai/paperclip/pull/16892), [#12785](https://github.com/paperclipai/paperclip/pull/12785)).
- **Adapters and CLI** — An unlimited Claude run no longer fails its CLI probe after one second ([#15329](https://github.com/paperclipai/paperclip/pull/15329)); concurrent remote Claude runs of one agent no longer break each other's skill staging ([#17061](https://github.com/paperclipai/paperclip/pull/17061)); an isolated Claude task with assigned MCP tools starts ([#17149](https://github.com/paperclipai/paperclip/pull/17149)); a Claude (ACP) server install no longer fails because the ACP bridge was missing from the server package ([#15912](https://github.com/paperclipai/paperclip/pull/15912)); a Runner OpenCode turn survives oversized SSE events ([#16401](https://github.com/paperclipai/paperclip/pull/16401)) and uses the run's managed GitHub credentials in shell commands ([#16488](https://github.com/paperclipai/paperclip/pull/16488)); Hermes calls back at the adapter's configured API URL ([#9425](https://github.com/paperclipai/paperclip/pull/9425)); `pi_local` run logs are about 150× smaller ([#10249](https://github.com/paperclipai/paperclip/pull/10249)); `paperclipai install` and `update` work with npm 12 and Node 26 ([#14000](https://github.com/paperclipai/paperclip/pull/14000)); `paperclipai service install` no longer writes a service that crash-loops on a missing managed shim ([#13985](https://github.com/paperclipai/paperclip/pull/13985)); and the CLI browser login honors rate-limit `Retry-After` headers ([#16482](https://github.com/paperclipai/paperclip/pull/16482)).
- **Adapters and CLI** — An unlimited Claude run no longer fails its CLI probe after one second ([#15329](https://github.com/paperclipai/paperclip/pull/15329)); concurrent remote Claude runs of one agent no longer break each other's skill staging ([#17061](https://github.com/paperclipai/paperclip/pull/17061)); an isolated Claude task with assigned MCP tools starts ([#17149](https://github.com/paperclipai/paperclip/pull/17149)); a sandbox whose preinstalled Claude CLI is older than the selected model needs gets the required version installed privately during setup instead of failing the version check ([#17259](https://github.com/paperclipai/paperclip/pull/17259)); a Claude (ACP) server install no longer fails because the ACP bridge was missing from the server package ([#15912](https://github.com/paperclipai/paperclip/pull/15912)); a Runner OpenCode turn survives oversized SSE events ([#16401](https://github.com/paperclipai/paperclip/pull/16401)) and uses the run's managed GitHub credentials in shell commands ([#16488](https://github.com/paperclipai/paperclip/pull/16488)); Hermes calls back at the adapter's configured API URL ([#9425](https://github.com/paperclipai/paperclip/pull/9425)); `pi_local` run logs are about 150× smaller ([#10249](https://github.com/paperclipai/paperclip/pull/10249)); `paperclipai install` and `update` work with npm 12 and Node 26 ([#14000](https://github.com/paperclipai/paperclip/pull/14000)); `paperclipai service install` no longer writes a service that crash-loops on a missing managed shim ([#13985](https://github.com/paperclipai/paperclip/pull/13985)); and the CLI browser login honors rate-limit `Retry-After` headers ([#16482](https://github.com/paperclipai/paperclip/pull/16482)).
- **Server and Docker** — Startup no longer fails on legacy `SQL_ASCII` databases ([#16958](https://github.com/paperclipai/paperclip/pull/16958)) or crashes on a scheme-less `publicBaseUrl` ([#13727](https://github.com/paperclipai/paperclip/pull/13727)); a server behind a reverse proxy with slow PTR lookups no longer stops responding for minutes after upgrading ([#16333](https://github.com/paperclipai/paperclip/pull/16333), [#16335](https://github.com/paperclipai/paperclip/pull/16335)); one dropped pooled connection no longer fails permission checks, workspace readiness, or the reaper ([#16857](https://github.com/paperclipai/paperclip/pull/16857), [#16859](https://github.com/paperclipai/paperclip/pull/16859)); Docker boots with many files on the data volume or a non-default `USER_UID`/`USER_GID` no longer walk the whole volume ([#15992](https://github.com/paperclipai/paperclip/pull/15992), [#16475](https://github.com/paperclipai/paperclip/pull/16475)); both shipped Compose files forward `PAPERCLIP_TOOL_ACTION_SIGNING_SECRET` ([#15202](https://github.com/paperclipai/paperclip/pull/15202)); a Stop that outlasts sync-back returns a retryable 409 instead of a 500 ([#16032](https://github.com/paperclipai/paperclip/pull/16032)); concurrent task title edits no longer 500 ([#13898](https://github.com/paperclipai/paperclip/pull/13898)); one malformed comment ID no longer blocks a task's queued wakes ([#13912](https://github.com/paperclipai/paperclip/pull/13912)); watchdog wakes deliver the operator's instructions ([#13771](https://github.com/paperclipai/paperclip/pull/13771)); the child-task cap counts only open children ([#11542](https://github.com/paperclipai/paperclip/pull/11542)); a plain-heartbeat agent can comment on tasks again ([#12621](https://github.com/paperclipai/paperclip/pull/12621)); a human handoff from a legacy run no longer gets cancelled by a slow restore ([#16082](https://github.com/paperclipai/paperclip/pull/16082)); and feedback exports keep public identifiers readable while still masking real tokens ([#16168](https://github.com/paperclipai/paperclip/pull/16168)).
## Improvements
@@ -69,6 +69,6 @@ Paperclip v2026.1026.0 carries 515 commits, promoted from `2026.1023.0-beta.0`.
## Contributors
This release has 515 commits from 26 contributors. Thank you to everyone who contributed to this release!
This release has 515 commits from 27 contributors. Thank you to everyone who contributed to this release!
@aashish254, @aronprins, @dylanroy, @harjothkhara, @lorenzozanee, @lucktastic, @MindSyncHub, @naeemakhtar110, @phorel, @Prompt-Surfer, @raviy06, @Sergio-LPA, @serhiq, @simberthon, @SkilLab-Tech, @superbiche, @TaoXieSZ, @tiangao88, @Waseemilyas, @xiehuanyi, @youngjk
+2 -1
View File
@@ -86,10 +86,11 @@ try {
settledMs = Math.round(performance.now() - started);
await bundle.transport.close();
assert.match(failure ?? '', /session\.open failed/);
assert.match(commandFailure?.result?.message ?? '', /retryable=false, classification=session_ensure_failed/);
assert.match(commandFailure?.result?.message ?? '', /retryable=false, classification=provider_authentication/);
assert.equal(commandFailure?.result?.providerFailure?.schema, 'paperclip.provider-failure.v1');
assert.equal(commandFailure.result.providerFailure.provider, 'pi');
assert.equal(commandFailure.result.providerFailure.phase, 'startup');
assert.equal(commandFailure.result.providerFailure.category, 'authentication');
assert.doesNotMatch(failure, /timed out/);
assert.ok(settledMs < 60_000, `Pi admission took ${settledMs} ms`);
assert.ok(evidence.some(item => item.runnerExited === true && item.runnerExitCode === 0));
+30 -6
View File
@@ -11,6 +11,7 @@
// cycle the largest import cycle, reading `below` as declarations
// above files that depend on the cycle, reading both as declarations
// tests test files, reading all three as declarations
// test-tree-N src/__tests__, in shards, reading the source layers as declarations
//
// The layers come from the import graph on every run, so nothing has to move
// and no list can go stale. The import scan does not have to be perfect: if it
@@ -22,21 +23,26 @@
//
// Projects outside the server read the source layers as declarations through a
// `tsconfig.layers.json` that references tsconfig.declarations.json in the output
// directory. --declarations-only builds those layers and skips the tests layer.
// directory. --declarations-only builds those layers and skips the test layers.
//
// Usage: node scripts/typecheck-layers.mjs [--declarations-only]
import { mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { crc32 } from "node:zlib";
import { runTsc } from "../../scripts/run-tsc.mjs";
const IMPORT_SPECIFIER = /(?:from\s+|import\s*\(\s*|import\s+)["'](\.[^"']+)["']/g;
const OUTPUT_DIRECTORY = path.join("node_modules", ".cache", "paperclip-typecheck");
// One program over this tree peaks at 3.9 GiB on one thread. Three peak at 2.5 GiB.
const TEST_TREE_SHARDS = 3;
export const isTestFile = (file) => /\.test\.tsx?$/.test(file) || file.split("/").includes("__tests__");
// tsconfig.json excludes this tree. A test reaches its helpers through imports.
const isExcludedFromProgram = (file) => file.startsWith("src/__tests__/");
// tsconfig.json excludes this tree, so the test-tree layers check it.
const isTestTreeFile = (file) => file.startsWith("src/__tests__/");
// Test layers emit nothing, so other projects cannot read them as declarations.
const emitsDeclarations = (layer) => layer.name !== "tests" && !layer.testTree;
export function listSourceFiles(serverRoot) {
const files = [];
@@ -174,7 +180,12 @@ export function planLayers(files) {
const dependents = reachableFrom(cycle, reversed);
const above = [...dependents].filter((file) => !cycle.has(file));
const below = [...graph.keys()].filter((file) => !dependents.has(file));
const tests = [...candidates.keys()].filter((file) => isTestFile(file) && !isExcludedFromProgram(file));
const tests = [...candidates.keys()].filter((file) => isTestFile(file) && !isTestTreeFile(file));
// A hash keeps each file in its shard when other files are added or removed.
const testTree = Array.from({ length: TEST_TREE_SHARDS }, () => []);
for (const file of candidates.keys()) {
if (isTestTreeFile(file)) testTree[crc32(file) % TEST_TREE_SHARDS].push(file);
}
const layer = (name, members, references) => {
const sorted = [...members].sort();
@@ -186,6 +197,10 @@ export function planLayers(files) {
layer("cycle", cycle, ["below"]),
layer("above", above, ["below", "cycle"]),
layer("tests", tests, ["below", "cycle", "above"]),
...testTree.map((shard, index) => ({
...layer(`test-tree-${index}`, shard, ["below", "cycle", "above"]),
testTree: true,
})),
].filter((candidate) => candidate.sourceFiles.length > 0);
}
@@ -195,6 +210,15 @@ export function layerConfig(layer, { emptyLayers = new Set() } = {}) {
const references = layer.references
.filter((name) => !emptyLayers.has(name))
.map((name) => ({ path: `./tsconfig.${name}.json` }));
// Editors read the same options from this file.
if (layer.testTree) {
return {
extends: fromOutput("src/__tests__/tsconfig.json"),
files: layer.files.map(fromOutput),
include: [],
references,
};
}
const output =
layer.name === "tests"
? { composite: false, declaration: false, declarationMap: false, sourceMap: false, noEmit: true }
@@ -222,7 +246,7 @@ export function declarationsConfig(layers) {
compilerOptions: { composite: true },
files: [],
references: layers
.filter((layer) => layer.name !== "tests")
.filter(emitsDeclarations)
.map((layer) => ({ path: `./tsconfig.${layer.name}.json` })),
};
}
@@ -259,7 +283,7 @@ export function typecheckLayers({
let exitCode = 0;
for (const layer of layers) {
if (declarationsOnly && layer.name === "tests") continue;
if (declarationsOnly && !emitsDeclarations(layer)) continue;
log(`typecheck:layers: ${layer.name} (${layer.sourceFiles.length} files)`);
const config = path.join(OUTPUT_DIRECTORY, `tsconfig.${layer.name}.json`);
const { status, signal } = run(["-p", config], { cwd: serverRoot });
+102 -6
View File
@@ -76,7 +76,7 @@ describe("planLayers", () => {
for (const layer of layers) expect(layer.files).toContain("src/types/ambient.d.ts");
});
it("keeps test support files in the test layer and leaves src/__tests__ to imports", () => {
it("keeps test support files in the test layer and shards src/__tests__ by a stable hash", () => {
const layers = planLayers(
new Map([
["src/a.ts", ""],
@@ -88,6 +88,25 @@ describe("planLayers", () => {
expect(sourcesOf(layers, "below")).toEqual(["src/a.ts"]);
expect(sourcesOf(layers, "tests")).toEqual(["src/services/__tests__/fixtures.ts"]);
const shards = layers.filter((layer) => layer.testTree);
expect(shards.flatMap((layer) => layer.sourceFiles).sort()).toEqual([
"src/__tests__/big.test.ts",
"src/__tests__/helpers/db.ts",
]);
for (const shard of shards) expect(shard.references).toEqual(["below", "cycle", "above"]);
const withMore = planLayers(
new Map([
["src/a.ts", ""],
["src/__tests__/helpers/db.ts", ""],
["src/__tests__/big.test.ts", ""],
["src/__tests__/added-1.test.ts", ""],
["src/__tests__/added-2.test.ts", ""],
]),
);
for (const shard of shards) {
for (const file of shard.sourceFiles) expect(sourcesOf(withMore, shard.name)).toContain(file);
}
});
it("lists imported JSON in the layer that imports it", () => {
@@ -120,14 +139,14 @@ describe("planLayers", () => {
const planned = layers.flatMap((layer) => layer.sourceFiles);
expect(new Set(planned).size).toBe(planned.length);
expect(planned.sort()).toEqual(
sourceFiles.filter((file) => !file.endsWith(".d.ts") && !file.startsWith("src/__tests__/")).sort(),
sourceFiles.filter((file) => !file.endsWith(".d.ts")).sort(),
);
const position = new Map(
layers.flatMap((layer, index) => layer.sourceFiles.map((file) => [file, index])),
);
const imports = readImports(new Map([...files].filter(([file]) => !file.endsWith(".d.ts"))));
for (const layer of layers.filter((candidate) => candidate.name !== "tests")) {
for (const layer of layers.filter((candidate) => candidate.name !== "tests" && !candidate.testTree)) {
for (const file of layer.sourceFiles) {
for (const target of imports.get(file).modules) {
if (position.has(target)) expect(position.get(target)).toBeLessThanOrEqual(position.get(file));
@@ -160,9 +179,37 @@ describe("layerConfig", () => {
});
});
describe("layerConfig for the test tree", () => {
it("reads the options that editors read and emits nothing", () => {
const config = layerConfig({
name: "test-tree-0",
files: ["src/__tests__/a.test.ts"],
references: ["below"],
testTree: true,
});
expect(config.extends).toBe("../../../src/__tests__/tsconfig.json");
expect(config.compilerOptions).toBeUndefined();
expect(config.files).toEqual(["../../../src/__tests__/a.test.ts"]);
expect(config.references).toEqual([{ path: "./tsconfig.below.json" }]);
});
it("has a real tests tsconfig that cannot emit and sees the ambient types", () => {
const config = JSON.parse(readFileSync(path.join(serverRoot, "src/__tests__/tsconfig.json"), "utf8"));
expect(config.compilerOptions).toMatchObject({ noEmit: true, rootDir: "../../.." });
expect(config.include).toContain("../types/*.d.ts");
});
});
describe("declarationsConfig", () => {
it("references the source layers that exist, and not tests", () => {
const config = declarationsConfig([{ name: "below" }, { name: "above" }, { name: "tests" }]);
it("references the source layers that exist, and no test layer", () => {
const config = declarationsConfig([
{ name: "below" },
{ name: "above" },
{ name: "tests" },
{ name: "test-tree-0", testTree: true },
]);
expect(config.compilerOptions.composite).toBe(true);
expect(config.files).toEqual([]);
@@ -213,8 +260,10 @@ describe("typecheckLayers", () => {
expect(JSON.parse(readFileSync(path.join(output, "package.json"), "utf8"))).toEqual({ type: "module" });
});
it("skips the tests layer when only declarations are needed", () => {
it("skips the test layers when only declarations are needed", () => {
const root = serverFixture();
mkdirSync(path.join(root, "src", "__tests__"));
writeFileSync(path.join(root, "src", "__tests__", "route.test.ts"), `import "../route.js";`);
const configs = [];
const status = typecheckLayers({
@@ -405,6 +454,53 @@ describe("typecheckLayers with the real compiler", () => {
expect(output).toContain("leaf");
}, 60_000);
function addTestTree(root, source) {
mkdirSync(path.join(root, "src", "__tests__"), { recursive: true });
writeFileSync(
path.join(root, "src", "__tests__", "tsconfig.json"),
`${JSON.stringify({
extends: "../../tsconfig.json",
compilerOptions: { noEmit: true, rootDir: "../.." },
include: ["."],
})}\n`,
);
writeFileSync(path.join(root, "src", "__tests__", "tree.test.ts"), source);
}
it("checks the test tree through declarations", () => {
const root = compilerFixture();
addTestTree(root, `import { route } from "../route.js";\nexport const checked: number = route();\n`);
const { status, output } = check(root);
expect(output).toBe("");
expect(status).toBe(0);
}, 60_000);
it("fails on a type error in the test tree", () => {
const root = compilerFixture();
addTestTree(root, `import { route } from "../route.js";\nexport const checked: string = route();\n`);
const { status, output } = check(root);
expect(status).not.toBe(0);
expect(output).toMatch(/tree\.test\.ts\(2,\d+\): error TS2322/);
}, 60_000);
it("fails when the test-tree tsconfig is invalid", () => {
const root = compilerFixture();
addTestTree(root, `export const checked = 1;\n`);
writeFileSync(
path.join(root, "src", "__tests__", "tsconfig.json"),
`${JSON.stringify({ extends: "../../tsconfig.json", compilerOptions: { noEmit: true, rootDir: "../..", target: "not-a-target" } })}\n`,
);
const { status, output } = check(root);
expect(status).not.toBe(0);
expect(output).toMatch(/error TS\d+/);
}, 60_000);
it("reports a type change through declarations in the test layer", () => {
const root = compilerFixture();
writeFileSync(
+39
View File
@@ -0,0 +1,39 @@
// Server tests reach Daytona plugin source, but the root install does not install
// that plugin's dependencies. Its own project checks that source against the real
// package. The server tests only need these names to resolve. Async calls return a
// Promise because a bare `any` makes the plugin's generic helpers infer `unknown`.
type AsyncMembers = { [member: string]: (...args: any[]) => Promise<any> };
export declare class Daytona {
constructor(config?: DaytonaConfig);
snapshot: AsyncMembers;
get(...args: any[]): Promise<any>;
create(...args: any[]): Promise<any>;
list(...args: any[]): Promise<any>;
[member: string]: any;
}
export declare class DaytonaNotFoundError extends Error {}
export declare class DaytonaTimeoutError extends Error {}
export type CreateSandboxBaseParams = any;
export type CreateSandboxFromImageParams = any;
export type CreateSandboxFromSnapshotParams = any;
export type DaytonaConfig = any;
export type FileDownloadRequest = any;
export type FileDownloadResponse = any;
export type FileUpload = any;
export type Resources = any;
export interface Sandbox {
fs: { downloadFiles(...args: any[]): Promise<any[]>; [member: string]: (...args: any[]) => Promise<any> };
process: {
createPty: (...args: any[]) => Promise<any>;
executeCommand(...args: any[]): Promise<{ result?: string; artifacts?: { stdout?: string }; exitCode?: number }>;
getSessionCommandLogs(
sessionId: string,
commandId: string,
onStdout?: (chunk: string) => void,
onStderr?: (chunk: string) => void,
): Promise<any>;
[member: string]: (...args: any[]) => Promise<any>;
};
[member: string]: any;
}
@@ -201,7 +201,7 @@ describeEmbeddedPostgres("access routes permissions upgrade compatibility", () =
companyId: company.id,
applicationKey: `route-app-${randomUUID()}`,
name: "Route personal app",
type: "mcp",
type: "mcp_http",
status: "active",
}).returning().then((rows) => rows[0]!);
const connection = await db.insert(toolConnections).values({
+7 -7
View File
@@ -272,7 +272,7 @@ describeEmbeddedPostgres("access service", () => {
companyId: company.id,
applicationKey: `instance-access-${randomUUID()}`,
name: "Instance access app",
type: "mcp",
type: "mcp_http",
status: "active",
}).returning().then((rows) => rows[0]!);
const connection = await db.insert(toolConnections).values({
@@ -333,7 +333,7 @@ describeEmbeddedPostgres("access service", () => {
companyId: company.id,
applicationKey: `app-${randomUUID()}`,
name: "Personal mail",
type: "mcp",
type: "mcp_http",
status: "active",
}).returning().then((rows) => rows[0]!);
const connection = await db.insert(toolConnections).values({
@@ -428,7 +428,7 @@ describeEmbeddedPostgres("access service", () => {
companyId: company.id,
applicationKey: `shared-personal-${randomUUID()}`,
name: "Shared personal app",
type: "mcp",
type: "mcp_http",
status: "active",
}).returning().then((rows) => rows[0]!);
const connection = await db.insert(toolConnections).values({
@@ -505,7 +505,7 @@ describeEmbeddedPostgres("access service", () => {
companyId: company.id,
applicationKey: `mixed-${randomUUID()}`,
name: "Mixed credential app",
type: "mcp",
type: "mcp_http",
status: "active",
}).returning().then((rows) => rows[0]!);
const definition = await db.insert(userSecretDefinitions).values({
@@ -595,7 +595,7 @@ describeEmbeddedPostgres("access service", () => {
companyId: company.id,
applicationKey: `shared-org-${randomUUID()}`,
name: "Shared organization app",
type: "mcp",
type: "mcp_http",
status: "active",
}).returning().then((rows) => rows[0]!);
const connection = await db.insert(toolConnections).values({
@@ -692,7 +692,7 @@ describeEmbeddedPostgres("access service", () => {
companyId: company.id,
applicationKey: `app-${randomUUID()}`,
name: "Personal mail",
type: "mcp",
type: "mcp_http",
status: "active",
}).returning().then((rows) => rows[0]!);
const connection = await db.insert(toolConnections).values({
@@ -754,7 +754,7 @@ describeEmbeddedPostgres("access service", () => {
companyId: company.id,
applicationKey: `app-${randomUUID()}`,
name: "Personal shared credentials",
type: "mcp",
type: "mcp_http",
status: "active",
}).returning().then((rows) => rows[0]!);
const definitions = await db.insert(userSecretDefinitions).values([
@@ -75,7 +75,7 @@ describe("resolveResponsibleUserIdForActivity", () => {
});
it("prefers the heartbeat run responsible user", async () => {
const db = createReader(new Map([
const db = createReader(new Map<unknown, Array<Record<string, unknown>>>([
[heartbeatRuns, [{ responsibleUserId: "run-user" }]],
[issues, [{ responsibleUserId: "issue-user", createdByUserId: null }]],
[agentApiKeys, [{ responsibleUserId: "key-user" }]],
@@ -89,7 +89,7 @@ describe("resolveResponsibleUserIdForActivity", () => {
});
it("falls back to issue attribution when the run is unavailable", async () => {
const db = createReader(new Map([
const db = createReader(new Map<unknown, Array<Record<string, unknown>>>([
[heartbeatRuns, []],
[issues, [{ responsibleUserId: "issue-user", createdByUserId: "creator-user" }]],
[agentApiKeys, [{ responsibleUserId: "key-user" }]],
@@ -103,7 +103,7 @@ describe("resolveResponsibleUserIdForActivity", () => {
});
it("uses explicit issue context for non-issue activity", async () => {
const db = createReader(new Map([
const db = createReader(new Map<unknown, Array<Record<string, unknown>>>([
[issues, [{ responsibleUserId: "issue-user", createdByUserId: null }]],
[companies, [{ defaultResponsibleUserId: "default-user" }]],
]));
@@ -116,7 +116,7 @@ describe("resolveResponsibleUserIdForActivity", () => {
});
it("uses the active agent API key responsible user for no-run actions", async () => {
const db = createReader(new Map([
const db = createReader(new Map<unknown, Array<Record<string, unknown>>>([
[agentApiKeys, [{ responsibleUserId: "key-user" }]],
[companies, [{ defaultResponsibleUserId: "default-user" }]],
]));
@@ -129,7 +129,7 @@ describe("resolveResponsibleUserIdForActivity", () => {
});
it("falls back to the company default responsible user", async () => {
const db = createReader(new Map([
const db = createReader(new Map<unknown, Array<Record<string, unknown>>>([
[agentApiKeys, []],
[companies, [{ defaultResponsibleUserId: "default-user" }]],
]));
@@ -142,7 +142,7 @@ describe("resolveResponsibleUserIdForActivity", () => {
});
it("uses issue creator attribution when responsibleUserId is absent", async () => {
const db = createReader(new Map([
const db = createReader(new Map<unknown, Array<Record<string, unknown>>>([
[issues, [{ responsibleUserId: null, createdByUserId: "creator-user" }]],
[companies, [{ defaultResponsibleUserId: "default-user" }]],
]));
@@ -151,7 +151,7 @@ describe("resolveResponsibleUserIdForActivity", () => {
});
it("ignores malformed UUID-backed identifiers", async () => {
const db = createReader(new Map([
const db = createReader(new Map<unknown, Array<Record<string, unknown>>>([
[heartbeatRuns, [{ responsibleUserId: "run-user" }]],
[issues, [{ responsibleUserId: "issue-user", createdByUserId: null }]],
[agentApiKeys, [{ responsibleUserId: "key-user" }]],
+2 -1
View File
@@ -4,6 +4,7 @@ import request from "supertest";
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
import { vi } from "vitest";
import type { ServerAdapterModule } from "../adapters/index.js";
import type { RunnerTarget } from "../services/agent-runner-selection.js";
const mockAdapterPluginStore = vi.hoisted(() => ({
listAdapterPlugins: vi.fn(),
@@ -221,7 +222,7 @@ describe("adapter routes", () => {
});
});
it.each([
it.each<[RunnerTarget, string]>([
[{ driver: "ssh", platform: "linux", architecture: "x64" }, "paperclip"],
[{ driver: "ssh", platform: "darwin", architecture: "arm64" }, "paperclip"],
[{ driver: "ssh", platform: "darwin", architecture: "x64" }, "paperclip"],
@@ -177,12 +177,12 @@ describePostgres("agent action audit routes", () => {
it("denies agents and board users without the audit permission", async () => {
const { company, agent } = await seed();
const agentResponse = await request(await createApp(db, {
type: "agent", agentId: agent.id, companyId: company.id, runId: null, source: "agent_jwt",
type: "agent", agentId: agent.id, companyId: company.id, source: "agent_jwt",
})).get(`/api/companies/${company.id}/audit/agent-actions`);
expect(agentResponse.status).toBe(403);
const agentAllActorsResponse = await request(await createApp(db, {
type: "agent", agentId: agent.id, companyId: company.id, runId: null, source: "agent_jwt",
type: "agent", agentId: agent.id, companyId: company.id, source: "agent_jwt",
})).get(`/api/companies/${company.id}/audit/agent-actions?actorScope=all`);
expect(agentAllActorsResponse.status, JSON.stringify(agentAllActorsResponse.body)).toBe(200);
expect(agentAllActorsResponse.body.items).toHaveLength(3);
@@ -307,7 +307,7 @@ describe("agent auth middleware", () => {
it.each(["key", "jwt"])("rejects unfinished setup authentication with a %s", async kind => {
const companyId = randomUUID();
const agentId = randomUUID();
const token = kind === "jwt" ? createLocalAgentJwt(agentId, companyId, "codex_local", randomUUID(), "user-1") : "pcp_setup-test-token";
const token = kind === "jwt" ? createLocalAgentJwt(agentId, companyId, "codex_local", randomUUID(), "user-1")! : "pcp_setup-test-token";
const { db } = createDbState({
agent: { id: agentId, companyId, status: "paused", lifecycleHolds: ["setup"] },
...(kind === "key" ? { agentKey: { id: randomUUID(), agentId, companyId, keyHash: hashToken(token), responsibleUserId: "user-1" } } : {}),
@@ -147,7 +147,7 @@ import { recoveryPostgresSupport as support } from "./helpers/recovery-postgres.
const locked = new Promise<number>(resolve => { ready = resolve; });
const release = new Promise<void>(resolve => { unlock = resolve; });
const gate = server.db.transaction(async tx => {
const [{ pid }] = await tx.execute(sql<{ pid: number }>`select pg_backend_pid() as pid`);
const [{ pid }] = await tx.execute<{ pid: number }>(sql`select pg_backend_pid() as pid`);
await tx.select({ id: issues.id }).from(issues).where(eq(issues.id, receipt.conversationId)).for("update");
ready(pid); await release;
});
@@ -263,7 +263,7 @@ import { recoveryPostgresSupport as support } from "./helpers/recovery-postgres.
const locked = new Promise<number>(resolve => { ready = resolve; });
const release = new Promise<void>(resolve => { unlock = resolve; });
const gate = server.db.transaction(async tx => {
const [{ pid }] = await tx.execute(sql<{ pid: number }>`select pg_backend_pid() as pid`);
const [{ pid }] = await tx.execute<{ pid: number }>(sql`select pg_backend_pid() as pid`);
await tx.select({ id: agents.id }).from(agents).where(eq(agents.id, f.agentId)).for("update");
ready(pid);
await release;
@@ -71,7 +71,7 @@ describe("agent commentary browsing", () => {
await db.insert(agents).values({ id: agentId, companyId: pagedCompanyId, name: "Writer", role: "engineer", adapterType: "codex_local" });
await db.insert(heartbeatRuns).values({ id: runId, companyId: pagedCompanyId, agentId, status: "succeeded" });
await db.insert(agentCommentary).values(Array.from({ length: 58 }, (_, index) => ({
companyId: pagedCompanyId, agentId, runId, kind: "suggestion", body: `Message ${index}`,
companyId: pagedCompanyId, agentId, runId, kind: "suggestion" as const, body: `Message ${index}`,
idempotencyKey: String(index), payloadHash: "hash",
})));
const first = await reader.list(pagedCompanyId, query(), sql`true`);
@@ -0,0 +1,188 @@
import { randomUUID } from "node:crypto";
import { mkdtemp, realpath, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import express from "express";
import request from "supertest";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { and, eq } from "drizzle-orm";
import { agents, agentConfigRevisions, activityLog, builtInManagedResources, companies, companyMemberships, connectionGrants, createDb, environments, toolConnections, toolConnectionInstalls } from "@paperclipai/db";
import { agentRoutes } from "../routes/agents.js";
import { aiConnectionService } from "../services/ai-connections.js";
import * as environmentTests from "../services/agent-environment-test.js";
import { instanceSettingsService } from "../services/instance-settings.js";
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
const support = await getEmbeddedPostgresTestSupport();
const describePostgres = support.supported ? describe : describe.skip;
const realEnvironmentTests = environmentTests.agentEnvironmentTestService;
describePostgres("agent configuration save latency", () => {
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
let db: ReturnType<typeof createDb>;
let home: string;
let app: express.Express;
let agent: typeof agents.$inferSelect;
let account: { connectionId: string; grantId: string };
let fixtureNumber = 0;
const userId = randomUUID();
const binding = { provider: "openai", method: "api_key", mode: "responsible_user" } as const;
const allocate = vi.fn<ReturnType<typeof realEnvironmentTests>["resolveAdapterTestExecutionContext"]>();
const probe = vi.fn<ReturnType<typeof realEnvironmentTests>["testManagedEnvironment"]>();
const release = vi.fn(async () => undefined);
const readAgent = async () => (await db.select().from(agents).where(eq(agents.id, agent.id)))[0];
beforeAll(async () => {
home = await realpath(await mkdtemp(path.join(os.tmpdir(), "paperclip-config-save-")));
vi.stubEnv("PAPERCLIP_HOME", home);
vi.stubEnv("PAPERCLIP_INSTANCE_ID", "configuration-save-fixture");
database = await startEmbeddedPostgresTestDatabase("paperclip-config-save-db-");
db = createDb(database.connectionString);
}, 90_000);
beforeEach(async () => {
const companyId = randomUUID();
await db.insert(companies).values({ id: companyId, name: "Configuration save", issuePrefix: `CS${++fixtureNumber}` });
await db.insert(companyMemberships).values({ companyId, principalId: userId, principalType: "user", status: "active", membershipRole: "member" });
[agent] = await db.insert(agents).values({ companyId, name: "Configuration agent", adapterType: "paperclip_runner",
adapterConfig: { provider: "codex", model: "gpt-5.6-sol" } }).returning();
account = await aiConnectionService(db).save(companyId, userId, {
provider: "openai", method: "api_key", ownership: "personal", name: "Saved account", apiKey: "fixture-save-key", agentIds: [agent.id], allAgents: false,
}, "fixture-save-key");
await aiConnectionService(db).setDefault(companyId, userId, account.grantId);
allocate.mockReset().mockRejectedValue(new Error("A save must not allocate a remote computer"));
probe.mockReset().mockResolvedValue({ adapterType: "paperclip_runner", status: "pass", testedAt: new Date().toISOString(),
checks: [{ code: "codex_hello_probe_passed", level: "info", message: "Fixture probe passed" }] });
release.mockClear();
vi.spyOn(environmentTests, "agentEnvironmentTestService").mockImplementation((database, manager) => ({
...realEnvironmentTests(database, manager), resolveAdapterTestExecutionContext: allocate, testManagedEnvironment: probe,
}));
vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("A save must not contact the provider"));
app = express(); app.use(express.json());
app.use((req, _res, next) => { req.actor = { type: "board", source: "local_implicit", userId, companyIds: [companyId] }; next(); });
app.use("/api", agentRoutes(db));
app.use((error: { status?: number; message: string }, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
res.status(error.status ?? 500).json({ error: error.message });
});
});
afterEach(() => vi.restoreAllMocks());
afterAll(async () => {
await database?.cleanup(); vi.unstubAllEnvs();
if (home) await rm(home, { recursive: true, force: true });
});
it("saves an installed healthy account without allocating a computer or sending a prompt", async () => {
const response = await request(app).patch(`/api/agents/${agent.id}`).send({ runtimeConfig: { aiConnection: binding } });
expect(response.status, JSON.stringify(response.body)).toBe(200);
expect((await readAgent()).runtimeConfig.aiConnection).toEqual(binding);
expect(allocate).not.toHaveBeenCalled(); expect(probe).not.toHaveBeenCalled(); expect(globalThis.fetch).not.toHaveBeenCalled();
expect(JSON.stringify(response.body)).not.toContain("fixture-save-key");
expect(await db.select().from(agentConfigRevisions).where(eq(agentConfigRevisions.agentId, agent.id))).toHaveLength(1);
expect(await db.select().from(activityLog).where(and(eq(activityLog.entityId, agent.id), eq(activityLog.action, "agent.updated")))).toHaveLength(1);
});
it("saves model edits against an existing healthy account without a runtime probe", async () => {
await db.update(agents).set({ runtimeConfig: { aiConnection: binding } }).where(eq(agents.id, agent.id));
const response = await request(app).patch(`/api/agents/${agent.id}`).send({ adapterConfig: { model: "gpt-5.6-luna" } });
expect(response.status, JSON.stringify(response.body)).toBe(200);
expect((await readAgent()).adapterConfig.model).toBe("gpt-5.6-luna");
expect(allocate).not.toHaveBeenCalled(); expect(probe).not.toHaveBeenCalled(); expect(globalThis.fetch).not.toHaveBeenCalled();
});
it("saves a roster connection edit without a runtime probe and rejects a stale retry", async () => {
const patch = { runtimeSettings: { harness: "codex_local", aiConnection: binding,
expected: { model: "gpt-5.6-sol", aiConnection: null } } };
const response = await request(app).patch(`/api/agents/${agent.id}`).send(patch);
expect(response.status, JSON.stringify(response.body)).toBe(200);
expect((await readAgent()).runtimeConfig.aiConnection).toEqual(binding);
expect((await request(app).patch(`/api/agents/${agent.id}`).send(patch)).status).toBe(409);
expect(allocate).not.toHaveBeenCalled(); expect(probe).not.toHaveBeenCalled();
});
it("rejects an account without an installation for this agent", async () => {
await db.delete(toolConnectionInstalls).where(eq(toolConnectionInstalls.connectionId, account.connectionId));
const response = await request(app).patch(`/api/agents/${agent.id}`).send({ runtimeConfig: { aiConnection: binding } });
expect(response.status, JSON.stringify(response.body)).toBe(403);
expect((await readAgent()).runtimeConfig.aiConnection).toBeUndefined();
expect(allocate).not.toHaveBeenCalled();
});
it("rejects a revoked account before saving or allocating a computer", async () => {
await db.update(connectionGrants).set({ status: "revoked" }).where(eq(connectionGrants.id, account.grantId));
const response = await request(app).patch(`/api/agents/${agent.id}`).send({ runtimeConfig: { aiConnection: binding } });
expect(response.status, JSON.stringify(response.body)).toBe(422);
expect((await readAgent()).runtimeConfig.aiConnection).toBeUndefined();
expect(await db.select().from(agentConfigRevisions).where(eq(agentConfigRevisions.agentId, agent.id))).toHaveLength(0);
expect(allocate).not.toHaveBeenCalled();
});
it.each(["foreign", "same-company", "global"])("checks %s environment ownership without a runtime probe", async ownership => {
const [environment] = await db.insert(environments).values({ name: `Save target ${agent.companyId}`, driver: "sandbox", config: { provider: "daytona" } }).returning();
if (ownership !== "global") {
const companyId = ownership === "foreign" ? randomUUID() : agent.companyId;
if (ownership === "foreign") await db.insert(companies).values({ id: companyId, name: "Foreign company", issuePrefix: `FC${++fixtureNumber}` });
await db.insert(builtInManagedResources).values({ companyId, bundleKey: "managed-environment", resourceKind: "environment",
resourceKey: "managed-sandbox", resourceId: environment.id, stockVersion: "1", stockHash: "fixture" });
}
const response = await request(app).patch(`/api/agents/${agent.id}`).send({ defaultEnvironmentId: environment.id, runtimeConfig: { aiConnection: binding } });
expect(response.status, JSON.stringify(response.body)).toBe(ownership === "foreign" ? 403 : 200);
const saved = await readAgent();
expect(saved.defaultEnvironmentId).toBe(ownership === "foreign" ? null : environment.id);
expect(saved.runtimeConfig.aiConnection).toEqual(ownership === "foreign" ? undefined : binding);
expect(await db.select().from(agentConfigRevisions).where(eq(agentConfigRevisions.agentId, agent.id))).toHaveLength(ownership === "foreign" ? 0 : 1);
expect(allocate).not.toHaveBeenCalled(); expect(probe).not.toHaveBeenCalled();
});
it.each(["inherited", "existing-binding"])("rejects a foreign environment with %s settings", async mode => {
const companyId = randomUUID();
await db.insert(companies).values({ id: companyId, name: "Foreign company", issuePrefix: `FC${++fixtureNumber}` });
const [environment] = await db.insert(environments).values({ name: `Foreign target ${companyId}`, driver: "sandbox", config: { provider: "daytona" } }).returning();
await db.insert(builtInManagedResources).values({ companyId, bundleKey: "managed-environment", resourceKind: "environment",
resourceKey: "managed-sandbox", resourceId: environment.id, stockVersion: "1", stockHash: "fixture" });
const settings = instanceSettingsService(db);
const previousDefault = (await settings.get()).defaultEnvironmentId;
try {
if (mode === "inherited") await settings.update({ defaultEnvironmentId: environment.id });
else await db.update(agents).set({ runtimeConfig: { aiConnection: binding } }).where(eq(agents.id, agent.id));
const response = await request(app).patch(`/api/agents/${agent.id}`).send(mode === "inherited"
? { runtimeConfig: { aiConnection: binding } } : { defaultEnvironmentId: environment.id });
expect(response.status, JSON.stringify(response.body)).toBe(403);
expect((await readAgent()).defaultEnvironmentId).toBeNull();
expect(await db.select().from(agentConfigRevisions).where(eq(agentConfigRevisions.agentId, agent.id))).toHaveLength(0);
expect(allocate).not.toHaveBeenCalled(); expect(probe).not.toHaveBeenCalled();
} finally { if (mode === "inherited") await settings.update({ defaultEnvironmentId: previousDefault }); }
});
it("keeps live validation for an unverified legacy account", async () => {
const [connection] = await db.select().from(toolConnections).where(eq(toolConnections.id, account.connectionId));
await db.update(toolConnections).set({ healthStatus: "unknown", config: { ...connection.config, aiLegacyAdoption: true } }).where(eq(toolConnections.id, account.connectionId));
allocate.mockResolvedValue({ executionTarget: null, environmentName: "Fixture computer", fallbackChecks: [], release });
probe.mockResolvedValueOnce({ adapterType: "paperclip_runner", status: "fail", testedAt: new Date().toISOString(),
checks: [{ code: "codex_hello_probe_auth_required", level: "error", message: "Fixture login rejected" }] });
const failed = await request(app).patch(`/api/agents/${agent.id}`).send({ runtimeConfig: { aiConnection: binding } });
expect(failed.status, JSON.stringify(failed.body)).toBe(422);
expect((await readAgent()).runtimeConfig.aiConnection).toBeUndefined();
const saved = await request(app).patch(`/api/agents/${agent.id}`).send({ runtimeConfig: { aiConnection: binding } });
expect(saved.status, JSON.stringify(saved.body)).toBe(200);
expect(probe).toHaveBeenCalledTimes(2); expect(release).toHaveBeenCalledTimes(2);
const [verified] = await db.select().from(toolConnections).where(eq(toolConnections.id, account.connectionId));
expect(verified.healthStatus).toBe("ok"); expect(verified.config.aiLegacyAdoption).toBe(false);
});
it("profiles a save with controlled remote allocation latency", async () => {
const delayMs = Number(process.env.PAPERCLIP_CONFIG_SAVE_PROFILE_DELAY_MS ?? 50);
allocate.mockImplementation(async () => {
await new Promise(resolve => setTimeout(resolve, delayMs));
return { executionTarget: null, environmentName: "Delayed fixture computer", fallbackChecks: [], release };
});
const startedAt = performance.now();
const response = await request(app).patch(`/api/agents/${agent.id}`).send({ runtimeConfig: { aiConnection: binding } });
const elapsedMs = performance.now() - startedAt;
if (process.env.PAPERCLIP_CONFIG_SAVE_PROFILE_DELAY_MS) {
process.stdout.write(`${JSON.stringify({ profile: "agent-configuration-save", remoteAllocationDelayMs: delayMs, elapsedMs,
allocations: allocate.mock.calls.length, probes: probe.mock.calls.length, status: response.status })}\n`);
}
expect(response.status, JSON.stringify(response.body)).toBe(200);
expect(allocate).not.toHaveBeenCalled(); expect(probe).not.toHaveBeenCalled();
}, 60_000);
});
@@ -51,6 +51,7 @@ import {
undeliveredConversationComments,
} from "../services/agent-conversations.js";
import { classifyIssueGraphLiveness } from "../services/recovery/issue-graph-liveness.js";
import { NATIVE_STATUS_ARBITER_POLICY_VERSION } from "../services/native-runtime/status-arbiter.js";
import { runningProcesses } from "../adapters/index.js";
import {
buildPaperclipTaskMarkdown,
@@ -444,9 +445,9 @@ import {
});
const [interaction] = await db.insert(issueThreadInteractions).values({
companyId, issueId: issue.id, kind: "request_confirmation", status: "rejected",
payload: { version: 1, target: { type: "issue_document", key: "plan", documentId: document.id,
payload: { version: 1, prompt: "Approve the plan?", target: { type: "issue_document", key: "plan", documentId: document.id,
revisionId: document.latestRevisionId!, revisionNumber: document.latestRevisionNumber } },
result: { outcome: "rejected", reason: "Include CHAT_REVIEW_MARKER in the revised plan." },
result: { version: 1, outcome: "rejected", reason: "Include CHAT_REVIEW_MARKER in the revised plan." },
}).returning();
const input = { db, companyId, issueSummary: { ...issue, workMode: "planning" },
contextSnapshot: { issueId: issue.id, conversationMode: true, interactionId: interaction!.id,
@@ -467,7 +468,7 @@ import {
contextSnapshot: { issueId: issue.id, conversationMode: true } });
expect(later?.planReviewContext).toBeNull();
// An unrelated confirmation must not cause old plan context to be replayed.
await db.update(issueThreadInteractions).set({ payload: { version: 1 } })
await db.update(issueThreadInteractions).set({ payload: { version: 1, prompt: "Proceed?" } })
.where(eq(issueThreadInteractions.id, interaction!.id));
expect((await buildPaperclipWakePayload(input))?.planReviewContext).toBeNull();
});
@@ -769,7 +770,7 @@ import {
const succeeded = { ...run, contextSnapshot: prepared.context, status: "succeeded" };
const [interaction] = await db.insert(issueThreadInteractions).values({
companyId, issueId: chat.id, sourceRunId: run.id, createdByAgentId: agentId,
kind: "request_confirmation", status: "answered", payload: { version: 1 },
kind: "request_confirmation", status: "answered", payload: { version: 1, prompt: "Proceed?" },
}).returning();
expect(await settleConversationTurn(db, succeeded)).toBe(false);
await db.update(issueThreadInteractions).set({ status: "pending", sourceRunId: null })
@@ -831,8 +832,8 @@ import {
blockedByIssueIds: [blocker.id],
});
for (const state of [
{ status: "in_review", conversationState: "waiting" },
{ status: "blocked", conversationState: "active" },
{ status: "in_review", conversationState: "waiting" as const },
{ status: "blocked", conversationState: "active" as const },
]) {
await db.update(issues).set(state).where(eq(issues.id, chat.id));
for (const reason of ["issue_blockers_resolved", "issue_children_completed", "issue_unblock_requested"]) {
@@ -914,19 +915,21 @@ import {
const answerRun = await runFor(chat.id, first.id, { source: "issue.interaction.respond", interactionId: question.id,
interactionKind: "ask_user_questions", interactionStatus: "answered" });
const prepared = await prepareConversationTurn(db, answerRun);
expect(prepared.context.wakeCommentId).toBe(first.id);
expect(prepared.context.conversationReplyBoundaryCommentId).toBe(["answered", "new-message"].includes(scenario) ? newer.id : first.id);
const preparedContext: Record<string, unknown> = prepared.context;
expect(preparedContext.wakeCommentId).toBe(first.id);
expect(preparedContext.conversationReplyBoundaryCommentId).toBe(["answered", "new-message"].includes(scenario) ? newer.id : first.id);
// A retry reuses the frozen boundary, even if another message has arrived.
if (scenario === "new-message") await svc.addComment(chat.id, "New instructions while you answer", { userId: "local-board" });
const retry = await prepareConversationTurn(db, { ...answerRun, contextSnapshot: prepared.context });
expect(retry.context.conversationReplyBoundaryCommentId).toBe(prepared.context.conversationReplyBoundaryCommentId);
const retryContext: Record<string, unknown> = retry.context;
expect(retryContext.conversationReplyBoundaryCommentId).toBe(preparedContext.conversationReplyBoundaryCommentId);
await svc.addComment(chat.id, "You chose Blue.", { agentId, runId: answerRun.id });
expect(await settleConversationTurn(db, { ...answerRun, status: "succeeded", contextSnapshot: prepared.context })).toBe(true);
const [settled] = await db.select().from(issues).where(eq(issues.id, chat.id));
expect(settled.conversationState).toBe(scenario === "answered" ? "waiting" : "active");
expect(retry.context.conversationReplayThroughCommentId).toBe(prepared.context.conversationReplayThroughCommentId);
expect(retryContext.conversationReplayThroughCommentId).toBe(preparedContext.conversationReplayThroughCommentId);
const replay = await conversationReplay(db, companyId, chat.id, first.id,
prepared.context.conversationReplayThroughCommentId as string);
preparedContext.conversationReplayThroughCommentId as string);
if (["answered", "new-message"].includes(scenario)) {
expect(replay).toContain("What is the capital of France?");
expect(replay).toContain("Paris.");
@@ -1026,7 +1029,7 @@ describe("chat prompt policy", () => {
it.each([true, false])("preserves rejected-plan changes in task markdown (includeDescription=%s)", (includeDescription) => {
const prompt = buildPaperclipTaskMarkdown({
issue: { id: "chat", title: "Chat", workMode: "planning", conversationAgentId: "agent" },
issue: { id: "chat", identifier: null, title: "Chat", workMode: "planning", conversationAgentId: "agent" },
interaction: { kind: "request_confirmation", status: "rejected" },
planReview: { status: "rejected", reason: "Add CHAT_REVIEW_MARKER and a validation step." },
acceptedPlanContinuation: true,
@@ -1070,7 +1073,7 @@ describe("chat prompt policy", () => {
describe("native conversation finalization", () => {
it("does not require execution completion or schedule a continuation after a successful chat turn", () => {
const decision = {
policyVersion: "paperclip.native-status-arbiter.v1",
policyVersion: NATIVE_STATUS_ARBITER_POLICY_VERSION,
statusAction: "in_progress",
toStatus: "in_progress",
reasonCode: "completion_evidence_incomplete",
@@ -47,6 +47,7 @@ let currentKeyAgentId = agentId;
let currentAccessCanUser = false;
const mockAgentService = vi.hoisted(() => ({
create: vi.fn(),
getById: vi.fn(),
retryLifecycle: vi.fn(),
pause: vi.fn(),
@@ -12,7 +12,7 @@ import { agentJoinGrantsFromDefaults } from "../services/invite-grants.js";
const support = await getEmbeddedPostgresTestSupport();
const describeDatabase = support.supported ? describe : describe.skip;
const expectedNewAgentGrantKeys: PermissionKey[] = [
const expectedNewAgentGrantKeys: PermissionKey[] = ([
"agents:read",
"agents:configure",
"agents:suggest-changes",
@@ -28,7 +28,7 @@ const expectedNewAgentGrantKeys: PermissionKey[] = [
"tasks:assign",
"tasks:assign_scope",
"tasks:manage_active_checkouts",
].sort();
] satisfies PermissionKey[]).sort();
describeDatabase("new agent configuration defaults", () => {
let db!: ReturnType<typeof createDb>;
@@ -51,6 +51,7 @@ const GROK_PROMPT_OUTPUT = [
const CREDENTIAL_BYTES = '{"tokens":{"access":"SECRET-ACCESS-TOKEN"}}';
const mockAgentService = vi.hoisted(() => ({
create: vi.fn(),
getById: vi.fn(),
getChainOfCommand: vi.fn(async () => []),
}));
@@ -182,7 +182,7 @@ describe("stable live agent directory observation", () => {
traversal: `${reserved}/../../scratch`, "trailing-slash": `${reserved}/scratch/`, nul: `${reserved}/scratch\0`,
}[kind]!;
await expect(prepareSandboxManagedRuntime({
spec: { transport: "sandbox", provider: "fixture", sandboxId: "owned", remoteCwd: root, apiKey: null },
spec: { transport: "sandbox", provider: "fixture", sandboxId: "owned", remoteCwd: root, timeoutMs: 30_000, apiKey: null },
adapterKey: "agent-files", client: {} as never, workspaceLocalDir: root, runtimeRootDir: invalid,
})).rejects.toThrow("Transfer scratch must remain within the lease runtime tree");
expect(fs.existsSync(reserved)).toBe(false);
@@ -908,10 +908,10 @@ describe("persistent agent directories", () => {
it("cleans up a staging failure before any provider starts", async () => {
const runId = randomUUID();
await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, invocationSource: "on_demand", responsibleUserId: userId });
const shell = vi.spyOn(executionTargetTools, "runAdapterExecutionTargetShellCommand").mockResolvedValue({ exitCode: 1, signal: null, timedOut: false, stdout: "", stderr: "fixture failure" });
const shell = vi.spyOn(executionTargetTools, "runAdapterExecutionTargetShellCommand").mockResolvedValue({ exitCode: 1, signal: null, timedOut: false, stdout: "", stderr: "fixture failure", pid: null, startedAt: null });
try {
await expect(copies.prepare({ ...target(), runId, cwd: home, target: { kind: "remote", transport: "ssh", environmentId: randomUUID(), remoteCwd: "/fixture/task",
spec: { host: "unused.invalid", port: 22, username: "test", remoteCwd: "/fixture/task" } } })).rejects.toThrow("Could not exclude");
spec: { host: "unused.invalid", port: 22, username: "test", remoteCwd: "/fixture/task", remoteWorkspacePath: "/fixture/task", privateKey: null, knownHosts: null, strictHostKeyChecking: true } } })).rejects.toThrow("Could not exclude");
const row = (await copies.get(companyId, runId))!;
expect(row.state).toBe("unavailable");
expect(row.receipt?.baseline).toBeUndefined();
@@ -1424,7 +1424,7 @@ describe("persistent agent directories", () => {
it("stages SSH agent files at the registered root without a nested task workspace", async () => {
const remoteCwd = path.join(home, "ssh-task");
const exclude = vi.spyOn(executionTargetTools, "runAdapterExecutionTargetShellCommand").mockResolvedValue({ exitCode: 0, signal: null, timedOut: false, stdout: "", stderr: "" });
const exclude = vi.spyOn(executionTargetTools, "runAdapterExecutionTargetShellCommand").mockResolvedValue({ exitCode: 0, signal: null, timedOut: false, stdout: "", stderr: "", pid: null, startedAt: null });
const stage = vi.spyOn(ssh, "syncDirectoryToSsh").mockImplementation(async input => {
await fs.mkdir(path.dirname(input.remoteDir), { recursive: true });
await fs.cp(input.localDir, input.remoteDir, { recursive: true });
@@ -1436,7 +1436,7 @@ describe("persistent agent directories", () => {
const runId = randomUUID();
await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, invocationSource: "on_demand", responsibleUserId: userId });
const target = { kind: "remote" as const, transport: "ssh" as const, environmentId: randomUUID(), remoteCwd,
spec: { host: "unused.invalid", port: 22, username: "test", remoteCwd } };
spec: { host: "unused.invalid", port: 22, username: "test", remoteCwd, remoteWorkspacePath: remoteCwd, privateKey: null, knownHosts: null, strictHostKeyChecking: true } };
await db.insert(environments).values({ id: target.environmentId, name: "Fixture SSH", driver: "ssh" });
// Legacy no-ID receipt: only the unique run/environment lease authorizes retrieval.
await db.insert(environmentLeases).values({ companyId, environmentId: target.environmentId, heartbeatRunId: runId, provider: "ssh", providerLeaseId: "fixture-host" });
@@ -1606,8 +1606,8 @@ describe("persistent agent directories", () => {
const { copy, lease } = await unavailableRemote({ state: "destroyed" });
const remoteCwd = "/fixture/cleanup-task";
const executionTarget = { kind: "remote" as const, transport: "ssh" as const, environmentId: lease.environmentId, leaseId: lease.id, remoteCwd,
spec: { host: "unused.invalid", port: 22, username: "test", remoteCwd } };
const shell = vi.spyOn(executionTargetTools, "runAdapterExecutionTargetShellCommand").mockResolvedValue({ exitCode: 0, signal: null, timedOut: false, stdout: "", stderr: "" });
spec: { host: "unused.invalid", port: 22, username: "test", remoteCwd, remoteWorkspacePath: remoteCwd, privateKey: null, knownHosts: null, strictHostKeyChecking: true } };
const shell = vi.spyOn(executionTargetTools, "runAdapterExecutionTargetShellCommand").mockResolvedValue({ exitCode: 0, signal: null, timedOut: false, stdout: "", stderr: "", pid: null, startedAt: null });
const stage = vi.spyOn(ssh, "syncDirectoryToSsh").mockResolvedValue(undefined);
let stopCommitted!: () => void, finishCleanup!: () => void;
const committed = new Promise<void>(resolve => { stopCommitted = resolve; });
@@ -1728,7 +1728,7 @@ describe("persistent agent directories", () => {
await db.insert(environmentLeases).values(lease);
const execute = vi.fn(), restoreWorkspace = vi.fn(), cleanupWorkspaceSnapshot = vi.fn().mockResolvedValue(undefined);
const executionTarget = { kind: "remote" as const, transport: "sandbox" as const, environmentId, leaseId, remoteCwd, runner: { execute } };
const shell = vi.spyOn(executionTargetTools, "runAdapterExecutionTargetShellCommand").mockResolvedValue({ exitCode: 0, signal: null, timedOut: false, stdout: "", stderr: "" });
const shell = vi.spyOn(executionTargetTools, "runAdapterExecutionTargetShellCommand").mockResolvedValue({ exitCode: 0, signal: null, timedOut: false, stdout: "", stderr: "", pid: null, startedAt: null });
const stage = vi.spyOn(executionTargetTools, "prepareAdapterExecutionTargetRuntime").mockImplementation(async options => ({ target: executionTarget,
workspaceRemoteDir: path.posix.join(remoteCwd, ".paperclip-runtime", "agent-files", agentId, runId), runtimeRootDir: options.runtimeRootDir ?? null,
assetDirs: {}, additionalSourceDirs: {}, additionalSourceFailures: [], workspaceSyncSnapshot: null, restoreWorkspace, cleanupWorkspaceSnapshot }));
@@ -94,7 +94,7 @@ describe("incremental managed file checkpoints", () => {
if (first) { first = false; await fs.appendFile(filename, Buffer.alloc(1024 * 1024, 2)); }
yield chunk;
}
})()) as typeof handle.createReadStream;
})()) as unknown as typeof handle.createReadStream;
}
return handle;
});
@@ -9,7 +9,7 @@ import express from "express";
import request from "supertest";
import { and, eq } from "drizzle-orm";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { activityLog, agents, authUsers, companies, companyMemberships, createDb, closeRegisteredClients, environments, heartbeatRuns, issues, plugins, principalPermissionGrants, toolConnectionInstalls } from "@paperclipai/db";
import { activityLog, agents, authUsers, companies, companyMemberships, createDb, closeRegisteredClients, environments, heartbeatRuns, issues, plugins, principalPermissionGrants, toolConnections, toolConnectionInstalls } from "@paperclipai/db";
import { type AiConnectionBinding, type AiConnectionPoolMember, type PaperclipPluginManifestV1 } from "@paperclipai/shared";
import { startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
import { agentRoutes } from "../routes/agents.js";
@@ -94,6 +94,11 @@ function hired(response: request.Response) {
return response.body.agent ?? response.body;
}
async function markLegacyAdoption(connectionId: string) {
const [connection] = await db.select().from(toolConnections).where(eq(toolConnections.id, connectionId));
await db.update(toolConnections).set({ config: { ...connection.config, aiLegacyAdoption: true } }).where(eq(toolConnections.id, connectionId));
}
async function poolFixture(f: Awaited<ReturnType<typeof fixture>>, extraMembers: AiConnectionPoolMember[] = []) {
await instanceSettingsService(db).updateExperimental({ enableAiConnectionRouters: true });
const pluginKey = `fixture.pool-${f.companyId}`;
@@ -257,8 +262,10 @@ describe("agent-created hires use managed AI connections", () => {
} finally { unregisterServerAdapter(adapterType); }
});
it.each(["model", "engine", "environment", "harness", "connection"] as const)("probes a changed %s selection and preserves the saved configuration on failure", async (selection) => {
it.each((["model", "engine", "environment", "harness", "connection"] as const).flatMap(selection =>
[false, true].map(legacy => ({ selection, legacy }))))("checks a changed $selection selection (legacy: $legacy)", async ({ selection, legacy }) => {
const f = await fixture("openai");
if (legacy) await markLegacyAdoption(f.account.connectionId);
await db.insert(principalPermissionGrants).values({ companyId: f.companyId, principalType: "user", principalId: f.userId, permissionKey: "agents:configure" });
const [environment] = await db.select().from(environments).where(eq(environments.driver, "local"));
const adapterType = selection === "harness" ? "paperclip_runner" : f.adapterType;
@@ -273,11 +280,15 @@ describe("agent-created hires use managed AI connections", () => {
: { adapterConfig: { [selection]: selection === "model" ? "changed-model" : "cli" } };
try {
const response = await request(f.app).patch(`/api/agents/${f.agentId}`).send(patch);
expect(response.status, JSON.stringify(response.body)).toBe(422);
expect(probe).toHaveBeenCalledOnce();
expect(response.status, JSON.stringify(response.body)).toBe(legacy ? 422 : 200);
expect(probe).toHaveBeenCalledTimes(legacy ? 1 : 0);
const [saved] = await db.select().from(agents).where(eq(agents.id, f.agentId));
expect(saved).toMatchObject({ adapterType: f.adapterType, adapterConfig: {}, defaultEnvironmentId: null,
runtimeConfig: { aiConnection: f.binding } });
if (legacy) {
expect(saved).toMatchObject({ adapterType: f.adapterType, adapterConfig: {}, defaultEnvironmentId: null,
runtimeConfig: { aiConnection: f.binding } });
} else {
expect(saved).toMatchObject(patch);
}
} finally { unregisterServerAdapter(adapterType); }
});
@@ -286,7 +297,7 @@ describe("agent-created hires use managed AI connections", () => {
const otherOwner = `other-${f.companyId}`;
await db.insert(companyMemberships).values({ companyId: f.companyId, principalType: "user", principalId: otherOwner, membershipRole: "member", status: "active" });
const account = await aiConnectionService(db).save(f.companyId, otherOwner, { provider: "openai", method: "api_key", ownership: "shared", name: "Shared credential, restricted installation", apiKey: "fixture", agentIds: [f.agentId], allAgents: false }, "fixture");
await toolAccessService(db).replaceConnectionGrantMembers(account.connectionId, account.grantId, [f.userId], { userId: otherOwner });
await toolAccessService(db).replaceConnectionGrantMembers(account.connectionId, account.grantId, [f.userId], { actorType: "user", actorId: otherOwner });
const member: AiConnectionPoolMember = { id: randomUUID(), binding: { ...account, provider: "openai", method: "api_key", mode: "shared" }, profile: { provider: "codex", model: "gpt-5.6-sol" } };
const p = await poolFixture(f, [member]);
const agent = hired(await request(f.app).post(`/api/companies/${f.companyId}/agents`).send({ name: "Restricted pool teammate", role: "engineer", adapterType: f.adapterType, runtimeConfig: { aiConnection: p.binding } }));
@@ -315,8 +326,9 @@ describe("agent-created hires use managed AI connections", () => {
});
for (const operation of ["test", "save"] as const) {
it.each([401, 403, 429, 503, null])(`${operation} changes API-key health only for a provider rejection (status: %s)`, async (status) => {
it.each([401, 403, 429, 503, null])(`${operation} changes API-key health only for a provider rejection (legacy save; status: %s)`, async (status) => {
const f = await fixture("anthropic", "api_key");
if (operation === "save") await markLegacyAdoption(f.account.connectionId);
await db.insert(principalPermissionGrants).values({ companyId: f.companyId, principalType: "user", principalId: f.userId, permissionKey: "agents:configure" });
const original = getServerAdapter(f.adapterType);
registerServerAdapter({ ...original, testEnvironment: async () => ({ adapterType: f.adapterType, status: "pass", checks: [], testedAt: new Date().toISOString() }) });
@@ -334,8 +346,9 @@ describe("agent-created hires use managed AI connections", () => {
});
}
it.each(["test", "save"] as const)("%s marks a hello-test authentication rejection as needing attention and reconnect repairs the same default", async (operation) => {
it.each(["test", "save"] as const)("%s marks a hello-test authentication rejection as needing attention and reconnect repairs the same default (legacy save)", async (operation) => {
const f = await fixture("anthropic", "subscription");
if (operation === "save") await markLegacyAdoption(f.account.connectionId);
await db.insert(principalPermissionGrants).values({ companyId: f.companyId, principalType: "user", principalId: f.userId, permissionKey: "agents:configure" });
const original = getServerAdapter(f.adapterType);
registerServerAdapter({ ...original, testEnvironment: async () => ({
+1 -1
View File
@@ -129,7 +129,7 @@ describe("agent cryptographic identity", () => {
await execute({
runId: randomUUID(), agent, agentIdentity: identity,
config: { command: process.execPath, cwd: directory, args: ["-e", `const {sign}=require('node:crypto'); process.stdout.write(sign(null,Buffer.from(process.env.CHALLENGE,'base64'),process.env.PAPERCLIP_AGENT_PRIVATE_KEY).toString('base64'));`], env: { CHALLENGE: challenge, PAPERCLIP_AGENT_PRIVATE_KEY: "configured override" } },
context: {}, runtime: { sessionId: null, sessionParams: null, taskKey: null },
context: {}, runtime: { sessionId: null, sessionParams: null, sessionDisplayId: null, taskKey: null },
onLog: async (_stream, chunk) => { logs.push(chunk); }, onMeta: async meta => { metas.push(meta); },
});
expect(verify(null, Buffer.from(challenge, "base64"), publicPem, Buffer.from(logs.join(""), "base64"))).toBe(true);
@@ -43,7 +43,7 @@ describe.skipIf(!support.supported)("agent-scoped runner qualification", () => {
await db.insert(authUsers).values({ id: userId, name: "Company owner", email: `${userId}@example.test`, emailVerified: true, createdAt: new Date(), updatedAt: new Date() });
await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: userId, membershipRole: "owner", status: "active" });
await db.insert(principalPermissionGrants).values(grantsForHumanRole("owner").map(grant => ({ ...grant, companyId, principalType: "user" as const, principalId: userId })));
await db.insert(environments).values({ id: environmentId, name: `Paperclip Computer ${environmentId}`, driver: "sandbox", status: "active", config: { provider: "daytona", snapshot: "managed-snapshot" } });
await db.insert(environments).values({ id: environmentId, name: `Paperclip Computer ${environmentId}`, driver: "sandbox", status: "active", config: { provider: "daytona", snapshot: "managed-snapshot", reuseLease: false } });
await db.insert(agents).values({ id: agentId, companyId, name: "Amy fixture", adapterType: "claude_local", defaultEnvironmentId: environmentId,
adapterConfig: { model: "claude-opus-5-5", effort: "high", maxTurnsPerRun: 1000, dangerouslySkipPermissions: true } });
app = express(); app.use(express.json());
@@ -89,7 +89,7 @@ describe.skipIf(!support.supported)("agent-scoped runner qualification", () => {
it("reuses a current native snapshot proof without invalidating it or allocating another probe", async () => {
await db.update(agents).set({ adapterType: "paperclip_runner", adapterConfig: { provider: "opencode" } }).where(eq(agents.id, agentId));
const config = { provider: "daytona", snapshot: "managed-snapshot" };
const config = { provider: "daytona", snapshot: "managed-snapshot", reuseLease: false };
const fingerprint = fingerprintEnvironmentSandboxProviderConfig(config);
const saved = (await environmentService(db).getById(environmentId))!;
const runtime = await resolveEnvironmentDriverConfigForRuntime(db, companyId, saved, { applyCustomImageTemplate: true, readOnlyCustomImageTemplate: true });
@@ -98,14 +98,14 @@ describeDatabase("connection assignment synchronization", () => {
const [project] = await db.insert(projects).values({ companyId, name: "Project binding fixture" }).returning();
for (let round = 0; round < 2; round++) {
let before = (await events()).length;
await service.bindProfile(profile.id, { targetType: "agent", targetId: agentId });
await service.bindProfile(profile.id, { targetType: "agent", targetId: agentId, priority: 100 });
expect((await events()).slice(before).map(event => event.resourceId)).toEqual([agentId]);
before = (await events()).length;
await service.unbindProfile(profile.id, { targetType: "agent", targetId: agentId });
expect((await events()).slice(before).map(event => event.resourceId)).toEqual([agentId]);
}
const beforeProject = await events();
await service.bindProfile(profile.id, { targetType: "project", targetId: project.id });
await service.bindProfile(profile.id, { targetType: "project", targetId: project.id, priority: 100 });
await service.unbindProfile(profile.id, { targetType: "project", targetId: project.id });
expect(await events()).toEqual(beforeProject);
let before = (await events()).length;
@@ -121,7 +121,7 @@ describeDatabase("connection assignment synchronization", () => {
await db.update(toolConnectionInstalls).set({ targetId: peerId }).where(eq(toolConnectionInstalls.id, install.id));
expect((await events()).slice(before).map(event => event.resourceId).sort()).toEqual([agentId, peerId].sort());
before = (await events()).length;
await service.bindProfile(profile.id, { targetType: "company", targetId: companyId });
await service.bindProfile(profile.id, { targetType: "company", targetId: companyId, priority: 100 });
expect((await events()).slice(before).map(event => event.resourceId).sort()).toEqual([agentId, peerId].sort());
});
it("keeps connector changes and their events atomic and company-scoped", async () => {
@@ -217,9 +217,9 @@ describeDatabase("connection assignment synchronization", () => {
const single = createDb(database.connectionString, { maxConnections: 1 });
const service = toolAccessService(single);
let timer: ReturnType<typeof setTimeout> | undefined;
const work = change === "bind" ? service.bindProfile(source.id, { targetType: "agent", targetId: agentId })
const work = change === "bind" ? service.bindProfile(source.id, { targetType: "agent", targetId: agentId, priority: 100 })
: change === "unbind" ? service.unbindProfile(source.id, { targetType: "agent", targetId: agentId })
: service.deleteProfile(source.id, change === "reassign" ? { reassignToProfileId: target.id } : { force: true });
: service.deleteProfile(source.id, change === "reassign" ? { force: false, reassignToProfileId: target.id } : { force: true });
const settled = Promise.allSettled([work]);
try {
await Promise.race([work, new Promise<never>((_, reject) => { timer = setTimeout(() => reject(new Error("Profile operation waited for another pooled connection")), 3000); })]);
@@ -261,9 +261,9 @@ describeDatabase("connection assignment synchronization", () => {
expect(activity.wait_event_type).toBe("Lock");
}, { timeout: 5000 });
const mutation = run(writer => change === "bind"
? writer.bindProfile(profile.id, { targetType: "agent", targetId: targetAgent })
? writer.bindProfile(profile.id, { targetType: "agent", targetId: targetAgent, priority: 100 })
: change === "unbind" ? writer.unbindProfile(profile.id, { targetType: "agent", targetId: targetAgent })
: writer.deleteProfile(source!.id, { reassignToProfileId: profile.id }));
: writer.deleteProfile(source!.id, { force: false, reassignToProfileId: profile.id }));
const mutationResult = Promise.allSettled([mutation]);
let finishResult: Promise<PromiseSettledResult<unknown>[]> | undefined;
try {
@@ -324,7 +324,7 @@ describeDatabase("connection assignment synchronization", () => {
const firstResult = Promise.allSettled([first]);
try {
await waitForLock(0);
second = Promise.allSettled([run(change === "connection settings" ? adopt : client => toolAccessService(client).bindProfile(bindingProfile!.id, { targetType: "agent", targetId: lower }, { actorType: "agent", actorId: higher }))]);
second = Promise.allSettled([run(change === "connection settings" ? adopt : client => toolAccessService(client).bindProfile(bindingProfile!.id, { targetType: "agent", targetId: lower, priority: 100 }, { actorType: "agent", actorId: higher }))]);
await waitForLock(1);
} finally { release(); await blocker; }
for (const result of [...await firstResult, ...await second!]) if (result.status === "rejected") throw result.reason;
@@ -373,7 +373,7 @@ describeDatabase("connection assignment synchronization", () => {
await waitForLock(0);
replacement = Promise.allSettled([run(service => change === "installs"
? service.putConnectionInstalls(connection.id, { installs: [{ targetType: "agent", targetId: lower }] })
: change === "reassign" ? service.deleteProfile(source.id, { reassignToProfileId: target.id })
: change === "reassign" ? service.deleteProfile(source.id, { force: false, reassignToProfileId: target.id })
: service.finishGalleryAppConnection(companyId, connection.id, { enabledCatalogEntryIds: [], askFirstCatalogEntryIds: [], access: { agentIds: [change === "finish" ? lower : higher] } }))]);
await waitForLock(1);
} finally { release(); await blocker; }
@@ -72,7 +72,7 @@ describeDatabase("connector access runtime synchronization", () => {
let memberId: string | undefined;
if (change === "uninstall") {
await toolAccessService(db).putConnectionInstalls(connectionId, { installs: [{ targetType: "agent", targetId: agentId }] });
expect((await toolAccessService(db).getConnection(connectionId, companyId)).config.mcpAgentAccessConfigured).toBe(true);
expect((await toolAccessService(db).getConnection(connectionId, companyId)).config?.mcpAgentAccessConfigured).toBe(true);
first = client => toolAccessService(client).putConnectionInstalls(connectionId, { installs: [] });
second = client => toolAccessService(client).finishGalleryAppConnection(companyId, connectionId, { enabledCatalogEntryIds: [], askFirstCatalogEntryIds: [], access: "all_agents" });
} else {
@@ -80,7 +80,7 @@ describeDatabase("connector access runtime synchronization", () => {
memberId = member.id;
await db.insert(principalPermissionGrants).values({ companyId, principalType: "user", principalId: "alice", permissionKey: "agents:create" });
first = client => accessService(client).updateMemberAndPermissions(companyId, member.id, { membershipRole: "admin", grants: [{ permissionKey: "agents:create" }] }, null);
second = client => accessService(client).setMemberPermissions(companyId, member.id, [{ permissionKey: "issues:create" }], null);
second = client => accessService(client).setMemberPermissions(companyId, member.id, [{ permissionKey: "tasks:assign" }], null);
}
let locked!: () => void; let release!: () => void;
const held = new Promise<void>(resolve => { locked = resolve; });
@@ -118,7 +118,7 @@ describeDatabase("connector access runtime synchronization", () => {
expect(bindings.some(binding => binding.metadata?.source === "tool_connection_install")).toBe(false);
} else {
expect(await accessService(db).getMemberById(companyId, memberId!)).toMatchObject({ membershipRole: "admin" });
expect(await db.select().from(principalPermissionGrants).where(eq(principalPermissionGrants.companyId, companyId))).toEqual([expect.objectContaining({ permissionKey: "issues:create" })]);
expect(await db.select().from(principalPermissionGrants).where(eq(principalPermissionGrants.companyId, companyId))).toEqual([expect.objectContaining({ permissionKey: "tasks:assign" })]);
}
}, 15000);
it.each(["email allowAgent", "connection intent"] as const)("orders outer %s with direct install saves", async caller => {
@@ -132,7 +132,7 @@ describeDatabase("connector access runtime synchronization", () => {
await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: userId, status: "active", membershipRole: "member" });
await db.insert(issues).values({ id: issueId, companyId, title: "Use Notion", status: "in_progress", assigneeAgentId: agentId });
await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, status: "running", responsibleUserId: userId, contextSnapshot: { issueId } });
const pending = await connectionIntentService(db).request({ sub: agentId, company_id: companyId, run_id: runId, responsible_user_id: userId, scope: "connection_intents", iat: 1, exp: 2, instance_id: "test" }, "notion");
const pending = await connectionIntentService(db).request({ sub: agentId, company_id: companyId, run_id: runId, responsible_user_id: userId }, "notion");
expect(pending.interactionId).toEqual(expect.any(String));
await db.update(toolConnections).set({ healthStatus: "ok", config: { sourceTemplateKey: "notion", mcpAgentAccessConfigured: true } }).where(eq(toolConnections.id, connectionId));
await db.insert(connectionGrants).values({ companyId, connectionId, kind: "organization", status: "active", isDefault: true });
@@ -316,7 +316,7 @@ describeDatabase("connector access runtime synchronization", () => {
});
it("limits delegation creation to its target without waiting on unrelated agents", async () => {
await db.insert(companyMemberships).values(["alice", "bob"].map(principalId => ({ companyId, principalType: "user", principalId, status: "active" })));
const grants = await db.insert(connectionGrants).values(["alice", "bob"].map(subjectUserId => ({ companyId, connectionId, kind: "user", subjectUserId }))).returning();
const grants = await db.insert(connectionGrants).values(["alice", "bob"].map(subjectUserId => ({ companyId, connectionId, kind: "user" as const, subjectUserId }))).returning();
const baseline = await events();
const [lower, higher] = [agentId, peerId].sort();
let locked!: () => void; let release!: () => void;
@@ -136,7 +136,7 @@ describeDatabase("tool definition runtime synchronization", () => {
await db.update(toolConnections).set({ config: { mcpAgentAccessConfigured: true } }).where(eq(toolConnections.id, connectionId));
await db.insert(toolProfiles).values({ companyId, profileKey: `app:${connectionId}`, name: "Install profile" });
const [lower, higher] = [randomUUID(), randomUUID()].sort();
await db.insert(toolPolicies).values([higher, lower].map((id, index) => ({ id, companyId, name: `Managed policy ${index}`, policyType: "require_approval", priority: (index + 1) * 100, config: { source: "app_gallery_finish", connectionId } })));
await db.insert(toolPolicies).values([higher, lower].map((id, index) => ({ id, companyId, name: `Managed policy ${index}`, policyType: "require_approval" as const, priority: (index + 1) * 100, config: { source: "app_gallery_finish", connectionId } })));
let locked!: () => void; let release!: () => void;
const held = new Promise<void>(resolve => { locked = resolve; });
const resume = new Promise<void>(resolve => { release = resolve; });
@@ -222,10 +222,10 @@ describeDatabase("tool definition runtime synchronization", () => {
expect(row.wait_event_type).toBe("Lock");
}, { timeout: 5000 });
const mutation = run(writer => change === "profile entry add"
? writer.addProfileEntry(profile.id, { selectorType: "catalog_entry", catalogEntryId: entry.id, connectionId, applicationId })
? writer.addProfileEntry(profile.id, { selectorType: "catalog_entry", effect: "include", catalogEntryId: entry.id, connectionId, applicationId })
: change === "profile entry edit" ? writer.updateProfileEntry(profileEntry.id, { effect: "exclude" })
: change === "profile entry delete" ? writer.deleteProfileEntry(profileEntry.id)
: writer.updateProfile(profile.id, { entries: [{ selectorType: "catalog_entry", catalogEntryId: entry.id, connectionId, applicationId }] }));
: writer.updateProfile(profile.id, { entries: [{ selectorType: "catalog_entry", effect: "include", catalogEntryId: entry.id, connectionId, applicationId }] }));
const mutationResult = Promise.allSettled([mutation]);
let finishResult: Promise<PromiseSettledResult<unknown>[]> | undefined;
try {
@@ -279,7 +279,7 @@ describeDatabase("tool definition runtime synchronization", () => {
it.each(["add", "edit", "delete", "replace"] as const)("completes profile entry %s with a single database connection", async change => {
const [profile] = await db.insert(toolProfiles).values({ companyId, profileKey: "single-entry", name: "Single entry" }).returning();
const input = { selectorType: "connection" as const, applicationId, connectionId };
const input = { selectorType: "connection" as const, effect: "include" as const, applicationId, connectionId };
const [entry] = await db.insert(toolProfileEntries).values({ companyId, profileId: profile.id, ...input }).returning();
const single = createDb(database.connectionString, { maxConnections: 1 });
const service = toolAccessService(single);
@@ -301,7 +301,7 @@ describeDatabase("tool definition runtime synchronization", () => {
const [profile] = await db.insert(toolProfiles).values({ companyId, profileKey: "scoped", name: "Scoped" }).returning();
await db.insert(toolProfileBindings).values({ companyId, profileId: profile.id, targetType: "agent", targetId: agentId });
const [entry] = await db.insert(toolProfileEntries).values({ companyId, profileId: profile.id, selectorType: "connection", connectionId }).returning();
const [policy] = await db.insert(toolPolicies).values({ companyId, name: "Scoped", policyType: "deny", selectors: { agentId } }).returning();
const [policy] = await db.insert(toolPolicies).values({ companyId, name: "Scoped", policyType: "block", selectors: { agentId } }).returning();
for (let index = 0; index < 4; index++) {
const before = (await events()).length;
if (change === "entry") await db.update(toolProfileEntries).set({ effect: index % 2 ? "include" : "exclude" }).where(eq(toolProfileEntries.id, entry.id));
@@ -318,7 +318,7 @@ describeDatabase("tool definition runtime synchronization", () => {
});
it("uses old and new policy selectors with the execution match semantics", async () => {
const [policy] = await db.insert(toolPolicies).values({ companyId, name: "Retarget", policyType: "deny", selectors: { agentId } }).returning();
const [policy] = await db.insert(toolPolicies).values({ companyId, name: "Retarget", policyType: "block", selectors: { agentId } }).returning();
const write = async (selectors: Record<string, unknown>, expected: string[]) => {
const before = (await events()).length;
await db.update(toolPolicies).set({ selectors }).where(eq(toolPolicies.id, policy.id));
@@ -365,7 +365,7 @@ describeDatabase("tool definition runtime synchronization", () => {
const [profile] = await db.insert(toolProfiles).values({ companyId, profileKey: "coalesced", name: "Coalesced" }).returning();
await db.insert(toolProfileBindings).values({ companyId, profileId: profile.id, targetType: "agent", targetId: agentId });
const [entry] = await db.insert(toolProfileEntries).values({ companyId, profileId: profile.id, selectorType: "connection", connectionId }).returning();
const [policy] = await db.insert(toolPolicies).values({ companyId, name: "Coalesced", policyType: "deny", selectors: { agentId } }).returning();
const [policy] = await db.insert(toolPolicies).values({ companyId, name: "Coalesced", policyType: "block", selectors: { agentId } }).returning();
let before = (await events()).length;
await db.transaction(async tx => {
await tx.update(toolProfileEntries).set({ effect: "exclude" }).where(eq(toolProfileEntries.id, entry.id));
@@ -383,7 +383,7 @@ describeDatabase("tool definition runtime synchronization", () => {
it("orders scoped policy reorder before company agent fanout", async () => {
const [lower, higher] = [agentId, peerId].sort();
const policies = await db.insert(toolPolicies).values([higher, lower].map((target, index) => ({ companyId, name: `Scoped ${index}`, policyType: "deny", priority: (index + 5) * 100, selectors: { agentId: target } }))).returning();
const policies = await db.insert(toolPolicies).values([higher, lower].map((target, index) => ({ companyId, name: `Scoped ${index}`, policyType: "block" as const, priority: (index + 5) * 100, selectors: { agentId: target } }))).returning();
let locked!: () => void; let release!: () => void;
const held = new Promise<void>(resolve => { locked = resolve; });
const resume = new Promise<void>(resolve => { release = resolve; });
@@ -454,7 +454,7 @@ describeDatabase("tool definition runtime synchronization", () => {
}, 15000);
it("retries changed old references before taking profile runtime locks", async () => {
const [oldApp, newApp] = await db.insert(toolApplications).values(["Old retry app", "New retry app"].map(name => ({ companyId, name, type: "mcp_http" }))).returning();
const [oldApp, newApp] = await db.insert(toolApplications).values(["Old retry app", "New retry app"].map(name => ({ companyId, name, type: "mcp_http" as const }))).returning();
const [profile] = await db.insert(toolProfiles).values({ companyId, profileKey: "retry-references", name: "Retry references" }).returning();
await db.insert(toolProfileBindings).values({ companyId, profileId: profile.id, targetType: "agent", targetId: agentId });
const [entry] = await db.insert(toolProfileEntries).values({ companyId, profileId: profile.id, selectorType: "application", applicationId: oldApp.id }).returning();
@@ -160,7 +160,7 @@ describeEmbeddedPostgres("agent secret routes", () => {
targetType: "agent",
targetId: fixture.agentId,
configPath: "access.API_ONLY_KEY",
projectionClass: "class_2_runtime_only",
projectionClass: "unclassified",
});
const projectBinding = await svc.createBinding({
companyId: fixture.companyId,
@@ -184,7 +184,7 @@ describeEmbeddedPostgres("agent secret routes", () => {
const list = await request(createApp(fixture)).get("/api/agents/me/secrets");
expect(list.status).toBe(200);
expect(list.body.secrets).toEqual([
expect.objectContaining({ key: "api_only_key", delivery: "api", projectionClass: "class_2_runtime_only" }),
expect.objectContaining({ key: "api_only_key", delivery: "api", projectionClass: "unclassified" }),
expect.objectContaining({ key: "env_only_key", delivery: "env" }),
expect.objectContaining({ key: "project_key", delivery: "env" }),
]);
@@ -1,6 +1,7 @@
import express from "express";
import request from "supertest";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { AdapterEnvironmentTestContext } from "@paperclipai/adapter-utils";
import type { ServerAdapterModule } from "../adapters/index.js";
const mockAgentService = vi.hoisted(() => ({
@@ -837,7 +838,7 @@ describe("agent test-environment route", () => {
const { registerServerAdapter, getServerAdapter, unregisterServerAdapter } = await import("../adapters/index.js");
const previous = getServerAdapter("claude_local");
unregisterServerAdapter("claude_local");
const cliProbeSpy = vi.fn(async () => ({
const cliProbeSpy = vi.fn(async (_context: AdapterEnvironmentTestContext) => ({
adapterType: "claude_local",
status: "fail" as const,
checks: [{ code: "adapter_command_missing", level: "error" as const, message: 'Command not found in PATH: "claude"' }],
@@ -44,7 +44,7 @@ describe.skipIf(!support.supported)("agent timer owned deadlines", () => {
beforeAll(async () => {
if (external) await applyPendingMigrations(external);
else temporary = await startEmbeddedPostgresTestDatabase("agent-timer-deadlines-");
db = createDb(external ?? temporary!.connectionString, { idleTimeoutSeconds: 0, connectTimeoutSeconds: 0, maxLifetimeSeconds: 0 });
db = createDb(external ?? temporary!.connectionString, { maxConnections: 4, idleTimeoutSeconds: 0, connectTimeoutSeconds: 0, maxLifetimeSeconds: 0 });
}, 30_000);
beforeEach(async () => {
@@ -57,7 +57,7 @@ const mockInstanceSettingsService = vi.hoisted(() => ({
getGeneral: vi.fn(async () => ({ censorUsernameInLogs: false })),
getExperimental: vi.fn(async () => ({ enableManagedSandboxOnly: false })),
}));
const mockRunClaudeLogin = vi.hoisted(() => vi.fn(async () => ({ ok: true })));
const mockRunClaudeLogin = vi.hoisted(() => vi.fn(async (_input: { config: { env: Record<string, string> } }) => ({ ok: true })));
vi.mock("../services/index.js", () => ({
agentService: () => mockAgentService,
@@ -1103,7 +1103,6 @@ describeEmbeddedPostgres("agent service Claude OAuth binding claim", () => {
companyId: scope.companyId,
ownerUserId: scope.ownerUserId,
adapterType: "claude_local",
environmentId: scope.environmentId,
});
const writer = createSetupTokenSecretWriter({ db });
@@ -32,7 +32,7 @@ if (!support.supported) console.warn(`Managed-account database tests unavailable
const [connection] = await db.insert(toolConnections).values({ companyId: company.id, applicationId: application.id, uid: randomUUID(), name: `${provider} gateway`, transport: "mcp_remote", authKind: "none", credentialPolicy: "shared", status: "active", enabled: true, config: { sourceTemplateKey: provider, url: provider === "arcade" ? "https://api.arcade.dev/mcp/test" : "https://executor.example/mcp", managementUrl: "https://executor.example/team/integrations" } }).returning();
await db.insert(connectionGrants).values({ companyId: company.id, connectionId: connection.id, kind: "organization", status: "active", isDefault: true });
const names = provider === "executor" ? ["integrations"] : ["Notion.ListPages"];
await db.insert(toolCatalogEntries).values(names.map(toolName => ({ companyId: company.id, connectionId: connection.id, name: toolName, toolName, versionHash: "v1", status: "active", riskLevel: "read" as const })));
await db.insert(toolCatalogEntries).values(names.map(toolName => ({ companyId: company.id, connectionId: connection.id, name: toolName, toolName, versionHash: "v1", status: "active" as const, riskLevel: "read" as const })));
let accounts = [{ integration: "notion", owner: "user", connection: "Work", lastHealth: { status: "healthy", checkedAt: Date.now() } }];
let fail = false;
let partial = false;
@@ -88,7 +88,7 @@ if (!support.supported) console.warn(`Managed-account database tests unavailable
expect((await f.access.listAggregatorApps(f.connection.id, f.actor)).apps).toHaveLength(0);
});
it("reports unsupported Executor inventory without trying arbitrary tools", async () => {
const f = await fixture(); await sync(f); f.calls.length = 0; await db.update(toolCatalogEntries).set({ status: "inactive" }).where(eq(toolCatalogEntries.connectionId, f.connection.id));
const f = await fixture(); await sync(f); f.calls.length = 0; await db.update(toolCatalogEntries).set({ status: "disabled" }).where(eq(toolCatalogEntries.connectionId, f.connection.id));
expect((await f.access.syncAggregatorApps(f.connection.id, true, f.actor)).discovery.availability).toBe("unsupported");
expect(f.calls).toHaveLength(0);
expect((await f.access.listAggregatorApps(f.connection.id, f.actor)).apps[0].freshness).toBe("stale");
+3 -3
View File
@@ -24,12 +24,12 @@ describe("personal AI credential preflight recovery", () => {
expect(isAiAuthenticationBlocked({ ...run, livenessState: "blocked" })).toBe(true);
});
it.each([
it.each<[string, unknown]>([
["tool credential", [{ ...missingClaudeKey, envKey: "GITHUB_TOKEN", configPath: "env.GITHUB_TOKEN" }]],
["company secret", [{ ...missingClaudeKey, bindingType: "secret_ref" }]],
["unknown error", [{ ...missingClaudeKey, errorCode: "user_secret_definition_missing" }]],
["adapter field", [{ ...missingClaudeKey, configPath: "apiKey" }]],
...["project", "environment", "routine", "plugin"].map(consumerType => [
...["project", "environment", "routine", "plugin"].map((consumerType): [string, unknown] => [
`${consumerType} binding`, [{ ...missingClaudeKey, consumerType }],
]),
["different agent", [{ ...missingClaudeKey, consumerId: "another-agent" }]],
@@ -44,7 +44,7 @@ describe("personal AI credential preflight recovery", () => {
});
it("only classifies a failed preflight as an inline wait after the card is persisted", () => {
const input = { ...failure([missingClaudeKey]), runStatus: "failed", issue: { status: "blocked" },
const input = { ...failure([missingClaudeKey]), runStatus: "failed", issue: { status: "blocked", title: "Blocked task", description: null },
stdoutExcerpt: null, stderrExcerpt: null, error: null, continuationAttempt: 0, evidence: null };
expect(classifyRunLiveness(input).livenessState).toBe("failed");
expect(classifyRunLiveness({ ...input, authenticationRepairRequested: true })).toMatchObject({
+22 -17
View File
@@ -136,7 +136,7 @@ describe("managed AI connections", () => {
displayName: "Bootstrap", description: "Bootstrap fixture", author: "Test", categories: ["automation"],
capabilities: ["environment.drivers.register"], entrypoints: { worker: "worker.js" },
environmentDrivers: [{ driverKey: "tasks", displayName: "Tasks", supportsTasks: true, configSchema: { type: "object" } }] } });
const [environment] = await db.insert(environments).values({ companyId, name: "Agent computer", driver: "plugin",
const [environment] = await db.insert(environments).values({ name: "Agent computer", driver: "plugin",
config: { pluginKey, driverKey: "tasks", driverConfig: {} } }).returning();
const token = "github_pat_fixture_plugin_bootstrap";
const account = await service.save(companyId, owner, { provider: "github", method: "api_key", ownership: "personal", name: "Bootstrap", apiKey: token, agentIds: [], allAgents: false }, token);
@@ -300,7 +300,7 @@ describe("managed AI connections", () => {
await db.insert(principalPermissionGrants).values([owner, audience].map(principalId => ({ companyId, principalId, principalType: "user", permissionKey: "agents:create" })));
const token = "github_pat_fixture_shared_discovery";
const account = await service.save(companyId, owner, { provider: "github", method: "api_key", ownership: "shared", name: "Shared discovery", apiKey: token, agentIds: [], allAgents: false }, token);
await db.insert(connectionGrantMembers).values([owner, audience].map(subjectId => ({ companyId, grantId: account.grantId, subjectType: "user", subjectId })));
await db.insert(connectionGrantMembers).values([owner, audience].map(subjectId => ({ companyId, grantId: account.grantId, subjectType: "user" as const, subjectId })));
const binding = { provider: "github", method: "api_key", mode: "shared", ...account };
const probe = vi.spyOn(copilotProbe, "probeCopilotConnection").mockResolvedValue({ status: "verified", version: "1.0.88", profileDigest: "fixture-profile", promptSent: false, models: [{ id: "gpt-5.6-luna", label: "GPT" }] });
const app = express(); app.use(express.json());
@@ -1338,7 +1338,7 @@ describe("managed AI connections", () => {
agentIds: [], allAgents: true,
routing: { kind: "local", protocol, baseUrl: "http://127.0.0.1:9000/v1", auth: "none", models: [] },
}, "");
const binding = { provider, method: "api_key", mode: "connection", connectionId: account.connectionId, grantId: account.grantId } as const;
const binding = { provider, method: "api_key", mode: "delegated", connectionId: account.connectionId, grantId: account.grantId } as const;
const config = { model: "fixture-model", env: { OPENAI_API_KEY: "host-key", ANTHROPIC_API_KEY: "host-key" } };
const first = await prepareManagedAiRuntime(db, { ...input, adapterType, responsibleUserId: "alice", binding, config });
const second = await prepareManagedAiRuntime(db, { ...input, adapterType, responsibleUserId: "alice", binding, config });
@@ -1357,7 +1357,7 @@ describe("managed AI connections", () => {
}, "fixture-grok-key");
const taskKey = `grok-history-${randomUUID()}`;
const runInput = { ...input, taskKey, adapterType: "grok_local", responsibleUserId: "alice", config: {},
binding: { provider: "xai", method: "api_key", mode: "connection", connectionId: account.connectionId, grantId: account.grantId } as const };
binding: { provider: "xai", method: "api_key", mode: "delegated", connectionId: account.connectionId, grantId: account.grantId } as const };
const first = await prepareManagedAiRuntime(db, runInput);
const scope = createHash("sha256").update(JSON.stringify([
companyId, agentId, taskKey, first.sessionIdentity,
@@ -1597,12 +1597,12 @@ console.log(JSON.stringify({ type: "result", subtype: "success", result: "hello"
const account = await service.save(companyId, "alice", { provider: "anthropic", method: "subscription", ownership: "shared", name: "Shared usage account", loginSessionId: "fixture", agentIds: [], allAgents: true }, "shared-usage-secret");
const selected = { provider: "anthropic", method: "subscription", mode: "shared", ...account } as const;
const tools = toolAccessService(db);
await tools.replaceConnectionGrantMembers(account.connectionId, account.grantId, ["alice"], { userId: "alice" });
await tools.replaceConnectionGrantMembers(account.connectionId, account.grantId, ["alice"], { actorType: "user", actorId: "alice" });
const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(async () => new Response(JSON.stringify({ five_hour: { utilization: 100 }, extra_usage: { is_enabled: false } })));
try {
await expect(service.probeUsage(companyId, "bob", account.connectionId)).rejects.toThrow("not found");
expect(fetchSpy).not.toHaveBeenCalled();
await tools.replaceConnectionGrantMembers(account.connectionId, account.grantId, ["bob"], { userId: "alice" });
await tools.replaceConnectionGrantMembers(account.connectionId, account.grantId, ["bob"], { actorType: "user", actorId: "alice" });
for (const config of [{ adapterType: "claude_local" }, { adapterType: "paperclip_runner", runnerProvider: "acpx", acpxAgent: "claude" }]) {
const row = await service.select({ companyId, userId: "bob", agentId, binding: selected, ...config });
expect(await service.probeUsage(companyId, "bob", row.connection.id, row.grant.id)).toMatchObject({ status: "ok", overage: { available: false }, limits: [{ limitReached: true }, { allowed: false }] });
@@ -1673,7 +1673,7 @@ console.log(JSON.stringify({ type: "result", subtype: "success", result: "hello"
it("reconnects JSONB routing without changing its identity or access", async () => {
const routing = { kind: "gateway", protocol: "responses", auth: "bearer", baseUrl: "https://gateway.example/v1", models: [{ id: "gateway-model" }] } as const;
const input = { provider: "openai", method: "api_key", name: "Reconnect gateway", ownership: "personal", allAgents: false, agentIds: [agentId], routing: { ...routing, models: [...routing.models] } } as const;
const saved = await service.save(companyId, "alice", { ...input, agentIds: [...input.agentIds] }, "old-gateway-credential");
const saved = await service.save(companyId, "alice", { ...input, agentIds: [...input.agentIds], routing: { ...routing, models: [...routing.models] } }, "old-gateway-credential");
const [stored] = await db.select().from(toolConnections).where(eq(toolConnections.id, saved.connectionId));
expect(stored!.config.ai).toMatchObject({ routing });
expect(stored!.config.sourceTemplateKey).toBe("responses-api");
@@ -1788,7 +1788,7 @@ console.log(JSON.stringify({ type: "result", subtype: "success", result: "hello"
const runId = randomUUID();
const vault = secretService(db);
const definition = await vault.createUserSecretDefinition(companyId, {
key: `ANTHROPIC_API_KEY.${randomUUID()}`, name: "ANTHROPIC_API_KEY for onboarding",
key: `ANTHROPIC_API_KEY.${randomUUID()}`, name: "ANTHROPIC_API_KEY for onboarding", provider: "local_encrypted",
}, { userId: "alice" });
const ownerSecret = await vault.createCurrentUserSecretValue(companyId, "alice", {
definitionId: definition.id, value: "fixture-original-owner-key",
@@ -1877,8 +1877,8 @@ console.log(JSON.stringify({ type: "result", subtype: "success", result: "hello"
}
expect(subEnv.HOME).not.toBe(apiEnvValues.HOME);
expect(subRun.identity).not.toBe(apiRun.identity);
expect(subRun.config.model).toBe(bot.config.model);
expect(apiRun.config.model).toBe(bot.config.model);
expect((subRun.config as Record<string, unknown>).model).toBe(bot.config.model);
expect((apiRun.config as Record<string, unknown>).model).toBe(bot.config.model);
expect(bot).toEqual(original);
expect(aiConnectionBindingSchema.parse(bot.binding)).toEqual(bot.binding);
} finally { await Promise.all([subRun.cleanup(), apiRun.cleanup()]); }
@@ -2005,16 +2005,16 @@ console.log(JSON.stringify({ type: "result", subtype: "success", result: "hello"
const shared = await create("alice", "Engineering", "shared");
const sharedBinding = { ...binding, mode: "shared" as const, ...shared };
const tools = toolAccessService(db);
await tools.replaceConnectionGrantMembers(shared.connectionId, shared.grantId, ["alice"], { userId: "alice" });
await tools.replaceConnectionGrantMembers(shared.connectionId, shared.grantId, ["alice"], { actorType: "user", actorId: "alice" });
const rejection = await service.select({ ...input, userId: "bob", binding: sharedBinding }).catch(error => error);
expect(rejection).toMatchObject({ status: 403, message: "This credential is not shared with the responsible user" });
expect(readAiConnectionConfigurationFailure(rejection)).toBe("ai_connection_credential_not_shared");
expect((await service.list(companyId, "bob", agentId)).some(account => account.id === shared.connectionId)).toBe(false);
await tools.replaceConnectionGrantMembers(shared.connectionId, shared.grantId, ["bob"], { userId: "alice" });
await tools.replaceConnectionGrantMembers(shared.connectionId, shared.grantId, ["bob"], { actorType: "user", actorId: "alice" });
expect((await service.select({ ...input, userId: "bob", binding: sharedBinding })).grant.id).toBe(shared.grantId);
expect((await service.list(companyId, "bob", agentId)).some(account => account.id === shared.connectionId)).toBe(true);
await expect(service.select({ ...input, userId: "alice", binding: sharedBinding })).rejects.toThrow("not shared");
await tools.replaceConnectionGrantMembers(shared.connectionId, shared.grantId, [], { userId: "alice" });
await tools.replaceConnectionGrantMembers(shared.connectionId, shared.grantId, [], { actorType: "user", actorId: "alice" });
for (const userId of ["alice", "bob"]) {
expect((await service.select({ ...input, userId, binding: sharedBinding })).grant.id).toBe(shared.grantId);
}
@@ -2062,7 +2062,7 @@ console.log(JSON.stringify({ type: "result", subtype: "success", result: "hello"
const ae = a.config.env as Record<string,string>, be = b.config.env as Record<string,string>;
expect(ae.ANTHROPIC_API_KEY).toBe("fixture-Alice second"); expect(be.ANTHROPIC_API_KEY).toBe("fixture-Bob first");
expect(ae.HOME).not.toBe(be.HOME); expect(a.identity).not.toBe(b.identity);
expect(a.config.model).toBe("unchanged-model"); expect(config.env.ANTHROPIC_API_KEY).toBe("project-never-use");
expect((a.config as Record<string, unknown>).model).toBe("unchanged-model"); expect(config.env.ANTHROPIC_API_KEY).toBe("project-never-use");
await Promise.all([a.cleanup(), b.cleanup()]); await expect(access(ae.HOME)).rejects.toThrow();
});
it("blocks missing identity, incompatible providers, and cross-company explicit selections", async () => {
@@ -2585,7 +2585,7 @@ console.log(JSON.stringify({ type: "result", subtype: "success", result: "hello"
const selectionInput = { ...input, agentId: futureAgentId, userId: "alice", binding: selected };
await expect(service.select(selectionInput)).rejects.toThrow("not permitted for this agent");
const run = await prepareManagedAiRuntime(db, { companyId, agentId: futureAgentId, responsibleUserId: "alice", adapterType: "claude_local", binding: selected, config: { cwd: home, model: "same-model" }, allowUninstalledShared: true });
expect(run.config.model).toBe("same-model");
expect((run.config as Record<string, unknown>).model).toBe("same-model");
await run.cleanup();
await db.insert(connectionGrantMembers).values({ companyId, grantId: account.grantId, subjectType: "user", subjectId: "bob" });
await expect(service.select({ ...selectionInput, allowUninstalledShared: true })).rejects.toThrow("not shared with the responsible user");
@@ -2595,7 +2595,7 @@ console.log(JSON.stringify({ type: "result", subtype: "success", result: "hello"
expect((await service.select(selectionInput)).grant.id).toBe(account.grantId);
});
it.each([false, true])("validates runner account adoption in the inherited sandbox (inline: %s)", async (inline) => {
it.each([false, true])("validates legacy or task account adoption in the inherited sandbox (inline: %s)", async (inline) => {
const { agentRoutes } = await import("../routes/agents.js");
const { instanceSettingsService } = await import("../services/instance-settings.js");
const targetModule = await import("../services/environment-execution-target.js");
@@ -2609,6 +2609,11 @@ console.log(JSON.stringify({ type: "result", subtype: "success", result: "hello"
await db.insert(agents).values({ id, companyId, name: "Runner adoption", adapterType: "paperclip_runner", adapterConfig: { provider: "codex", model: "gpt-5.6-sol" } });
const account = await service.save(companyId, "alice", { provider: "openai", method: "api_key", ownership: "personal", name: "Runner adoption account", apiKey: "fixture-adoption-key", agentIds: inline ? [] : [id], allAgents: false }, "fixture-adoption-key");
await service.setDefault(companyId, "alice", account.grantId);
if (!inline) {
const [connection] = await db.select().from(toolConnections).where(eq(toolConnections.id, account.connectionId));
await db.update(toolConnections).set({ healthStatus: "unknown", config: { ...connection.config, aiLegacyAdoption: true } })
.where(eq(toolConnections.id, account.connectionId));
}
const tryDeliver = vi.fn(async (interactionId: string) => {
// Dispatch observes committed adoption, never a partially saved choice.
expect((await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.id, interactionId)))[0].status).toBe("accepted");
@@ -2624,7 +2629,7 @@ console.log(JSON.stringify({ type: "result", subtype: "success", result: "hello"
await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId: id, status: "failed", errorCode: "acpx_auth_required", responsibleUserId: "alice", contextSnapshot: { issueId } });
interactionId = (await connectionIntentService(db).requestForRunAuthFailure(runId))!.interactionId!;
}
const target = { kind: "remote", transport: "sandbox", remoteCwd: "/workspace", providerKey: "daytona", runner: { execute: vi.fn(async () => ({ exitCode: 0, stdout: "", stderr: "", timedOut: false })) } } as const;
const target = { kind: "remote", transport: "sandbox", remoteCwd: "/workspace", providerKey: "daytona", runner: { execute: vi.fn(async () => ({ exitCode: 0, signal: null, stdout: "", stderr: "", timedOut: false, pid: null, startedAt: null })) } } as const;
const resolveTarget = vi.spyOn(targetModule, "resolveEnvironmentExecutionTarget").mockResolvedValue(target);
const release = vi.fn(async () => undefined);
const acquire = vi.fn(async () => ({ lease: { id: randomUUID(), provider: "daytona", providerLeaseId: "test-sandbox", metadata: {} }, leaseContext: {} }));
@@ -52,7 +52,7 @@ it("reconnect detaches an indexed credential without changing unadopted legacy a
const input = { companyId, agentId, responsibleUserId: owner, adapterType: "claude_local", binding: { provider: "anthropic", method: "api_key", mode: "delegated", connectionId: connection.id, grantId: connection.grantId } as const, config: {}, allowLegacyValidation: true };
const before = await prepareManagedAiRuntime(db, input);
await before.cleanup();
const resolve = () => vault.resolveUserSecretValue(companyId, { definitionId: definition.id, responsibleUserId: owner, required: true, version: "latest" }, { companyId, responsibleUserId: owner, actorType: "system" });
const resolve = () => vault.resolveUserSecretValue(companyId, { definitionId: definition.id, responsibleUserId: owner, required: true, version: "latest" }, { consumerType: "agent", consumerId: agentId, responsibleUserId: owner, actorType: "system" });
expect((await resolve())?.value).toBe("fixture-original");
await service.save(companyId, owner, { provider: "anthropic", method: "api_key", name: connection.name, ownership: "personal", agentIds: [], allAgents: true, connectionId: connection.id, apiKey: "fixture-new-account" }, "fixture-new-account");
expect((await resolve())?.value).toBe("fixture-original");
@@ -155,7 +155,7 @@ it("isolates sign-in and refresh from the host, survives restart, and completes
expect(await readFile(path.join(hostHome, "auth.json"), "utf8")).toBe(auth("legacy"));
const next = await prepareManagedAiRuntime(db, input);
expect(JSON.parse(await readFile(path.join(String(next.config.env.CODEX_HOME), "auth.json"), "utf8")).tokens.refresh_token).toBe("refresh-rotated-independent");
expect(next.config.model).toBe(input.config.model);
expect((next.config as Record<string, unknown>).model).toBe(input.config.model);
await next.cleanup();
// Cancellation after successful completion must not revoke the saved account.
await login.cancel(companyId, owner, attempt.sessionId);
@@ -3,7 +3,7 @@ import { access, readFile, realpath, rm, writeFile } from "node:fs/promises";
import { promises as fs } from "node:fs";
import { join } from "node:path";
import { and, eq, sql } from "drizzle-orm";
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi, type MockInstance } from "vitest";
import {
agents, authUsers, closeRegisteredClients, companies, companyMemberships, companySecrets, companySecretVersions,
connectionGrants, createDb, userSecretDefinitions,
@@ -36,7 +36,7 @@ describeDatabase("Google AI connection authority with real PostgreSQL", () => {
let db: ReturnType<typeof createDb>;
let embedded: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | undefined;
let isolatedHome: ReturnType<typeof createIsolatedTestHome>;
let request: ReturnType<typeof vi.spyOn<typeof globalThis, "fetch">>;
let request: MockInstance<typeof fetch>;
beforeAll(async () => {
isolatedHome = createIsolatedTestHome();
Loaded 100 of 531 files, more files were not shown because too many files have changed in this diff. Show more