mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-12 06:10:44 +01:00
Merge current upstream and regenerate cursor migration after admission retention
This commit is contained in:
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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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();
|
||||
File renamed without changes.
+11
-199
@@ -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": [
|
||||
|
||||
+553
-2
@@ -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
@@ -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,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";
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Generated
+15
-15
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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));
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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
@@ -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({
|
||||
|
||||
@@ -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" }]],
|
||||
|
||||
@@ -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 () => ({
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
Reference in new issue
Block a user