Merge updated Dot base and current task authority into capabilities

Preserve private task filtering and task monitors, keep independent Dot opt-in, and regenerate the combined tool catalog and seeded protocol fixture.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-07 18:45:10 -05:00
commit 80e3534cf6
520 files changed
+96019 -4755

No files matched your search

+5 -1
View File
@@ -14,8 +14,12 @@
<p align="center">
<a href="https://github.com/paperclipai/paperclip/blob/master/LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue" alt="MIT License" /></a>
<a href="https://github.com/paperclipai/paperclip/stargazers"><img src="https://img.shields.io/github/stars/paperclipai/paperclip?style=flat" alt="Stars" /></a>
<a href="https://www.star-history.com/paperclipai/paperclip"><img src="https://api.star-history.com/badge?repo=paperclipai/paperclip" alt="Star History Rank" /></a>
<a href="https://discord.gg/m4HZY7xNG3"><img src="https://img.shields.io/badge/discord-join-7289da" alt="Discord" /></a>
<a href="https://superagent.sh"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.superagent.sh/api/badge/paperclipai/paperclip/shield.svg?theme=dark&accent=teal&variant=scanned" /><img src="https://www.superagent.sh/api/badge/paperclipai/paperclip/shield.svg?theme=light&accent=teal&variant=scanned" alt="Security posture verified by Superagent" /></picture></a>
</p>
<p align="center">
<a href="https://www.star-history.com/paperclipai/paperclip"><img src="https://api.star-history.com/badge?repo=paperclipai/paperclip" alt="Star History Rank" /></a>
</p>
<br/>
+7 -7
View File
@@ -157,12 +157,12 @@ async function seedValidWorktreeSource(
userId,
role: "instance_admin",
});
await db.insert(companies).values({
id: companyId,
name: "Seed Source",
issuePrefix: "SEED",
requireBoardApprovalForNewAgents: false,
});
// Seed only columns present in the historical source schema. The current
// model includes accounting columns that the worktree migration adds later.
await db.$client`
insert into companies (id, name, issue_prefix, require_board_approval_for_new_agents)
values (${companyId}, 'Seed Source', 'SEED', false)
`;
await db.insert(companyMemberships).values({
companyId,
principalType: "user",
@@ -1384,7 +1384,7 @@ describe("worktree helpers", () => {
.select()
.from(executionWorkspaces)
.where(eq(executionWorkspaces.id, executionWorkspaceId));
expect(executionWorkspace?.metadata).toEqual({
expect(executionWorkspace?.metadata).toMatchObject({
keep: "execution-metadata",
config: {
environmentId: "environment-1",
+14
View File
@@ -226,6 +226,17 @@ When authoring migrations or one-time backfills:
- Split schema changes, index creation, and data backfill into separate phases so each step has clear locking and rollback behavior.
- Treat the `check:migrations` CI gate as the enforcement backstop for these rules. If it flags a migration, rewrite the migration or add a suppression comment with the indexed predicate, batch bound, and reason the remaining scan is safe.
Private-task migrations `0313` and `0314` are explicitly allowlisted in the
Paperclip executor to run outside a file-wide transaction. Their idempotent
keyset batches commit every 1,000 rows, and migration history is recorded only
when all batches finish. The executor repairs invalid concurrent indexes on
retry. A reserved connection holds a session advisory lock across all batch
commits, so concurrent migrators recheck history only after the preceding runner
finishes. Privacy triggers are replaced atomically. Bootstrap uses the same
executor; other migrations remain transactional
per file. Apply these migrations through `pnpm db:migrate` using a direct
connection, before enabling the new server and UI.
## Migration snapshots
`drizzle-kit generate` diffs `packages/db/src/schema/` against the newest snapshot in `packages/db/src/migrations/meta/`. That snapshot must describe the schema that every migration produces when they run in order. A snapshot that drifts from the schema makes the *next* migration wrong, because `generate` folds the drift into it. The drift can add a column that an earlier migration already created, which makes that migration fail on a fresh database. It can also drop a column that the schema still uses.
@@ -290,6 +301,8 @@ These rows are company-scoped and user-scoped. A missing row means the user is j
Both tables use a unique key on `(company_id, user_id, resource_id)` and keep `state` as `joined` or `left`. Join/leave mutations are idempotent board-user `/me` operations and write activity entries when the effective state changes.
Private-project authorization uses the separate `project_access_members` table. Its user/agent rows are security grants, not sidebar preferences, and are evaluated by the same issue-read predicate as issue-level grants. Do not merge or overload these two concepts.
## Decision training snapshot retention
`decision_training_examples` stores a point-in-time copy of an issue, its comments, relevant runs, and the selected decision. Each row carries the `scrub_deleted_comments_v1` retention policy marker, and JSONL exports include that marker alongside the snapshot.
@@ -600,6 +613,7 @@ write requires operator reconciliation before an unattached reservation is
removed. The table stores no response bodies. See `doc/runner-api-tools.md` for
limits and the operator override.
Project `privacy_owner_user_id` records who may manage its audience independently of project read membership. Creation assigns the authenticated user or run responsible user; migration recovers legacy ownership from creation audit evidence. Missing evidence leaves management with administrators.
## Internal agent commentary
`agent_commentary` stores company-scoped, attributed complaints and suggestions
+36 -1
View File
@@ -109,6 +109,11 @@ pnpm build-storybook
These run the `@paperclipai/ui` Storybook on port `6006` and build the static output to `ui/storybook-static/`.
**HTML artifacts** covers secure rendered/raw attachment previews, interactive
reports, security probes, workspace previews, and opening a report from a task.
See [HTML artifact previews](html-artifact-previews.md) for the security boundary,
supported content, and browser verification command.
**Composer → New task** includes agent/user/project/task mentions, skill and
routine slash commands, and populated rich chips on desktop and mobile. Agent
mentions use the same avatars in suggestions, inserted chips, and the mocked
@@ -755,6 +760,36 @@ PAPERCLIP_HOME=/custom/path PAPERCLIP_INSTANCE_ID=dev pnpm paperclipai run
No Docker or external database is required for this mode.
## Issue Privacy Rollout
`PAPERCLIP_ISSUE_PRIVACY_MODE` controls the canonical opt-in issue/project privacy predicate:
- `enforce` (default): return private issues only to implicit principals, issue grantees, or private-project access members; hide private projects from non-members
- `shadow`: log structured would-deny decisions but preserve existing reads; use only for rollout diagnosis
- `off`: skip the predicate and shadow logging
Grant checks read current database state; there is no positive privacy cache.
Sharing a task grants access to that task and its private descendants. Parents
and siblings need separate grants. An agent's access is intersected with its
run's responsible user's current access.
`enforce` is the production default. `shadow` and `off` disclose private data and
are only temporary diagnostic overrides. The old branch's historical sign-off
does not qualify the current runtime. See [ISSUE-PRIVACY.md](./ISSUE-PRIVACY.md)
for the model, validation coverage, break-glass behavior, and residual risks.
Issue-bound heartbeat runs inherit the same read predicate through the stored
`heartbeat_runs.scope_kind = 'issue'` and `issue_id` binding. Issue deletion
nulls the foreign key but preserves the explicit issue scope as a fail-closed
tombstone. Workspace-operation history is either deleted with its direct issue
or run binding, or remains bound to that fail-closed run tombstone, so it cannot
become company-level maintenance data through foreign-key nulling. In enforce mode, direct run detail, transcript,
event, log, and workspace-operation reads return `404` to non-members. Company
run-history and live-run lists retain a metadata-only row with timing, status,
token usage, and cost for budget oversight; issue identifiers, summaries, and
run content are omitted. Runs without an issue binding keep company-level
maintenance-run visibility.
## Storage in Dev (Auto-Handled)
For local development, the default storage provider is `local_disk`, which persists uploaded images/attachments at:
@@ -829,7 +864,7 @@ If a repository is detached or its source configuration changes, its previous ta
## Config Freshness
Agent, project, environment, secret, skill, and workspace config edits are sampled at the next run boundary. A heartbeat that is already running finishes with the config it started with.
Agent, project, environment, secret, skill, and workspace config edits are sampled at the next run boundary. A heartbeat that is already running finishes with the config it started with. Native runners project explicitly configured task environment variables into provider processes and tool commands, including custom `PAPERCLIP_*` names such as `PAPERCLIP_PAGE_BUCKET`. Adding, changing, or removing a projected variable replaces a retained provider process at the next run boundary. Unchanged variables permit process reuse. The projection contains names only; secret values stay in the child environment. Ambient host secrets are not projected. Native projections allow up to 128 variable names, 64 KiB per entry, and 256 KiB of values in total. These native launch limits do not apply to legacy adapter configuration. Runtime authority, provider login credentials, and process-loader settings use their existing restricted paths.
When effective run config changes, Paperclip may intentionally skip a saved adapter session, refresh persisted workspace runtime config, replace a reused execution workspace, or avoid reusing a sandbox/environment lease. Fresh execution can lose adapter-specific session, workspace, or sandbox state; correctness of the next run's config takes priority over continuity. Plain environment values affect freshness through value hashes; run result JSON and workspace operation logs expose only the non-sensitive freshness decision categories, without storing secret values, full env maps, provider credentials, or private path details.
+204
View File
@@ -0,0 +1,204 @@
# Private tasks and projects
Paperclip work is company-open by default. A task or project can instead be
marked private when its title, discussion, documents, attachments, work
products, or agent run traces should be limited to named participants.
## Who can read private work
A private task is readable by its responsible user, creating user, current
user or agent assignee, active task grantees, and members of its private
project. Restrictions flow down through children and run-created handoffs. Sharing or
assigning a task grants access to that task and its descendants; ancestors and
siblings require a separate grant. An assignment grant stays
active until it is explicitly revoked. Current assignment, ownership, private-project
membership, and inherited access remain independent reasons for access. The
sharing panel shows the source; revoking one grant does not remove other reasons.
Making a parent private protects existing descendants in the same transaction.
Moving a task under a private parent inherits privacy. A child cannot be made
public while it inherits a private boundary; making a parent public leaves its
existing private children private.
Private projects have a separate access-member list. A task-level grant can
expose one task without exposing its containing private project. Direct reads
by non-members return `404`, and list, count, search, attention, status-card,
activity, run-history, and tree-control surfaces apply the same predicate.
Visible blocker and mention edges may show a locked identifier-only stub; they
never include the private task's title or content.
Audit logs remain company-wide because they are the sanctioned oversight path.
They contain entity identifiers rather than private task content. Dashboard and
sidebar aggregate counts also remain company-wide: they may include private
work in totals, but do not expose titles, descriptions, or identifiers. The
issue list/count APIs themselves are viewer-filtered.
## Audited break-glass
Company owners and admins do not silently inherit private-task access. A normal
task detail request still returns `404`. When an owner or admin has a legitimate
emergency need, they must deliberately request the task with
`?breakGlass=true`.
Every successful break-glass read writes both:
- an `issue.break_glass_read` audit row containing the actor, task id, and time;
- a warning system notice on the private task so its owners can see that access occurred.
The flag does not change the canonical read predicate, create a grant, or make
future reads implicit. Non-admin members and agents cannot use it.
Cloud customer-success inspection is a separate operator capability. When an
operator enables it, the dedicated Cloud-signed permit can read private task
content within its company scope. Ordinary users, agent API keys, and plugins
cannot use that permit. Cloud enforces the inspection grant, expiration,
revocation, replay protection, and audit trail; this path does not create a task
grant or a task break-glass notice. Operators who require task ACLs on every
content read must leave `PAPERCLIP_CUSTOMER_SUCCESS_INSPECTION_ENABLED` disabled.
See [Cloud customer-success inspection support](CUSTOMER-SUCCESS-INSPECTION.md).
## Agent runs and shared-agent residual risk
Issue-bound run detail, events, transcripts, logs, and workspace operations use
the task ACL. Run authority is the intersection of the agent and the run's responsible user.
Reusing an agent does not transfer a different user's private-task permission.
Company run lists retain only timing/status/token/cost metadata for
non-members so budget oversight continues without exposing task identity or run
content.
Workspace operation records retain their original task and run sources even if
those entities are deleted or the operation is relinked. Deletion preserves the
audit record; unresolved sources make its details and log inaccessible. A
company-scoped run does not override a private task linked to the operation.
> **Residual risk — trusted agents (`trust-agent`).** Paperclip enforces privacy
> on reads from the control plane, but an agent that legitimately processes a
> private task may write learned content into shared persistent memory, its home
> directory, an external tool, or a later public response. V1 deliberately
> trusts the agent not to exfiltrate that context. Use isolated execution
> workspaces and appropriately trusted agents for sensitive work; task ACLs are
> not a sandbox or data-loss-prevention system.
## Plugins and notification/digest surfaces
Plugins (including any Slack notifier or digest plugin) are **non-member
principals**: they hold no company membership, task grant, or private-project
access. Rather than an allowlist of individually patched methods, the plugin
host centralizes the synthetic non-member check into shared helpers
(`requirePluginReadableIssue`, `pluginReadableIssueIdsByIds`,
`pluginRedactRelationSummary`) and routes **every** issue-derived read through
them. The complete inventory of issue-derived reads and their disposition:
- **Direct issue content** — `issues.list` (SQL predicate), `issues.get`,
`issues.listComments`, `issues.listAttachments`, `issues.getAttachmentContent`,
`issueDocuments.list`, `issueDocuments.get`: a private task is filtered from
list results, returns `null`/empty on soft reads, and reports "not found"
(indistinguishable from missing) on document reads.
- **Subtree / orchestration** — `issues.getSubtree` and
`issues.getOrchestrationSummary`: a private *root* reports "not found"; an
open root drops private descendants **before** any relation, document, run,
or assignee is fetched, so no private row or its metadata enters the payload.
- **Relationship metadata** — `issues.getRelations` (and the summaries echoed
by `issues.setBlockedBy` / `addBlockers` / `removeBlockers`, and the subtree
relation maps): blocker/blocks edges pointing at a private task are dropped,
recursing into nested terminal blockers, so its title, status, and assignee
never surface even on a public task.
- **Interactions** — `issues.listInteractions`: a private task yields no
interaction payloads.
A synthetic `{ type: "none" }` actor resolves to the same public-only scope as
any other non-member. This is why a digest broadcast to a shared channel —
whose audience is non-members by definition — cannot carry private issue
content. Writes that operate on a task must also satisfy its read predicate; plugin
capabilities do not substitute for task access. Returned relationship summaries
remain redacted.
There is intentionally **no plugin oversight exception**: unlike the
company-wide audit log, no plugin read path bypasses the predicate. A plugin
that must surface private work would need an explicit, separately designed
oversight capability; none exists today.
> **Residual metadata disclosure.** A plugin still learns aggregate,
> content-free signals about private work that mirror the accepted
> dashboard/count disclosure: `issues.getOrchestrationSummary` returns
> company-wide `openBudgetIncidents`. Its `costs`/token roll-up is limited to
> the readable subtree, and unreadable relationship edges are omitted entirely.
> No private title, body, comment, attachment, identifier, relationship target,
> or locked-edge signal is exposed.
## Rollout modes
`PAPERCLIP_ISSUE_PRIVACY_MODE=enforce` is the default. `shadow` records
structured would-deny decisions without enforcing them and exists only for
rollout diagnosis. `off` disables the task predicate. Operators should not use
`shadow` or `off` when private-task confidentiality is required.
The production gate includes positive and negative checks: an authorized reader
can read a shared child and its descendants, an unrelated reader cannot, and a
warm HTTP or live subscription loses that grant after revocation. Privacy grants
are read from the database without a cross-request positive cache.
## Migration and current runtime integration
Migration `0313_private_task_access.sql` adds the task/project ACL tables and
backfills privacy parent edges with indexed keyset batches. Run binding is derived
from native task identity, explicit issue identity, or legacy `issueId`/`taskId` context. Missing
source tasks remain issue-scoped tombstones. Context changes cannot turn those
runs into company-wide history. The migration is idempotent for preview installs;
existing explicit grants are preserved. Operators upgrading an unreleased preview
should inspect root grants created under its former whole-tree sharing semantics.
Migration `0314_private_task_draft_assets.sql` accompanies enforcement and binds
historical inline images to their first owned task. Unbound drafts remain
uploader-only under the new asset-content guard.
Both privacy migrations use the Paperclip migration executor's explicit
nontransactional path. Indexes on existing tables build concurrently; invalid builds are repaired on
retry. DDL statements commit individually and each 1,000-row
keyset batch commits before advancing, releasing schema and row locks. The
migration journal is written only after all batches succeed; an interruption
leaves the migration pending and its idempotent statements can be replayed.
Bootstrap uses the same executor, and other migrations retain a transaction per
file. Apply these migrations with `pnpm db:migrate`, before enabling the new
server or private-task UI.
Private output uses the same predicate on native tool searches and task context,
linked approvals, training exports, execution workspace APIs, stored run-response
assets, and WebSocket delivery. Workspace access requires permission for every
linked task. Chat publications recheck recipient identity and task access at the
provider boundary: private output is withheld from shared channels and from DMs
whose known recipients no longer qualify. This does not revoke Gmail or other
connection credentials; connection permissions continue to apply independently.
## CI leak-test inventory
The privacy regression gate is part of the normal server Vitest suite. Its
surface coverage is intentionally distributed beside the routes and services
it protects:
| Surface | Non-member regression coverage |
| --- | --- |
| Downward sharing, responsible-user intersection, mutation denial, provenance, live revocation | `privacy-production-review.test.ts` |
| Task detail, list, count, grants, documents, work products | `issue-access-grants-routes.test.ts`, `company-search-service.test.ts` |
| Search and machine extract | `company-search-service.test.ts`, `company-search-extract-service.test.ts` |
| Attention feed | `attention-service.test.ts` |
| Status-card hydrate and dry-run | `status-cards.test.ts` |
| Activity stream | `activity-service.test.ts`, `activity-routes.test.ts` |
| Run list, live run, detail, transcript, events, logs, operation history | `heartbeat-run-privacy-routes.test.ts` |
| Tree holds and tree control | `issue-tree-control-routes.test.ts` |
| Blocker and mention identifier-only stubs | `issue-access-grants-routes.test.ts` |
| Attachment content | `issue-attachment-routes.test.ts` |
| Private-project list and direct read | `projects-list-archived-routes.test.ts` |
| Plugin issue reads (list, get, comments, attachments, orchestration, subtree, relations, interactions, documents) | `plugin-orchestration-apis.test.ts` |
Adding a new task-derived read surface requires a non-member fixture in this
gate before the surface can ship.
Project privacy management belongs to its recorded creator (the responsible user
for agent-created projects), the personal-project owner, and administrators.
Project read membership alone never allows publishing the project or changing
its audience. Legacy projects recover ownership from their creation audit event;
when that evidence is missing, an administrator manages their privacy.
### Task privacy management hints
`GET /api/issues/:id/privacy-constraints` is available only to a principal who can read and manage that task. It returns the blocked scope kind and whether publishing leaves a personal project. It does not return protected parent or project names, identifiers, owners, or contents. This lets task owners manage their task without requiring access to its surrounding project. Visibility writes still enforce the canonical rules under the privacy-tree lock; these hints do not authorize a write.
+19 -1
View File
@@ -30,6 +30,15 @@ Example: A CEO agent's adapter config tells it to "review what your executives a
Then you define who reports to the CEO: a CTO managing programmers, a CMO managing the marketing team, and so on. Every agent in the tree gets their own adapter configuration.
Each person also has a personal primary agent per company. Their first human-created
agent becomes primary automatically; they can choose another from its profile.
An existing primary is replaced only after confirmation. The crown appears on the
profile and roster, while the primary stays first in the Agents sidebar without a
sidebar crown. Stars remain independent. Task drafts, explicit assignments, and
recent choices take precedence over the primary; Chat similarly reopens a valid
recent conversation before falling back to the primary. Opening Chat does not
start execution. This preference does not change the org chart or permissions.
### Agent Execution
Paperclip supports several ways to run an agent's heartbeat:
@@ -135,7 +144,7 @@ Paperclip’s core identity is a **control plane for autonomous AI companies**,
- Do not make the core product a general chat app. The current product definition is explicitly task/comment-centric and “not a chatbot,” and that boundary is valuable.
- Do not build a complete Jira/GitHub replacement. The repo/docs already position Paperclip as organization orchestration, not focused on pull-request review.
- Do not build enterprise-grade RBAC first. Paperclip now has authenticated mode, company memberships, instance roles, and permission grants, but fine-grained enterprise governance should remain secondary to the core company control plane.
- Do not interpret agent-level privacy flags as a project/issue privacy feature in V1; work visibility stays company-scoped.
- Do not interpret agent-profile privacy flags as project/issue privacy. Work remains company-open by default, while explicitly private issues and projects use their own task ACLs.
- Do not lead with raw bash logs and transcripts. Default view should be human-readable intent/progress, with raw detail beneath.
- Do not force users to understand provider/API-key plumbing unless absolutely necessary. There are active onboarding/auth issues already; friction here is clearly real.
@@ -232,3 +241,12 @@ to Tasks. A Views menu on the task list covers the inbox views (Mine, Unread,
Blocked, Recent, Everything) and the task-status views (All, Active, Backlog,
Done). Bare `/issues` opens the last-used view, defaulting to Mine; links that
carry a task filter open All. Old `/inbox` links redirect to the matching view.
### CSV file previews
Task attachment and workspace file tabs render CSV files as tables by default.
The first record supplies column headers. Row numbers, row and column counts,
and sticky headers help operators scan exports. Rendered and raw view icons
remain next to download; raw view preserves the original source. The table
shows up to 500 data rows and 100 columns, with a notice when the preview is
limited. Download retains the complete file.
+94 -5
View File
@@ -37,7 +37,7 @@ These decisions close open questions from `SPEC.md` for V1.
| Company model | Company is first-order; all business entities are company-scoped |
| Board | Single human board operator per deployment |
| Org graph | Strict tree (`reports_to` nullable root); no multi-manager reporting |
| Visibility | Company-scoped visibility: board + all in-company agents can see all work objects by default; public/private deployment flags affect external exposure only and do **not** imply project/issue privacy |
| Visibility | Issues remain company-open by default. Opt-in `private` issues use task ACLs; deployment exposure flags remain independent of work-object privacy. |
| Communication | Tasks + comments only (no separate chat system) |
| Task ownership | Single assignee; atomic checkout required for `in_progress` transition |
| Task watchdogs | A task watchdog is an explicitly configured, issue-subtree-scoped verification and recovery capacity. It may restore live task paths inside the watched subtree; for issue-thread interaction resolution it is an ordinary agent subject to the same audience and containment checks, not board authority, active-run output monitoring, or general liveness recovery. |
@@ -220,6 +220,8 @@ Invariant: at least one root `company` level goal per company.
- `goal_id` uuid fk `goals.id` null
- `name` text not null
- `description` text null
- `visibility` enum-like text: `open | private`, default `open`
- `personal_owner_user_id` text null; unique with `company_id` when present
- `status` enum: `backlog | planned | in_progress | completed | cancelled`
- `lead_agent_id` uuid fk `agents.id` null
- `target_date` date null
@@ -258,6 +260,9 @@ retain their titles and default to no generation request.
- `project_workspace_id` uuid fk `project_workspaces.id` null
- `goal_id` uuid fk `goals.id` null
- `parent_id` uuid fk `issues.id` null
- `visibility` enum-like text: `open | private`, default `open`
- `privacy_root_issue_id` uuid fk `issues.id` null; private-boundary metadata
- `privacy_parent_issue_id` uuid fk `issues.id` null; downward access inheritance, including standalone run-created handoffs
- `title` text not null
- `description` text null
- `status` enum: `backlog | todo | in_progress | in_review | done | blocked | cancelled`
@@ -310,11 +315,39 @@ Invariants:
- `author_user_id` uuid fk `users.id` null
- `body` text not null
## 7.7.1 `issue_access_grants`
- `issue_id` uuid fk `issues.id` not null
- `subject_type` enum-like text: `user | agent`
- `subject_id` text not null
- `source` enum-like text: `explicit | assignment | project`
- `granted_by_user_id` text null
- `granted_by_agent_id` uuid fk `agents.id` null
- `created_at` timestamptz not null
- `revoked_at` timestamptz null
An issue is readable when it is open outside a private project, the actor is an implicit principal (`responsible_user_id`, `created_by_user_id`, `assignee_user_id`, or current `assignee_agent_id`), the actor has an unrevoked grant on the issue or a private ancestor, or the actor is an access member of its private project. This is one canonical predicate shared by detail, list, count, search, and run-derived reads. Instance administrators retain platform-wide access; company owner/admin roles do not imply private-issue read access. Sharing a child covers that child and descendants; ancestors and siblings require separate access. Agent reads also require the responsible user to qualify. A private parent protects existing and newly created descendants; making a parent public does not publish private descendants.
## 7.7.2 `project_access_members`
Authorization membership for private projects, distinct from the personal-sidebar `project_memberships` table:
- `id` uuid pk
- `company_id` uuid fk `companies.id` not null
- `project_id` uuid fk `projects.id` not null
- `subject_type` enum-like text: `user | agent`
- `subject_id` text not null
- `created_at` timestamptz not null
- unique `(project_id, subject_type, subject_id)`
Private projects are absent from list and search results for non-members, and direct reads return `404`. An issue-level grant can still expose its issue without exposing the containing project. A user's `My private tasks` project is created lazily and concurrency-safely on their first unprojected private task; the owning user is its initial and non-removable project member. An agent-created private root also grants that creator issue-level access while retaining the responsible user as the human principal.
## 7.8 `heartbeat_runs`
- `id` uuid pk
- `company_id` uuid fk not null
- `agent_id` uuid fk not null
- `issue_id` uuid fk `issues.id` null; first-class binding for issue-scoped run confidentiality
- `invocation_source` enum: `scheduler | manual | callback`
- `status` enum: `queued | running | succeeded | failed | cancelled | timed_out`
- `started_at` timestamptz null
@@ -417,6 +450,7 @@ Operational policy:
- `cost_events(company_id, occurred_at)`
- `cost_events(company_id, agent_id, occurred_at)`
- `heartbeat_runs(company_id, agent_id, started_at desc)`
- `heartbeat_runs(company_id, issue_id, created_at)`
- `approvals(company_id, status, type)`
- `activity_log(company_id, created_at desc)`
- `assets(company_id, created_at desc)`
@@ -453,6 +487,7 @@ Operational policy:
- Attachment reads are company-scoped and expose stable path metadata: `contentPath`/`openPath` for inline-safe viewing and `downloadPath` for forced download.
- Inline-safe responses use `Content-Disposition: inline`; unsafe types and explicit download requests use `attachment`.
- Script-capable content such as HTML is always served as an attachment with `X-Content-Type-Options: nosniff` and a sandboxed, deny-by-default CSP; it is never rendered inline on the Paperclip origin.
- The board can fetch bounded HTML source for a rich preview in an opaque-origin iframe (`sandbox="allow-scripts"`, without `allow-same-origin`). A trusted CSP precedes artifact markup and blocks remote subresources, API connections, forms, and embeds. Rendered/raw icons sit beside Download. Workspace HTML previews return UTF-8 inside JSON under the existing text limits. See `doc/html-artifact-previews.md`.
- Video attachments are inline-safe and support single `Range: bytes=start-end` requests with `206`, `Content-Range`, and `Accept-Ranges: bytes` for browser playback/seeking.
- Attachment-backed artifact work products use `type: "artifact"`, `provider: "paperclip"`, and metadata with `attachmentId`, `contentType`, `byteSize`, `contentPath`, `openPath`, `downloadPath`, and optional `originalFilename`.
- Workspace-only file references use work product `metadata.resourceRef` with `kind: "workspace_file"`, `issueId`, `workspaceKind` (`execution_workspace` or `project_workspace`), `workspaceId`, `relativePath`, optional `line`/`column`, and `displayPath`. These references point at files in a workspace; they do not replace attachment-backed artifacts for deliverables that must be inspectable without workspace access.
@@ -508,6 +543,32 @@ Decision-desk triage uses company-scoped sidecars rather than adding queue field
- `decision_archive_notification_outbox` records one retry-safe origin-agent notification per source/archive version. The 90-day internal sweeper archives only unkept rows and coalesces delivery per origin agent.
- Queue membership never grants source visibility. Item writes re-authorize the referenced source, and queue reads re-authorize every member before returning rows or counts.
## 7.17 Personal primary agent (2026-10-07)
`user_company_preferences` has a unique company/user key, nullable `primary_agent_id`,
and sticky `primary_agent_initialized` flag. GET/PUT
`/api/companies/:companyId/primary-agent/me` derives the human identity from auth,
checks company access and agent visibility, rejects agent actors, and audits changes.
PUT accepts an approved, non-terminated agent; choosing a previously left agent
rejoins it under the existing membership rules. There is no explicit removal UI or
nullable PUT. Leaving, termination, and deletion clear the reference without
allowing a later creation to initialize it again. Pausing and errors retain it.
Initialization runs in the human-attributed creation transaction, including
onboarding, with uniqueness arbitrating concurrent creation. System provisioning
and agent-authored hires cannot initialize a human preference. Migration backfill
uses the earliest attributable human creation, preserving empty initialized state
when the original is gone, terminated, or left; unknown ownership stays unset.
The profile is the only setting surface. Replacing a primary requires the reviewed
avatar-to-avatar confirmation. A first choice has no modal. Only the profile and
roster show the accessible crown; the Agents sidebar pins the primary first without
duplication. Tasks preserve explicit/draft assignments, then choose a recent
eligible assignee, the eligible primary, or the existing fallback. Chat preserves
a valid recent conversation, then opens the primary, then retains its chooser.
Navigation alone does not create an execution. Failed preference mutations restore
the previous state and offer retry. Preferences and caches are company/user scoped.
## 8. State Machines
## 8.1 Agent Status
@@ -706,7 +767,7 @@ changes so both agent and board edits are visible in the issue activity stream.
## 9.4 Permission Terminology and Default Visibility Rule
Paperclip V1 keeps a company-scoped visibility model as the default because centralized authorization and scoped work-object controls are not yet a core V1 control surface.
Paperclip keeps company-scoped visibility as the default. The task-ACL foundation adds opt-in private issues while preserving `open` for all existing and unspecified issues.
The approved term set is:
@@ -720,8 +781,8 @@ The approved term set is:
## 9.5 Core V1 Rule: what “private” means
- A **private marker** on an agent profile (where represented) does **not** make company-visible work private.
- Company-visible work objects (issues, comments, work products, costs, activity, project/task state) remain visible to the board and in-company agents by default.
- Project/issue-level privacy, scoped assignment-only object visibility, and organization-wide custom ACLs are deferred to Pro/Enterprise controls.
- Work objects remain company-open by default. An issue or project can opt into the shared private-read predicate.
- Organization-wide custom ACL policy remains deferred to Pro/Enterprise controls.
## 9.6 V1 vs Pro/Enterprise Controls (recommended target split)
@@ -732,7 +793,7 @@ The approved term set is:
| Profile visibility | Full profile visibility for coordination and audit | Optional profile redaction / selective sharing for external surfaces |
| Config visibility | Board full read with redacted secret fields; agent config read/write constrained by own agent identity | Scoped config visibility controls and central policy enforcement |
| Assignment/invocation | Assignment creates execution authority; board can reassign or force release | Delegation policies and scoped invokers with deny-listed tool classes |
| Work-object visibility | All issues and projects in-company are visible to board and agents | Project/issue ACLs and reviewer-only channels |
| Work-object visibility | Company-open by default; opt-in private issues/projects with explicit user or agent access | Policy-driven ACLs and reviewer-only channels |
| Tool/secret policy | Secret refs, log redaction, and adapter-level command/webhook restrictions | Tool allowlists with centralized policy evaluation |
| Company skills | Open to authenticated company agents; core enforces invariants and any stored restriction policy | Paperclip EE policy editor, protected-skill controls, presets, simulation, and policy audit UX |
| Inbox management | Responsible agent may archive/unarchive its responsible user's Mine items under a default-open user policy; explicit cross-user access requires saved target-user opt-in or `inbox:manage`; all mutations are audited | Policy administration UX, organization presets, simulations, bulk controls, and richer audit/reporting surfaces |
@@ -1139,6 +1200,9 @@ Invites tab does not hide this Cloud action.
- `POST /companies/:companyId/issues`
- `GET /issues/:issueId`
- `PATCH /issues/:issueId`
- `GET /issues/:issueId/access-grants`
- `POST /issues/:issueId/access-grants`
- `POST /issues/:issueId/access-grants/:grantId/revoke`
- `GET /issues/:issueId/documents`
- `GET /issues/:issueId/documents/:key`
- `PUT /issues/:issueId/documents/:key`
@@ -1198,6 +1262,11 @@ assignee/run ownership checks, and the `issue.released` activity event still app
- `POST /companies/:companyId/projects`
- `GET /projects/:projectId`
- `PATCH /projects/:projectId`
- `GET /projects/:projectId/access-members`
- `POST /projects/:projectId/access-members`
- `DELETE /projects/:projectId/access-members/:memberId`
Project list, search, direct reads, and nested project routes apply private-project access. Non-members receive the same not-found behavior as missing projects.
## 10.6 Current-user Resource Memberships
@@ -1485,6 +1554,20 @@ for contracts, recovery behavior, Storybook, and acceptance workflows.
Board may override by raising budget or explicitly resuming agent.
Native runs retain final usage receipts during a bounded accounting-only drain
after a governed wait cancels provider work. This does not accept late messages,
tool calls, or new completion proposals. Missing or incomplete receipts continue
to block budget admission.
A controller that detaches for server restart loses checkpoint and completion
write authority. A closing event stream is not proof that the governed run
finished; the replacement controller must adopt and settle the original run.
Complete direct Anthropic API receipts for `claude-sonnet-5` can use a versioned
list-price estimate when the provider supplies no run price. The receipt records
the rates and assumptions. Aggregate cache writes use the one-hour rate because
their TTL is unknown. Estimates are not invoices. Other models, billers, unknown
billing types, and incomplete receipts remain unpriced.
## 13.3 Cost Event Ingestion
`POST /companies/:companyId/cost-events` body:
@@ -2015,3 +2098,9 @@ as free-form text in the instance database. Legacy agents use the default
in standard, ask, and planning modes. Submission never changes task disposition
or routes feedback externally. See [Agent commentary](agent-commentary.md) for
authentication, replay, document-sized limits, inspection, and deletion semantics.
## Company decision-model service
Company Settings → General can configure one shared API-key connection for optional internal decisions. V1 supports OpenAI Decisions and Jev through OpenRouter. Companies start unconfigured; background sponsorship defaults on when configured, while an explicit off setting persists. Human and agent calls retain current responsible-user, connection audience, resource, and agent installation checks. Only explicitly registered internal background features can use company sponsorship.
The internal service provides local-only availability and bounded, reauthorized execution. It records metadata-only invocation history and independent fractional service charges in the existing cost ledger, applying company and applicable agent/project budgets under the accounting lock. Unknown dispatch charges retain reservations until audited resolution. Settings/testing require connection management permission; history uses existing cost visibility. See [decision-models.md](decision-models.md) for the contract, supported models, accounting, privacy, and endpoints.
+18 -1
View File
@@ -91,6 +91,13 @@ The Board sets Company-level budgets. The CEO can set budgets for Agents below t
## 2. Agent Model [DRAFT]
A human's personal primary agent is a company-scoped navigation and assignment
preference, independent of the org chart, roles, stars, and authority. First human
creation initializes it automatically. Later explicit choices persist across
devices; recent task/chat choices take precedence. The profile owns the setting
and replacement confirmation. See the personal-primary addendum in
`SPEC-implementation.md` for persistence and lifecycle rules.
Every employee is an agent. Agents are the workforce.
### Cryptographic identity
@@ -178,7 +185,7 @@ Hierarchical reporting structure. CEO at top, reports cascade down.
**Full visibility across the org.** Every agent can see the entire org chart, all tasks, all agents. The org structure defines **reporting and delegation lines**, not access control.
Visibility settings on an agent profile (where supported) do not alter company-level visibility for tasks, projects, issues, comments, costs, or activity. Those work-object privacy controls are not a V1 feature until centralized scoped authorization is in place.
Visibility settings on an agent profile do not control task privacy. Work is company-open by default; opt-in private tasks and projects use centralized authorization as specified in `SPEC-implementation.md` and `ISSUE-PRIVACY.md`. Sharing a child grants access to it and its descendants. Ancestors and siblings require separate access.
Each agent publishes a short description of their responsibilities and capabilities — almost like skills ("when I'm relevant"). This lets other agents discover who can help with what.
@@ -326,6 +333,8 @@ recipients must be valid and authorized in the company before a question is
saved. See `SPEC-implementation.md` §9.8.1 for the resolver contract.
Experimental Agent Chat presents one persistent task per person and agent as a simplified conversation. Chat has a searchable secondary sidebar with agent avatars; adding an agent starts or reopens their single conversation. It retains the task composer, transcript, tools, attachments, documents, and existing Subtasks panel, with the task's selected visibility. New execution tasks are ordinary project tasks, not structural children of the conversation; private-source restrictions still flow through their run provenance. Idle conversations wait for a message without entering execution-task work queues. Agents clarify goals here and create assigned tasks for substantial execution. `/new` resets provider context at an ordered session boundary within the same task while preserving visible history. `enableAgentChat` is disabled by default; the V1 lifecycle and rollout contract is specified in `SPEC-implementation.md`.
### Implications
- An agent's "inbox" is: tasks assigned to them + comments on tasks they're involved in
@@ -487,6 +496,10 @@ No separate "agent API" vs. "board API." Same endpoints, different authorization
Paperclip manages task-linked work artifacts: issue documents (rich-text plans, specs, notes attached to issues) and file attachments. Agents read and write these through the API as part of normal task execution. Full delivery infrastructure (code repos, deployments, production runtime) remains the agent's domain — Paperclip orchestrates the work, not the build pipeline.
Self-contained HTML reports can render in an opaque-origin sandbox. The report
cannot access the board's cookies, storage, or DOM. Users can switch to the raw
source beside Download, which returns the original file.
Users may start a task with only a prompt. Paperclip uses a short prompt slice as
its initial title and asks the assigned agent to name the task early. Explicit
user titles are preserved, and naming does not change task execution state.
@@ -737,3 +750,7 @@ as free-form text in the instance database. Legacy agents use the default
in standard, ask, and planning modes. Submission never changes task disposition
or routes feedback externally. See [Agent commentary](agent-commentary.md) for
authentication, replay, document-sized limits, inspection, and deletion semantics.
### Managed decision models
A company may configure a shared decision model for optional Paperclip features. The instance owns credential resolution, authorization, budget admission, and attributable service charges. Company-sponsored background use is enabled by default during configuration; explicit opt-out persists. User and agent requests keep their own access boundaries and cannot become sponsored background requests after denial. Availability is a cheap local capability check, and metadata-only request history makes service usage inspectable. The implemented V1 contract is in [decision-models.md](decision-models.md).
+9
View File
@@ -58,6 +58,15 @@ removes it if startup fails. The child's environment values remain unchanged.
If the launch shell fails before the wrapper emits a protocol event, the run
log retains the shell's stderr alongside the exit code.
The process-session bridge also writes one bounded terminal diagnostic to the
run's stderr. It distinguishes a remote event, input delivery failure, output
poll failure, output stream failure, and unexpected authenticated proxy closure.
Exit codes and signals are validated; unavailable values are `unknown`. The line
contains no raw error message, command, path, or environment value. It records
the first observed terminal event, not proof of remote shutdown. An error frame
drains to the local proxy before the connection closes, and a failed or stalled
diagnostic write does not delay failure delivery or teardown.
The settlement sequence is the one live cleanup owner for every settled path. It
claims the ledger once, makes the pure reuse decision, then runs the ordered
steps:
+1 -1
View File
@@ -10,7 +10,7 @@ No agent defaults or saved model selections are migrated.
| Adapter | Changes from the audit |
| --- | --- |
| Codex and the Codex runner catalog | Add GPT-6.1 Sol (`gpt-6.1-sol`). Expose efforts through Ultra and Fast mode, as documented. Keep `gpt-5.6-sol` as the default. |
| Codex and the Codex runner catalog | Add GPT-6.1 Sol (`gpt-6.1-sol`). Expose efforts through Ultra and Fast mode, as documented. Keep `gpt-5.6-sol` as the default. Follow-up (October 6, 2026): with ChatGPT sign-in the Codex backend accepts `gpt-6.1-sol` only from Codex CLI 0.159.0 or newer (0.156.1 and older are rejected with "The 'gpt-6.1-sol' model is not supported when using Codex with a ChatGPT account."), and `gpt-6-sol` / `gpt-6-luna` entered the bundled catalog in 0.157.0. The adapter now records these floors; the environment Test and the remote runner compare the installed `codex --version` against them, so a sandbox image baked before the pin moved reports the stale CLI instead of an account error. |
| Claude on Bedrock | Add Sonnet 5.5 (`us.anthropic.claude-sonnet-5-5`), using the catalog's existing US inference-profile convention. Sonnet 5.5 IDs (direct or Bedrock-qualified) get the documented `xhigh` and `max` efforts and require Claude Code 2.1.284 or later on the CLI lane. |
| OpenCode | Add `openai/gpt-6.1-sol` and `anthropic/claude-sonnet-5-5` to the static fallback used by remote environments. Both IDs are present in the OpenCode model registry. |
| Claude Code (direct) | No change in this audit. [#14993](https://github.com/paperclipai/paperclip/pull/14993) (merged October 5, 2026, superseding #14816) adds Sonnet 5.5 (`claude-sonnet-5-5`, Claude Code 2.1.284 or later) and refreshes the Claude runtime. |
+6
View File
@@ -37,6 +37,12 @@ workspace: their independent agent copy therefore lives under the excluded
`.paperclip-runtime/agent-files/<agent>/<run>/` area. It is not included in task
workspace sync, Git staging, or task deliverables.
Claude CLI runs keep the working-copy location in each run's prompt, separate
from the cached system instructions. A new copy path alone does not reset the
task session. Each turn names the current copy for relative file references;
instruction or enabled skill content changes still invalidate the prompt bundle.
Sessions saved with the older path-bearing bundle start fresh once after upgrade.
Regular files (including binary bytes) and directories are supported, up to
100,000 entries (files and folders), 256 MiB per file and 2 GiB total. Symlinks,
hardlinks, and special
+13
View File
@@ -57,6 +57,12 @@ grants consent. A provider connection is not proof that its underlying app is
authorized. Multiple matching aggregator apps are returned as candidates; the
agent searches the selected `aggregator.targetService` to get its provider question.
A fuzzy or partial external app match remains a suggestion alongside authorized
installed capability matches. It cannot replace those matches with a mandatory
provider question. For example, a query for a page service can return the
installed page tools alongside a Page X suggestion; selecting the exact Page X
service is required before that external route asks for provider consent.
## Regression coverage
```sh
@@ -69,3 +75,10 @@ Circleback/Attio/ClickUp, indexed Executor tools, and provider consent. The nati
tool authority test searches a paragraph longer than the old 200-character limit
and then creates a real connection interaction. Existing tests retain checks for
private metadata, administrative denials, stale identities, and saved declines.
When `connection_request` rejects an unknown `toolNames` entry, its error includes
up to twenty exact names from that eligible connection's active tool catalog.
This is discovery metadata: the request still fails with 422, grants no access,
and creates no approval card. The agent must select the needed exact names and
submit a new request; normal user approval, catalog-version and policy checks
still apply. Ineligible connections and inactive catalog entries are excluded.
+73
View File
@@ -0,0 +1,73 @@
# Company decision models
Company Settings → General → **Decision model** configures one shared API connection for optional Paperclip features. New companies remain unconfigured. A connection manager selects or adds an OpenAI or OpenRouter connection, saves, and can run a small billed setup test. Test answers appear only in that session; request metadata and charges appear under Activity → Costs → Decisions.
**Allow company-sponsored background decisions defaults on.** Saving authorizes internal background features to charge the selected connection without a responsible human. An explicit off setting survives later edits, including changing the connection or omitting the field from an update. Deleting the selected connection or grant preserves the setting and blocks calls until a valid replacement is selected. Disabling decisions prevents new calls. It does not cancel or erase calls already dispatched.
V1 supports native OpenAI Decisions (`gpt-6-luna`) and OpenRouter Decisions (`typesafe/jev-1.13`). It accepts company-owned API keys only. Personal grants, subscription credentials, custom endpoints, fallback chains, images, and public agent/plugin execution tools are outside this version. Decision compatibility is separate from CLI-adapter compatibility.
## Internal service
Register a feature once in its server module, then call `decisionModelService(db)` with trusted server context:
```ts
const classifyTask = defineDecisionFeature("tasks.classify");
const service = decisionModelService(db, { budgetHooks });
const context = { companyId, actor: req.actor, feature: classifyTask, issueId };
const available = await service.availability(context);
if (!available.available) return existingBehavior();
const result = await service.decide(context, {
state: { title: task.title },
questions: {
billing: { type: "boolean", instructions: "Is this about billing?" },
team: { type: "choice", instructions: "Which team should handle it?",
criteria: { billing: "Payments", support: "Product support" } },
urgency: { type: "score", instructions: "How urgent is it?",
criteria: ["Routine", "Soon", "Immediately"] },
},
});
```
The setup test is the only product caller in this release. Future callers must supply the existing budget enforcement hooks, handle unavailable/failure outcomes, and select their own thresholds. Boolean answers are probabilities, choice answers select a category, and scores use the ordered criteria's zero-based positions. Available distributions and provider confidence are preserved in returned answers. `succeeded` and `failed` results include an invocation ID and usage with reported, estimated, or unknown cost. Refusals and invalid responses never become invented answers.
`availability` reads local configuration and permissions only. It does not decrypt credentials, contact a provider, or promise reachability/budget admission. `availabilityForRequest()` returns a memoizing function; keep that function and its context object within one request. Dispatch never trusts this cached authorization.
Human calls use the authenticated user and retain the connection's audience checks. Agent calls reread the active persisted run and its current responsible user, check that user's current membership/resource access, and require an applicable agent/company connection installation. Task and project references must belong to that run/company. Attribution snapshots the effective identity at dispatch. Explicit internal system contexts additionally require a feature registered with `{ background: true }` and company sponsorship. A denied user/agent call cannot fall back to system context.
Credentials are resolved through the existing secret service immediately before admission, with the actual user/agent/system audit attribution. Authorization, connection/configuration revisions, current run identity, and budgets are rechecked before dispatch. Requests allow 1–20 named questions, text/JSON state, and at most 32 KB serialized request data. Execution is limited to 30 seconds and SDK paid retries are disabled.
## Accounting and privacy
`decision_invocations` records feature, actor/responsible user, applicable task/project/agent/run and identity revision, connection/grant, provider/model, request ID, timing, status, question types/count, token counters, and a cost-ledger reference. It does not store state, question text, answers, or raw provider payloads. SDK input/output telemetry is disabled and provider errors are reduced to bounded codes. These are local database records, not first-party telemetry events.
Cost events have `usageKind: decision` and may have no agent. Agentless reporting uses **Paperclip services**. The public agent cost-reporting API still requires an agent; only the internal service receipt path accepts agentless charges. Decision events snapshot responsible-user attribution; historical run costs continue to use their existing attribution rules. Decision events have no heartbeat receipt reference, so run receipt reconciliation cannot count them again.
Admission and settlement use the company accounting lock. Company and applicable agent/project budgets include held reservations and observed spend. Reservations use the largest configured applicable reservation amount. Settlement is idempotent. Explicit provider authentication/validation/rate-limit rejection releases the hold when no model work occurred. Uncertain dispatches, timeouts, and unknown prices remain unpriced with the reservation held. Startup/periodic accounting recovery marks stale invocations interrupted without resubmitting them. Use the existing audited cost adjustment workflow to resolve the charge and release its hold, including a verified zero charge when appropriate.
OpenRouter-reported USD costs are converted to fractional cents. OpenAI uses a dedicated, versioned Decisions rate snapshot: $0.10 per million input tokens for the bounded short requests at the native default endpoint. No chat-model price is substituted. Missing usage or an unsupported rate stays unknown; it is never displayed as a free request. The ledger retains seven decimal places in cents. Pricing provenance accompanies the cost event and remains available to accounting corrections.
History follows company cost visibility and rechecks task access before returning task/run links. It shows the latest 100 requests in the selected date window. Input/output contents remain absent after refresh.
## HTTP surface
All paths are under `/api/companies/:companyId/decision-model`:
| Method/path | Access and behavior |
| --- | --- |
| `GET /` | Board; returns manager capability and settings/compatible choices to connection managers |
| `PUT /` | Connection manager; validates and audits configuration |
| `GET /availability` | Authenticated company caller; local authorization check |
| `POST /test` | Connection manager; fixed server-owned three-question sample, ignoring client prompts/identity |
| `GET /history` | Existing company cost-read rules; date bounds and limit up to 500 |
There is no general HTTP decision execution endpoint.
## Provider dependencies and verification
The experimental Vercel contract is hidden behind Paperclip-owned types. Dependencies are pinned to `ai@7.0.130`, `@ai-sdk/openai@4.0.86`, and `@openrouter/ai-sdk-provider@3.1.0`. That OpenRouter release calls its compatible model factory `evaluationModel`; the wrapper adapts it to the current Decisions interface. Upgrade these together and rerun native wire-format tests.
CI owns lockfile updates. Before a direct Docker build of a feature checkout whose dependency manifests changed, refresh the local build context with `pnpm install --resolution-only --ignore-scripts --no-frozen-lockfile`. Do not commit that generated lockfile; the hosted Docker workflow performs the same preparation and the lockfile bot updates master.
References: [OpenAI Decisions](https://developers.openai.com/api/docs/guides/decisions), [Vercel AI SDK](https://ai-sdk.dev/docs/reference/ai-sdk-core/decide), [OpenRouter Decisions](https://openrouter.ai/docs/api/api-reference/alphadecisions/submit-a-decisions-questions-and-answers-request).
Automated coverage lives in the provider contract and database-backed decision service tests. Provider tests use actual pinned SDK adapters with simulated HTTP responses; they do not establish live account/model availability. Storybook's **Decision models** group uses production components and includes settings states, metadata history, and a settings-to-history journey. A live acceptance check must run the fixed setup test with each authorized shared provider connection and inspect the corresponding persisted invocation/charge. Record live and simulated evidence separately.
+48
View File
@@ -211,6 +211,22 @@ New comments received during an execution hold retain their individual deferred
The conversation groups repeated empty pre-start reconciliation cancellations into a neutral waiting notice. Started runs, actual startup failures, and run history remain inspectable. No historical run records are deleted.
An unstarted legacy conversation retry waits on its existing `scheduled_retry`
row while a previous execution still owns a process or environment lease. Each
sweep moves the next check forward without creating another run or incrementing
retry accounting. The wait survives a controller restart. A queued retry that
encounters the same ownership hold returns to that scheduled state. Once
ownership is released, normal promotion and dispatch gates still apply,
including reassignment, cancellation, budget limits, and no-replay holds.
The **Retry now** action returns `waiting` with the saved retry schedule while
cleanup holds ownership. Its controls show the wait without claiming that a run
started, and another click can check the gate again.
Recovery instructions in a wake describe only an active or escalated recovery
action. Resolving or cancelling that action removes its repair instructions from
subsequent wakes, so the original worker can resume the task. This prompt change
does not clear any persisted execution reconciliation hold.
Workspace contention (`workspace_busy`) displays **Waiting for workspace** and
continues automatically when the workspace is available. Internal scheduling
attempts remain in the run log without conversation cancellation markers,
@@ -656,6 +672,21 @@ An issue monitor is a one-shot deferred action path for agent-owned issues in `i
Use a monitor when the current assignee owns a future check against an async system or external service. Examples include Greptile review loops, GitHub checks, Vercel deployments, or provider jobs where the agent should come back later and decide what happens next.
Native runners in standard execution use `set_task_monitor({ taskId?, idempotencyKey, monitor })`. Omit `taskId` for the current task. An explicit target must be accessible, in the same company, assigned to the caller with no human assignee, and `in_progress` or `in_review`. Runtime-management permission still applies. Ask, planning, and review-only runs cannot use this tool. Generic `call_api` lifecycle restrictions remain in force.
To wait for a check:
1. Set a future `monitor.nextCheckAt` and short `monitor.notes` describing the check, with optional service context and bounds.
2. Confirm the receipt's persisted task ID, monitor state, next-check time, and bounds.
3. Call `paperclip_finish` with `reportedWorkDisposition: "yielded"` and `continuation: { kind: "monitor", summary, idempotencyKey }`. Report outstanding work honestly; the scheduled check may still block completion.
4. End the run. Paperclip keeps the task active, releases execution ownership, and the one-shot scheduler later wakes it with `issue_monitor_due`. This does not enqueue an immediate continuation. On resume, `get_task_context.activeTask.monitor` includes the consumed monitor’s notes and attempt count.
Only a valid persisted monitor on the current task authorizes that finish; scheduling another owned task does not. Authority is checked again under the final disposition lock. A timer that becomes due during an active native execution remains scheduled until execution releases it. Dispatch checks the current schedule, claim, status, and assignee so an older dispatch cannot clear a replacement monitor.
The service name `AI provider quota` is reserved for server-owned recovery of legacy runs. Native task monitors reject it; use ordinary service context and notes when scheduling a provider-usage check.
Use a new idempotency key to replace the schedule or clear it with `monitor: null`. Retrying the original call returns current monitor state without re-arming a consumed, replaced, or cleared timer. Monitor changes, audit activity, and mutation receipts are committed together, and unrelated execution/review policy is preserved. Legacy agents continue to use the issue API.
Monitor policy lives under `executionPolicy.monitor` and includes:
- `nextCheckAt`: when Paperclip should wake the assignee
@@ -1351,6 +1382,23 @@ controller must recover it through the authenticated ownership checks. This
preserves active work and queued messages without treating a server restart as
user cancellation.
When a native turn creates a governed approval wait, its checkpoint retains the
exact committed triggering event and yielded disposition before interrupting the
provider. Shutdown gives these turns a shared, bounded 20-second window to finish
settling their usage and terminal evidence before detaching. Recovery verifies
the trigger against the retained event ledger and settles that same turn, even
if the user has already answered the interaction. It replays retained accounting
through the ordinary control-plane observers without starting another model turn.
When a later heartbeat run reuses the provider session, it clears that prior
run's wait marker along with its terminal, result and pending-request authority.
Already-numbered cancellation acknowledgements and cancelled/expired runtime requests
are retained during shutdown so recovery can replay a contiguous journal. They
carry no permission to start work. Stream closure, stream failure or drain timeout
after complete usage leaves the saved wait unfinalized until a provider terminal
is retained; complete token counters alone cannot certify a stopped provider.
A timeout, missing accounting, or an unproven provider terminal remains a failure;
the saved wait does not certify successful provider execution.
Before either shutdown path exits, idle warm sessions close through their
normal suspend-and-checkpoint path. Remote sessions therefore leave verified
backup authority for the next controller even though their last run is already
+43
View File
@@ -0,0 +1,43 @@
# HTML artifact previews
HTML attachments and workspace `.html`/`.htm` files open rendered by default.
The eye and code icons beside Download switch between the report and its original
source. Download always returns the original file. Empty and oversized attachments
use the existing text-viewer states and limits.
The shared `HtmlArtifactPreview` uses `srcdoc` in an iframe with
`sandbox="allow-scripts"`. **Never add `allow-same-origin`.** Its opaque origin
prevents artifact JavaScript from reading Paperclip cookies, storage, or the parent
DOM. Forms, popups, downloads from inside the frame, and top navigation are not
granted. The host does not accept messages or privileged requests from artifacts.
A trusted CSP is installed before any artifact markup. It permits inline scripts
and styles for self-contained tables, charts, filtering, and other report controls.
It blocks API connections, remote scripts/styles/images/fonts/media, child frames,
objects, base URLs, and form submissions. Embedded data images/fonts/media work.
An artifact's own CSP cannot weaken the first policy. No artifact HTML is parsed
or inserted into the host DOM. The iframe sends no referrer.
This supports self-contained reports. Reports that need CDN libraries or external
resources must embed them instead. Ordinary external links cannot open new tabs
or navigate the host. The sandbox does allow navigation within the artifact frame;
its opaque-origin restrictions remain after navigation. This is not a claim of
complete network isolation or protection against resource exhaustion by scripts.
Attachment HTTP serving retains forced download, `nosniff`, and its existing
sandbox CSP. Workspace previews return bounded UTF-8 inside JSON, and workspace
downloads retain `Content-Disposition: attachment`. No database or API shape changes
are needed.
Review **HTML artifacts** in Storybook for the production viewer, security probes,
workspace panel/file sheet, and a task-to-report journey. Run the browser security
checks with:
```sh
pnpm exec playwright test --config tests/html-preview/playwright.config.ts
```
These checks use Chromium and simulated report data. They verify working report
controls, raw mode, cookie/storage/parent access denials, blocked resource and API
requests, and denied top navigation/popups. They do not replace testing the actual
attachment journey in a running Paperclip instance.
+32 -3
View File
@@ -269,9 +269,9 @@ environment variables: `SENTRY_DSN_FRONTEND` for the browser and
`SENTRY_DSN_BACKEND` for the server. Each variable is optional. A
specific variable always wins for its own component; a legacy variable,
`SENTRY_DSN`, supplies a component that has no specific value set. An
empty string counts as absent for all three variables. The feature uses
built-in Sentry options only. It adds no `beforeSend` hook and no custom
filter code.
empty string counts as absent for all three variables. SDK configuration uses
built-in Sentry options only. It adds no `beforeSend` hook. The run-failure
reporter selects reportable outcomes before calling the SDK, as described below.
The server is inactive when the backend DSN resolves to `null`; then it
imports no Sentry package. The browser is inactive when the front-end DSN
@@ -434,6 +434,15 @@ They do not change the ambient Sentry scope, whose isolation is unavailable
without an OpenTelemetry context manager. Later, unrelated exceptions must
not inherit a previous run's identity or fingerprint.
Known missing-secret configuration blockers are kept in the task's run log,
blocked state, and owner recovery action, without a Sentry run-failure event.
This requires a failed `configuration_incomplete` run in the preparing stage,
a setup-phase report, explicit proof that provider work did not start, and a
nonempty list of recognized missing or inactive secret bindings. Process exit
evidence, unknown binding reasons, secret-provider failures, ambiguous missing
secret-definition lookups, and workspace failures remain reportable. This filter
does not change task recovery, credentials, or execution policy.
The `run_failure` context also includes the recorded process `exitCode` and
`signal`, so a generic adapter error can still distinguish a nonzero exit from
a signal termination. Exit codes must fit the database's signed 32-bit integer;
@@ -515,6 +524,19 @@ and native runs:
- `run_exception_0` through `run_exception_3`: exception names, codes, HTTP
statuses, and request IDs for a caught exception and up to three causes.
The orphan reaper records a bounded `processLossDiagnostic` before status writes
or cleanup. For `process_lost` failures, `run_execution` includes
`processLossPidRecorded`, `processLossGroupRecorded`, and `processLossLocalCheck`
(`not_observed_alive`, `not_checked`, or `no_identifiers`). It also includes
`processLossRunPredatesObserver`, `processLossObserverUptimeMs`, and
`processLossLastOutputAgeMs` when the timestamps are available and valid. Ages
above seven days are omitted. These are observations, not proof of an OOM,
provider failure, or deployment. The last-output age can include system output.
`processLossRetryEligible` records eligibility for the existing process-loss retry
at detection; it does not claim that a retry was queued or succeeded. This data
contains no process IDs, task text, paths, or credentials and changes no recovery
or ownership decisions.
ACP turns record `acpLastEventAgeMs`, `acpObservedEventCount`,
`acpPendingToolCount`, and `acpToolInventoryComplete` at finalization, before
usage reads, error logging, and cleanup. The age measures time since the last
@@ -554,6 +576,13 @@ integers from 1 through 255. These fields accompany a known restore failure code
only. They omit error messages, raw command lines, paths, process output, and arbitrary
cause data. Git error wrappers preserve only these safe codes and numbers for
diagnostics, without adding the original error as a cause.
Native sandbox `environmentSyncOut` failures carry the same allowlisted codes
and bounded HTTP/exit statuses across the plugin worker RPC boundary. The host
revalidates that optional envelope and retains it only for restore diagnostics.
The envelope excludes provider messages, response bodies, names, paths, and
credentials. The existing RPC error message and code remain unchanged, as do
restore classification, retries, and source retention. Older workers without the
envelope still report `unknown` when no structured cause is available.
For `git_integration`, optional `workspaceRestoreGitCommand` identifies the fixed
command family: `rev_parse`, `symbolic_ref`, `merge_base`, `merge_tree`,
`commit_tree`, `update_ref`, or `log`. `workspaceRestoreGitFailureKind` is
+2 -2
View File
@@ -61,7 +61,7 @@ Manual browser and device consent still require an operator role. Device codes a
and organization. The gateway's Connections invitations remain personal
assistant invitations; use the agent's Dot connection panel for Runner pairing.
Database migration `0312_broken_sentinels.sql` adds only Dot tables and extensions
Database migration `0317_messy_famine.sql` adds only Dot tables and extensions
after the merged gateway migrations. It is safe to reapply. The earlier
prototype migration number is retired; published master migrations are intact.
@@ -348,7 +348,7 @@ server-side authorization.
| Area | Runner tools |
| --- | --- |
| Identity and people | `get_identity`, `list_people`, `list_agents`, `get_agent` |
| Task work | `get_task_context`, `get_task_history`, `search_tasks`, `get_task`, `report_progress`, `set_task_title`, `create_task`, `reassign_task`, `set_dependencies`, `comment_on_task` |
| Task work | `get_task_context`, `get_task_history`, `search_tasks`, `get_task`, `report_progress`, `set_task_title`, `set_task_monitor`, `create_task`, `reassign_task`, `set_dependencies`, `comment_on_task` |
| Human input and approvals | `request_human_input`, `list_approvals`, `get_approval`, `get_approval_context` |
| Documents | `list_documents`, `read_document`, `list_document_revisions`, `write_document`, `list_task_documents`, `read_task_document`, `write_task_document` |
| Agent instructions | `read_agent_instructions`, `update_agent_instructions`, `get_agent_instruction_history`, `restore_agent_instructions` |
@@ -71,7 +71,7 @@ Verified client metadata, durable verification quotas, event secret rotation,
subscription refresh races and warm-standby gates use the merged implementation.
Personal and Dot event workers consume their own subscription resources.
The generated Dot-only migration is now `0312_broken_sentinels.sql`, after the
The generated Dot-only migration is now `0317_messy_famine.sql`, after the
published master migrations. Its table, column, index and constraint additions
are safe to reapply. Both consent UIs retain the merged organization branding
and request layout while explaining agent access. Runner provider selection
@@ -0,0 +1,96 @@
# Native connection baseline repair — 2026-10-07
The original baseline remains **0 PASS / 15 FAIL**. It did not exercise the intended user decisions and does not qualify a production instruction reduction. This repair makes a new baseline possible; it does not change the old grades.
## Original evidence
- [Campaign 37562577199, attempt 1](https://github.com/paperclipai/paperclip/actions/runs/37562577199).
- [Original public report](https://d1p6rlowie26tp.cloudfront.net/runner-e2e/campaigns/gha-37562577199-1/index.html).
- Source and trusted workflow: `caf120105c487532db769670fcd39249483e1d99`.
- Suite hash: `d7bd6c177fe97e9cba28497555d475b545824796d33178fd0372ae21dbb06429`.
- Definition digest: `e2298d2e190448aa112167e8f2989b8d087766482af145007d86a36f8ab29830`.
- Five cases on native Codex `gpt-5.6-sol`, ACPX Claude `claude-sonnet-5`, and OpenCode `openrouter/deepseek/deepseek-v4-flash-0731`.
- One attempt per cell, 15 actual run records: 14 succeeded and one failed. No harness retries. All 15 cleanup and budget readbacks passed. A succeeded run record is not a passing behavioral grade.
- Recorded LLM cost subtotal: $0.01824843. Positive costs were reported only by the five OpenCode cells. Twelve of 15 runs had token coverage. Zero or missing Codex/Claude billing is unknown, not free; actual charges and local/hosted runtime cost are unknown.
| Cells | Observed failure | Supported diagnosis |
| --- | --- | --- |
| 10 | Browser could not find the creation-time title; renamed title and pending interaction were visible | Stale-title harness assertion, before any intended user decision |
| 2 Codex provider-choice cells | Native `request_human_input` schema denial before Paperclip execution | Rejected input; retained diagnostics omit the invalid field. Four rejections in the positive cell are calls within one run, not four paid harness attempts |
| 2 OpenCode provider-choice cells | Used the preinstalled HubSpot tool once without a choice card | Fixture had already granted real tool access. This does not prove a consent violation or disregard of a decline; no decline was sent |
| 1 Claude provider-decline cell | `native_finalization_missing: session returned no semantic result` | Native session failed before choice; retained evidence does not establish the underlying cause |
The schema accepts both canonical and legacy question forms. Conflicting native/legacy guidance is observable, but is not proven to have caused the old Codex denials. Do not infer that a corrected run explains the old Claude failure.
## Repair contract
1. Match the requested browser route and visible task identifier, then wait for the loaded conversation. Agent title changes are allowed; the wrong task still fails.
2. Configure the deterministic company Arcade gateway with zero agent installs and zero allowed tool entries. Verify public effective-access records before task creation.
3. The positive provider case requires the saved Arcade choice, followed by one real access card for the same agent, connection, selected interaction and exact HubSpot read tool. The browser grants access. The oracle requires the accepted result, exactly one provider call, and the independently observed fixture marker. The expected run count is three. Declines still require one decision, no tool calls and an attributed explanation.
4. Return a canonical `providerQuestionSet` alongside the legacy `providerQuestion`. Native guidance uses the canonical form. Both forms preserve the exact prompt and option IDs that validate saved consent.
5. Invalid native question input remains rejected before execution. Bounded feedback identifies authorized schema locations and required fields, without echoing submitted values or arbitrary keys. No schema or authorization gate is relaxed.
The historical Everyday fixture and prompts remain unchanged. The new fixture and production correctness fixes change the measured contract. Results from the new source must be reported separately; they cannot be treated as a matched comparison with the invalid original baseline.
## Validation and remaining gate
Credential-free checks pass: eval typecheck; 1,801 support tests plus 128 Node tests (one intentional support skip); 51 connection/schema tests; one real-server fixture test; one focused Rust feedback test; and local browser wrong-task/title calibration. Repository typecheck and build pass. The full local repository test run was stopped after five failures outside the changed paths; those five passed in a separate focused invocation. The full run is incomplete, not passing, and the original failures remain retained. Exact-head normal CI at `162cc90fdabe7f505b88ae095044531b82784c92` passed (52 successful checks, four intentional skips, separate Snyk success); its fresh review completed 5/5.
The corrected Codex provider-choice canary passed before the other 14 distinct cells were dispatched. The completed corrected baseline is **10 PASS / 5 FAIL**, with further continuation defects described below. It is not ready to qualify an instruction reduction. Preserve every attempt, decision, actual run, cost gap and failure. No instruction reduction or general integration quality claim is qualified yet.
## Initial corrected campaign stopped before provider execution
[Campaign 37574251834](https://github.com/paperclipai/paperclip/actions/runs/37574251834) resolved source `cbf1c4fba8eabbf779435f550c455f6a66c5aae5` on trusted master workflow `99a9de9940bf5974352d9dbfbb2f21e62e89689f`. It was cancelled during the shared build after review identified another browser precondition defect. The matrix job has no steps and no runner execution; no provider cell or behavioral grade occurred. Preserve this cancelled campaign separately.
The initial stable-ID check assumed whitespace around the identifier. The real breadcrumb uses adjacent title/identifier spans. A regression reproduces the failure with `Renamed taskRUN-1`; the corrected check targets the visible identifier element under the current breadcrumb and matches its text exactly. All five browser checks now pass, including wrong route, wrong/partial identifier and an identifier present only in the mutable title or elsewhere on the page. Seven focused suite checks and eval typecheck pass. This review correction changes only browser support and this report; production logic is unchanged.
## Continuation failures exposed by the corrected baseline
Frozen measured source: `162cc90fdabe7f505b88ae095044531b82784c92`; trusted workflow: `99a9de9940bf5974352d9dbfbb2f21e62e89689f`. These results do not retroactively change the original baseline.
- [Codex provider-second canary 37575158761](https://github.com/paperclipai/paperclip/actions/runs/37575158761): PASS, 17/17 checks, three actual succeeded runs. The saved Arcade choice, separate exact HubSpot access approval, one authorized provider call, persisted Ada Fixture response and actual verification marker all pass. No harness retry. Cleanup and both budget readbacks pass.
- [Remaining 14 distinct cells, campaign 37576261807](https://github.com/paperclipai/paperclip/actions/runs/37576261807): preserve every first-attempt original grade. Claude provider-decline and provider-second fail after the saved choice on `run.attach`: their run-scoped instruction working-copy path changes while runtime content identity is unchanged. This is not evidence for the unrelated old missing-final failure.
- Claude service-approve executes the approved service call, then the evaluator fails fast in the gap between the first blocked run finalizing and its continuation appearing. The final ledger retains a second run that starts and is cancelled by cleanup. The earlier workflow snapshot misses it; count both records and do not claim the continuation behavior passed.
- Codex service-approve executes the approved call, but a yielded result requests another human approval for the same referenced card. Finalization materializes a redundant completion-review card. The original FAIL is retained.
The corrected fifteen-cell result is:
| Profile | Connection decline | Provider decline | Provider second | Service approve | Service decline |
| --- | --- | --- | --- | --- | --- |
| Codex | PASS | PASS | PASS (separate canary) | FAIL | PASS |
| ACPX Claude | PASS | FAIL | FAIL | FAIL | PASS |
| OpenCode | PASS | PASS | FAIL | PASS | PASS |
OpenCode provider-second saved both the Arcade choice and the exact access grant. Gateway discovery occurred twice, but the fixture observed zero HubSpot calls. The third run reached the existing deadline without a final answer. The facade was dropping canonical tool events, so retained outer logs cannot establish the exact tool-rejection cause. This is a real incomplete workflow; do not infer its cause from the model's self-diagnosis or treat a larger timeout as a fix.
The final ledgers contain **31 actual runs: 27 succeeded, two failed, two cancelled during cleanup**. Earlier polling counted 30; the final Claude service-approve ledger adds a late continuation absent from its workflow snapshot. Its API snapshot has that run queued, and its final ledger records start and cancellation. OpenCode's third run is running in the failure snapshot and cancelled in the final ledger. Preserve these capture-time differences. All result/API/final-ledger run-ID sets agree; Claude service-approve's earlier workflow run set does not. All fifteen cleanup and 1,000-cent company/agent readbacks pass, and all attempts are first attempts with no harness retries. The independent local audit retains 279 evidence-file hashes, source/suite/model/attempt identities and original grades. The [remaining campaign's original public report](https://d1p6rlowie26tp.cloudfront.net/runner-e2e/campaigns/gha-37576261807-1/index.html) is accessible (HTTP 200).
Recorded LLM subtotal for the corrected fifteen cells is **$0.04553787**, with incomplete token/cost coverage. Only OpenCode reports positive amounts. Codex/Claude zeros have unknown billing type, invoices are unknown, and local/hosted runtime is unmetered. Count the cancelled continuations as actual executions; do not describe zero reports as free.
The next source correction admits only Claude's authenticated registered-copy path rotation while preserving prompt/custom-entry bytes, content identities, model, permissions, session and lifecycle guards. TypeScript/Rust shared fixtures bind the exact canonical asset suffix. It rejects stale paths and changed custom text instead of replacing arbitrary path strings. Cursor's separate authenticated run-grant policy is unchanged.
Completion feedback rejects a duplicate human-approval request when a response-wake result references this exact run/agent/task's tool-action card. A resolved reference plus a repeated human approval request is rejected when no different pending interaction or approval exists. General evidence of a completed action does not prevent a valid wait on a new question; waiting without any real pending condition still fails. Unrelated reviews and other runs' cards are not silently dismissed. The evaluator waits for the specifically recorded executed-action continuation within its existing deadline; a consumed response followed by another blocker still fails. No expected outcome, service-call limit, approval requirement, overall deadline, automatic retry policy or paid prompt is relaxed.
Provider-free regression evidence: the original Claude reattachment error and duplicate approval acceptance both reproduce before their fixes. Nine Rust attachment checks (including cross-language suffix parity), 127 ACPX runtime/adapter checks, 33 completion/control-plane tests, 1,802 evaluator support tests plus 128 Node tests pass; one support skip is intentional. New live qualification is still required for these source changes. Recorded zero costs do not establish free calls or complete billing; runtime and actual invoices remain unknown.
The OpenCode repair forwards bounded tool start/progress/completion events through the existing provider facade, retaining the actual execution-part identity, tool name, status and provider-visible result. It does not invent a host-call-ID join, expose tool arguments, or change the model prompt, permissions, tool catalog, timeouts or grading. A real bundled-proxy regression emits an invalid-tool failure: the old forwarding path drops all three events, and the repaired path retains all three. Runnerd normalizes builtin tool activity and preserves the error while redacting secret patterns. The output digest describes the bounded forwarded result, not an unretained full provider payload. This closes an evidence gap; it does not establish the old OpenCode failure's cause or claim its behavior fixed.
New local verification also passes repository typecheck and build, nine continuation deadline tests, 59 OpenCode driver/proxy checks and the focused Rust tool-error/redaction check. The earlier interrupted full repository suite remains incomplete; current-source full CI and live canaries remain required. The next paid selection is bounded to Claude provider-decline (restart repair), Codex service-approve (duplicate approval repair), and OpenCode provider-second (one diagnostic attempt with repaired evidence). Other failed cells wait for usable canary evidence. Preserve all old outcomes, frozen aliases and costs.
## Bounded continuation canaries and retained-evidence correction
[Campaign 37581023494](https://github.com/paperclipai/paperclip/actions/runs/37581023494), on measured source `a704a0b9128ab54c1707c1a7ce04c6f969d3a8cb` and trusted master workflow `03cf6a6ecb0caf5e6f9c4e6af87dc723e5e3bca2`, completed **2 PASS / 1 FAIL** on the first and only attempts. Its [original public report](https://d1p6rlowie26tp.cloudfront.net/runner-e2e/campaigns/gha-37581023494-1/index.html) is accessible (HTTP 200). Suite hash `246bcce46091a96be43340213467adc4412f1031836191fc224158322ebff576`; definition digest `7f0b96fd202e68170e30acc10221c6fe012413adb33f932f1a845b13b8109e4c`.
- **Codex service-approve: PASS**, 17/17 checks. Two succeeded runs, exactly one approved service call, a saved briefing with both returned titles and the actual verification code, no extra pending approval.
- **OpenCode provider-second: PASS**, 17/17 checks. Three succeeded runs, saved Arcade choice, separate accepted access grant, exactly one `Hubspot_ListContacts`, and a run-attributed final reply containing Ada Fixture and the independently observed marker. The repaired evidence carrier does not establish why the earlier source timed out; a single later pass does not prove the old cause fixed.
- **Claude provider-decline: original FAIL**, 18/20 checks (the explanation check and resulting workflow-completed check fail). Both actual runs succeed, including attachment after the controller restart. The saved, run-attributed reply states that HubSpot “isn't connected” because the user selected None, and explains why contacts cannot be fetched. The old matcher omits this contraction. All decision, no-use, task completion, identity, and cleanup checks pass. This is a demonstrated grader false negative, not missing agent output.
The matcher correction recognizes contracted negative connection/retrieval statements, with positive and unrelated negative controls. Replaying the exact retained Claude input against old and corrected functions reproduces the old explanation FAIL and corrected explanation PASS, with **zero provider runs**. The original live grade stays FAIL. The unchanged no-use replay uses the original retained connection-identity PASS receipt and independently counts zero tool calls in the gateway snapshot; it does not invent a new connection snapshot. No attribution, timestamp, successful-run join, decision, no-use, or marker gate is removed. This is a separately labeled grader correction, not a new live pass or a rewritten campaign.
Seven actual runs are retained across these canaries, all succeeded, with no harness retry or recovery. All result, workflow, API and final-ledger run-ID sets agree. All three cleanup checks and both 1,000-cent budget readbacks pass; 59 retained evidence-file hashes are audited. Reported LLM subtotal is $0.006246754, entirely from OpenCode. Codex/Claude zero reports have unknown billing type; invoices and local/hosted runtime remain unknown.
Review also exposed an overbroad resolved-approval guard: completed action A could block a valid new question B. A real database regression fails before the correction for both accepted and rejected A, then passes after narrowing the check. The stale-wait and duplicate-approval regressions remain passing. The formatting-only follow-up `cac06f9259daee24beeee7d6b65bbc7c39ca82dd` changes one Rust regression test (wrapping and a trailing argument comma); it is distinct from the measured source. The subsequent guard and matcher corrections are also not relabeled as the frozen canary source.
Current local validation: seven completion-feedback database checks, 1,805 evaluator support checks plus 128 Node checks (one intentional support skip), and repository/evaluator typechecks pass. Repository build and fresh-head CI/review remain separate readiness gates. The only remaining authorized paid selection is the two previously held Claude cases, provider-second and service-approve, on a newly frozen corrected source. Instruction reductions remain held pending a usable, consistently measured baseline.
@@ -0,0 +1,180 @@
# Personal primary agent
## Current boundary
The user approved the revised Storybook design on 2026-10-07 and requested full
implementation plus acceptance testing in a real isolated test drive. This branch
now includes persistence, API wiring, and production defaults.
- Branch: `codex/personal-primary-agent-storybook`.
- Preview: http://localhost:6017/?path=/story/primary-agent-00-review-guide--start-here
- Source: `ui/storybook/stories/primary-agent/` (one feature folder).
- Shared presentation: `ui/src/components/primary-agent/PrimaryAgentPresentation.tsx`.
- Stories keep simulated state as the approved reference. The application now
mounts a separate provider backed by a user/company preference endpoint.
- Delivery includes real test-drive acceptance on desktop/mobile and required
repository checks. After reviewing the working feature, the user requested a
pull request with passing checks and merge into master.
## Agreed product contract
Each person has one primary agent per company, marked with an accessible
"My primary agent" crown on the profile and agent list. Neither the Agents
sidebar nor the Chat sidebar shows a crown. Different people can choose different agents; the
preference will persist across devices. Crowning does not change roles,
reporting lines, permissions, tasks, routines, or shared Board Operations.
- The first human-attributed creation silently initializes the primary, including
onboarding. Later creations, system provisioning, and agent-authored hires do
not replace or establish a person's preference.
- Tasks preserve explicit assignments and restored drafts, then prefer a recent
eligible assignee, then the primary, then the existing fallback.
- Chat reopens a valid recent conversation, otherwise the primary's conversation,
otherwise its existing chooser. Opening Chat does not start execution.
- The individual agent page offers Set as my primary beneath the agent details.
Kebab menus have no primary action, and there is no explicit removal option.
Standalone component examples do not offer this action. The current primary
shows its crown with no redundant set action. When a primary already exists,
switching requires a modal with the two agents' avatars and an arrow above.
The incoming primary has an outlined avatar and a crown beside its name:
"This will make [new name] your primary agent, do you want to do this?"
Cancel leaves the current primary unchanged. When none exists,
choosing one applies immediately without the modal. Stars remain independent.
Failures roll back and offer an actionable retry. These review decisions
supersede the original menu, removal, and immediate-switching design.
- The primary stays first in the Agents sidebar, even when idle; other ordering
is preserved and the primary appears only once in that section.
- Pausing and errors retain the crown and existing execution restrictions.
Leaving or terminating clears it without resetting initialization. Crowning a
previously left agent rejoins it under existing membership rules.
## Review coverage and implementation notes
Stories use the actual streamlined and classic Layout components, roster/profile
pages, composer, Chat pages/navigation, existing menus, and shared crown components.
Fixtures include idle/active, first-created result, absent/cleared/unavailable,
paused/error, independent stars, long names, loading, failed mutations, desktop,
mobile, and light theme. Interactive stories cover setting from the profile,
absence of primary menu actions, leaving, starring, rollback/retry, and opening
a task or Chat after switching, cancellation, and first choice without confirmation.
The streamlined Sidebar now includes the reviewed Agents section by default.
Stories can still supply it through Layout's existing sidebar slot for review.
The icon rail is retired in SidebarContext; collapsed coverage uses the supported
collapsible Agents section plus the mobile drawer. No rail is reintroduced.
Reloading resets the fixture preference. Task fallback is simulated through the
existing composer's defaults/recent-assignee input. Chat entry routing and the
first-created result are also simulations. These stories are design references,
not evidence of authorization, persistence, concurrency, or real execution.
## Verification at review handoff
- UI TypeScript build (`tsc -b ui`).
- 61 existing tests across SidebarAgents, AgentActionButtons, EntityRow, Agents,
and AgentOverview.
- Token gates and palette/character synchronization checks.
- Full static Storybook build.
- Browser walkthroughs: switch crown and sidebar order, independent stars,
rollback and Retry, leave/rejoin, direct profile action, task assignment/draft
precedence, recent Chat history, Chat fallback/chooser, and mobile drawer,
composer, and profile at 390 by 844.
## Implementation delivered
1. Added a company/user preference with nullable primaryAgentId and initialization
state; enforce uniqueness. Synchronize db/shared/server/ui contracts.
2. Added authenticated GET/PUT `/api/companies/:companyId/primary-agent/me`, deriving
the user from authentication, enforcing company access and agent visibility,
rejecting agent actors, and recording mutation activity.
3. Initialized transactionally during human-attributed creation. Concurrent
creations establish one primary and cannot overwrite an explicit choice or
lifecycle-cleared preference. Backfill from the earliest attributable human creation;
if that original agent is gone or terminated, leave the crown empty. Unknown
ownership remains unset.
4. Added lifecycle cleanup and existing membership rejoin behavior. Connected a
user/company-scoped query hook to the reviewed presentation and defaults.
5. Verified concurrency, backfill, lifecycle cleanup, user/company isolation
(including local trusted mode), drafts, recent selections, unavailable agents,
and real desktop/mobile task and Chat journeys.
## Real test-drive acceptance (2026-10-07)
- Isolated CLI test drive: `/tmp/paperclip-primary-agent-test-drive`, company
**Primary Agent Test Drive**, prefix `PRI`, at http://localhost:3104.
- Maia was crowned automatically through the real creation API. Creating Alex
preserved Maia. The simplified creation wizard's model-connection step rejected
the disposable placeholder credential; Alex was created through the real API
with the existing local Codex login instead. No creation-wizard success is claimed.
- Desktop: profile-only setting, cancel/confirm, persistence after reload, roster
crown, sidebar ordering, absent sidebar crowns and menu actions, leave/clear,
first choice without a modal, rejoin, and paused crown retention passed.
- Fresh browser-origin task composer selected Alex from the primary. After switching
to Maia, the composer preserved recent Alex and Chat reopened Alex's recent
conversation. An existing draft on the other origin remained intact.
- Opening Chat used the primary with no new execution. Sending a message produced
**“Alex received this primary-agent chat.”** from a successful real Codex run.
- Real task **PRI-3, Acknowledge Alex’s task receipt**, completed with
**“Task received by Alex”** and status `done`. The initial probe used an unsupported
`gpt-5.4` subscription model; both agents were changed to `gpt-6.1-sol`, the successful
checks were repeated, and the obsolete probe task was cancelled.
- Mobile at 390 × 844: the reviewed modal fits, confirmation switches the crown,
and the page has no horizontal overflow. Viewport override reset afterward.
- Focused tests cover initialization, simultaneous creation/selection, audit order,
leave races, backfill, visibility boundaries, viewer self-service, auth-derived
user/company isolation, optimistic rollback/retry, session retry, task defaults,
and recent Chat precedence.
## Verification results
- Repository typecheck passed with `pnpm -r --workspace-concurrency=1 typecheck`.
Final server/UI typechecks also passed after the last feature refinements.
- `pnpm build`, `pnpm check:token-gates`, and `git diff --check` passed.
- Entire UI suite: **698 files, 7,760 tests passed**.
- Entire DB suite, run serially: **47 files, 166 tests passed**. Parallel attempts
failed to initialize embedded PostgreSQL before migrations; the serial run
resolves that migration-verification concern.
- Focused primary/default/provider checks passed: **106 tests** across final
runs (9 database/API tests, plus provider, composer, and Chat tests).
- Existing onboarding, hire idempotency, resource membership, and company import
suites passed: **125 tests**. Legacy approval plus terminalization checks:
**15 tests passed**. Import/approval expectations now assert original creator
attribution rather than dropping the new creation option.
- **Repository-wide tests are not green.** `pnpm test:run` was stopped after
roughly an hour with failures, rather than being represented as a complete pass.
`adapter-cost-failure-paths.test.ts` still has OpenCode timeout failures in a
fresh rerun. `workspace-git-snapshot-streaming.test.ts` timed out after 300 seconds.
Heartbeat database-setup timeouts passed isolated reruns. The broad run began
before the final edits and also reported primary/approval failures; those suites
passed in fresh processes against final source. Later broad-run groups did not
complete. These local failures remain disclosed; PR checks must pass on the
current head before merge.
The final-code test drive remains running at http://localhost:3104 with **Alex**
primary and **Maia's profile** open. Choose **Set as my primary**, confirm, and
reload to try persistence. New Task and Chat retain recent Alex choices by design;
the primary is their fallback when no eligible recent choice or draft exists.
## Pull request verification
- PR: https://github.com/paperclipai/paperclip/pull/15470. The user authorized
passing PR checks and merging into master.
- The rebased branch passes repository typecheck and build, token gates, and
114 focused tests. Migration replay now runs twice without replacing choices.
- The earlier OpenCode timeouts pass all 31 tests with an isolated XDG config
directory; the local config copy caused the delay. The large Git streaming test
also passes when run alone (296 seconds).
- Initial CI identified stale creation-option assertions and app remounts on
company changes. Preserve mounted app children and bind each pending mutation
to its original user/company. Refresh membership caches for primary changes
received from other devices.
- Security review identified agent mappings in company activity. Preference
events now record only the affected user and action, with no selected agent IDs.
An authenticated cross-user activity endpoint test covers this boundary.
- Review fixes pass 96 database/permission-route tests and 127 provider,
live-update, and skill-route tests. Final merge requires fresh-head CI and review.
- The affected browser suites pass all 10 tests, including onboarding, archived
company navigation, Chat fallback without execution, and slow-CPU reloads.
- The second PR run passed 52 checks and Greptile 5/5, with one OpenAPI inventory
failure. Both personal endpoints now have board-only request/response contracts
in OpenAPI. Its 14 tests and the remaining 28 tests in that shard pass locally.
+14
View File
@@ -280,6 +280,14 @@ warnings and errors remain visible.
Recovery lifecycle events retain the original structured failure code, retry attempt, next retry time, and predecessor/successor identifiers. Durable status delivery uses an idempotency marker; delivery grants no provider authority. Failed publication is retried without repeating provider work. These records are not first-party Telemetry.
An unstarted legacy conversation retry waiting for execution cleanup retains
`resultJson.executionWait` with the issue, blocking run, and
`execution_owner_active` cause. A local lifecycle event records the wait and the
unchanged scheduled retry attempt once per blocking run. Repeated scheduler
checks update the same retry row without repeating that event. Promotion removes
this ownership-wait marker after cleanup; other execution holds retain their
existing dispatch gates.
If execution-continuation setup finds that a task no longer exists, is closed,
or its owner changed, the existing cancellation settlement records
`continuation_task_ownership_changed`. The run and wake request become cancelled
@@ -335,6 +343,12 @@ the enclosing workspace task. The original error and restore safety policy are
unchanged. These lines stay in the instance run log and its configured durable
storage, and are not new first-party telemetry events.
Native sandbox `environmentSyncOut` errors preserve allowlisted error codes and
bounded HTTP/exit statuses across worker RPC for this diagnostic line. The host
revalidates the envelope and keeps the original failure and recovery policy.
Provider messages, paths, response bodies, credentials, and arbitrary error data
are not copied into the new envelope. Older workers can still report `unknown`.
The optional `step` identifies the failed restore operation. For
`phase=workspace` and `step=git_integration`, `gitCommand` identifies one fixed
command family (`rev_parse`, `symbolic_ref`, `merge_base`, `merge_tree`,
+4 -1
View File
@@ -1,7 +1,10 @@
# Runner API escape hatch
`search_api` and `call_api` extend the native runner when an available dedicated
operation cannot express the requested work. Existing tools remain preferred;
operation cannot express the requested work. Use `set_task_monitor` to schedule or clear a one-shot task check; after confirming
the receipt, finish with `yielded` and `continuation.kind: "monitor"` to await that
task’s timer. `call_api` still rejects monitor/execution-policy lifecycle writes.
Existing tools remain preferred;
agents do not have to search before using them. Only two tool definitions are
advertised. The API catalog is returned on demand, never injected into the
initial prompt.
+43
View File
@@ -54,3 +54,46 @@ is safe to remove from its age, an absent PID, or a successful task response.
This change prevents new orphaned ownership. It cannot recover file changes
that an earlier failed collection discarded.
## Required remote restore failures
When a legacy sandbox run cannot copy required workspace files back, the server
keeps the original allocation for repair. It writes the failed run, the exact
lease retention intent, and a board-owned repair hold in one transaction. The
hold survives reassignment and conversation resets and coexists with newer
recovery incidents; it never supersedes them.
Cleanup stops that allocation and verifies its provider receipt. It does not
use ordinary ephemeral destruction or return the source to warm reuse. Stop
failures use the existing cleanup retry and backoff policy.
The retained source uses `retain_on_failure`. A stopped source can still incur
provider storage charges. It stays available until an operator recovers or
explicitly discards it; elapsed time never proves that its files were copied.
The task's `GET /api/issues/:id/recovery-actions` response lists these repair holds
alongside any newer active incident, including the original lease IDs. Inspect
those exact leases through `GET /api/environment-leases/:leaseId`,
confirm that cleanup stopped them, recover the missing files, and record
`workspaceRepairEvidence` through the existing recovery-action resolution API.
Send `POST /api/issues/:id/recovery-actions/resolve` with the exact recovery action
ID, `outcome: "restored"`, the task's **unchanged**
`sourceIssueStatus`, and `executionReconciliation` for the recorded source run.
Only a board operator can record this repair. It preserves the current task
owner, conversation generation, status, and execution locks. It does not queue a
continuation or replay the stopped run; after repair, any new work still follows
the normal admission rules.
The board currently shows repair guidance and an Inspect run link, not a repair
form. Recording the decision is an operator API/runbook workflow; the recovery
API response contains the exact action and lease IDs. Provider access may require
an instance administrator or Cloud support. This change does not add a repair UI.
Then use the provider's operator console to remove that exact retained
allocation when its files are no longer needed. Verify its immutable provider
allocation ID against the recorded lease before removal. The ordinary reusable
sandbox cleanup does not select `retain_on_failure` sources; do not use
environment deletion as proof that this allocation was removed. Keep the
environment and its provider credentials until recovery is complete. Recording a
repair decision does not automatically destroy it.
A new message, Retry, or `/new` cannot clear this workspace repair hold. Ordinary
local lock-timeout retries and runs without a required remote copy-back failure
keep their existing policy. This change preserves future failures; it cannot
recover files from an allocation that was already destroyed.
@@ -1107,6 +1107,7 @@ describe("sandbox adapter execution targets", () => {
// ACP keeps stdin open while it waits for a handshake. A remote child can
// exit before replying; that must close the proxy and fail the handshake.
const output = "final output\n".repeat(16_384);
const logs: string[] = [];
const bridge = await startAdapterExecutionTargetProcessSessionBridge({
runId: "run-open-stdin",
target: {
@@ -1124,11 +1125,16 @@ describe("sandbox adapter execution targets", () => {
env: {},
timeoutSec: 10,
streamOutputViaSession,
onLog: async (stream, chunk) => { if (stream === "stderr") logs.push(chunk); },
});
expect(bridge).not.toBeNull();
try {
const result = await runProxyWithInput(bridge!.agentCommand, "initialize\n", true);
expect(result.code).toBe(exitCode ?? 1);
const terminal = logs.filter((line) => line.includes("ACP process session terminal:"));
expect(terminal).toEqual([
`[paperclip] ACP process session terminal: source=remote_event outcome=${exitCode === null ? "error" : "exit"} exitCode=${exitCode ?? "unknown"} signal=unknown.\n`,
]);
if (exitCode === null) {
expect(result.stdout).toBe("");
expect(result.stderr).toContain("ENOENT");
@@ -1141,6 +1147,194 @@ describe("sandbox adapter execution targets", () => {
}
}, 15_000);
it("flushes remote errors under backpressure and retains only bounded terminal evidence", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-terminal-flush-"));
cleanupDirs.push(rootDir);
const delegate = createLocalSandboxRunner();
const logs: string[] = [];
let emit!: (frame: string) => Promise<void>;
let complete!: () => void;
let failInput!: (error: Error) => void;
let inputStarted = false;
const inputFailure = new Promise<never>((_resolve, reject) => { failInput = reject; });
void inputFailure.catch(() => {});
const commandComplete = new Promise<void>((resolve) => { complete = resolve; });
const frame = {
type: "error", message: "synthetic-private-detail".repeat(200_000),
code: 1e100, signal: "untrusted-private-signal",
};
const bridge = await startAdapterExecutionTargetProcessSessionBridge({
runId: "terminal-flush", adapterKey: "acpx", runtimeRootDir: rootDir,
target: {
kind: "remote", transport: "sandbox", remoteCwd: rootDir,
runner: { execute: async (input) => {
if (!input.useSession) {
const script = input.args?.[1] ?? "";
if (script.startsWith("mkdir -p") && script.includes("/stdin/000000000001.json")) {
inputStarted = true;
return inputFailure;
}
return delegate.execute(input);
}
emit = async (text) => { await input.onLog?.("stdout", text); };
await commandComplete;
return { exitCode: 1, stdout: "", stderr: "", timedOut: false, signal: null, pid: null, startedAt: null };
} },
},
command: "cat", args: [], cwd: rootDir, env: {}, streamOutputViaSession: true,
onLog: async (stream, chunk) => { if (stream === "stderr") logs.push(chunk); },
});
let peer: net.Socket | undefined;
try {
const source = await readFile(bridge!.agentCommand, "utf8");
const port = Number(/port: (\d+)/.exec(source)![1]);
const token = JSON.parse(/const token = (".*?");/.exec(source)![1]) as string;
peer = net.createConnection({ host: "127.0.0.1", port });
peer.on("error", () => {});
peer.setEncoding("utf8");
let output = "";
peer.on("data", (chunk) => { output += chunk; });
const closed = new Promise<void>((resolve) => peer!.once("close", resolve));
await new Promise<void>((resolve) => peer!.once("connect", resolve));
peer.write(JSON.stringify({ token, type: "hello" }) + "\n");
// Confirm authentication by receiving a marker before applying backpressure.
await emit(JSON.stringify({ type: "data", stream: "stdout", data: "" }) + "\n");
await waitForCondition(() => output.length > 0, "Missing authenticated marker.");
output = "";
peer.write(JSON.stringify({ token, type: "stdin", data: "aW5wdXQ=" }) + "\n");
await waitForCondition(() => inputStarted, "Input write did not start.");
peer.pause();
// The real wrapper emits error then exit when a child fails to spawn.
// A later frame must not write after end() and abort the pending error.
await emit([
frame,
{ type: "exit", code: 1 },
{ type: "data", stream: "stdout", data: "bGF0ZQ==" },
{ type: "shutdownAck" },
].map((event) => JSON.stringify(event) + "\n").join(""));
// The reverse race matters too: an already-accepted input write can
// reject after the remote error started draining. Its end(data) must
// not abort or replace the first terminal frame.
failInput(new Error("synthetic-private-input-detail"));
await waitForCondition(
() => logs.some((line) => line.includes("ACP process session input delivery failed.")),
"Input failure was not handled.",
);
peer.resume();
await closed;
expect(JSON.parse(output)).toEqual(frame);
expect(logs.filter((line) => line.includes("ACP process session terminal:"))).toEqual([
"[paperclip] ACP process session terminal: source=remote_event outcome=error exitCode=unknown signal=unknown.\n",
]);
} finally {
failInput(new Error("test cleanup"));
complete();
peer?.destroy();
await bridge?.stop();
}
}, 15_000);
it("reports a streamed transport failure even when diagnostic persistence stalls", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-terminal-stream-"));
cleanupDirs.push(rootDir);
const delegate = createLocalSandboxRunner();
const logs: string[] = [];
let releaseLog!: () => void;
const stalledLog = new Promise<void>((resolve) => { releaseLog = resolve; });
const bridge = await startAdapterExecutionTargetProcessSessionBridge({
runId: "terminal-stream", adapterKey: "acpx", runtimeRootDir: rootDir,
target: {
kind: "remote", transport: "sandbox", remoteCwd: rootDir,
runner: { execute: async (input) => {
if (input.useSession) throw new Error("synthetic-private-provider-detail");
return delegate.execute(input);
} },
},
command: "cat", args: [], cwd: rootDir, env: {}, streamOutputViaSession: true,
onLog: async (stream, chunk) => {
if (stream === "stderr") { logs.push(chunk); await stalledLog; }
},
});
try {
const result = await runProxyWithInput(bridge!.agentCommand, "", true);
expect(result.code).toBe(1);
expect(logs).toEqual([
"[paperclip] ACP process session terminal: source=output_stream outcome=error exitCode=unknown signal=unknown.\n",
]);
} finally {
releaseLog();
await bridge?.stop();
}
}, 10_000);
it("closes a failed output poll without waiting for the run log", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-terminal-poll-"));
cleanupDirs.push(rootDir);
const delegate = createLocalSandboxRunner();
const logs: string[] = [];
let releaseLog!: () => void;
const stalledLog = new Promise<void>((resolve) => { releaseLog = resolve; });
const bridge = await startAdapterExecutionTargetProcessSessionBridge({
runId: "terminal-poll", adapterKey: "acpx", runtimeRootDir: rootDir,
target: {
kind: "remote", transport: "sandbox", remoteCwd: rootDir,
runner: { execute: async (input) => {
const script = input.args?.[1] ?? "";
if (script.includes("/events'/*.json")) throw new Error("synthetic-private-provider-detail");
return delegate.execute(input);
} },
},
command: "cat", args: [], cwd: rootDir, env: {},
onLog: async (stream, chunk) => {
if (stream === "stderr") { logs.push(chunk); await stalledLog; }
},
});
try {
const result = await runProxyWithInput(bridge!.agentCommand, "", true);
expect(result.code).toBe(1);
expect(logs).toEqual([
"[paperclip] ACP process session terminal: source=output_poll outcome=error exitCode=unknown signal=unknown.\n",
]);
} finally {
releaseLog();
await bridge?.stop();
}
}, 10_000);
it("records an unexpected authenticated proxy close once and ignores unauthenticated peers", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-terminal-proxy-"));
cleanupDirs.push(rootDir);
const logs: string[] = [];
const bridge = await startAdapterExecutionTargetProcessSessionBridge({
runId: "terminal-proxy", adapterKey: "acpx", runtimeRootDir: rootDir,
target: { kind: "remote", transport: "sandbox", remoteCwd: rootDir, runner: createLocalSandboxRunner() },
command: "cat", args: [], cwd: rootDir, env: {},
onLog: async (stream, chunk) => { if (stream === "stderr") logs.push(chunk); },
});
try {
const source = await readFile(bridge!.agentCommand, "utf8");
const port = Number(/port: (\d+)/.exec(source)![1]);
const token = JSON.parse(/const token = (".*?");/.exec(source)![1]) as string;
for (const authenticated of [false, true]) {
const peer = net.createConnection({ host: "127.0.0.1", port });
peer.on("error", () => {});
const closed = new Promise<void>((resolve) => peer.once("close", resolve));
await new Promise<void>((resolve) => peer.once("connect", resolve));
if (authenticated) peer.end(JSON.stringify({ token, type: "hello" }) + "\n");
else peer.end();
await closed;
if (!authenticated) expect(logs).toEqual([]);
}
await waitForCondition(() => logs.length === 1, "Missing proxy-close diagnostic.");
await bridge!.stop();
expect(logs.filter((line) => line.includes("ACP process session terminal:"))).toEqual([
"[paperclip] ACP process session terminal: source=proxy_close outcome=error exitCode=unknown signal=unknown.\n",
]);
} finally {
await bridge?.stop();
}
}, 10_000);
it("buffers sandbox process session output until the local proxy connects", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-process-session-buffer-"));
cleanupDirs.push(rootDir);
+46 -10
View File
@@ -2165,6 +2165,26 @@ export async function startAdapterExecutionTargetProcessSessionBridge(input: {
}
};
let pollTimer: NodeJS.Timeout | null = null;
let terminalEvidenceRecorded = false;
const recordTerminalEvidence = (
source: "remote_event" | "input_delivery" | "output_poll" | "output_stream" | "proxy_close",
event: { type?: string; code?: number | null; signal?: string | null },
) => {
if (terminalEvidenceRecorded) return;
terminalEvidenceRecorded = true;
// Remote frames are untrusted. Keep the retained diagnostic bounded and
// independent of messages, commands, paths, payloads, and provider errors.
const outcome = event.type === "exit" ? "exit" : "error";
const code = typeof event.code === "number" && Number.isInteger(event.code)
&& event.code >= 0 && event.code <= 255 ? event.code : "unknown";
const signal = typeof event.signal === "string" && [
"SIGHUP", "SIGINT", "SIGQUIT", "SIGILL", "SIGABRT", "SIGFPE", "SIGKILL",
"SIGSEGV", "SIGPIPE", "SIGALRM", "SIGTERM", "SIGBUS", "SIGXCPU", "SIGXFSZ",
].includes(event.signal) ? event.signal : "unknown";
logFailureWithoutWaiting(
`[paperclip] ACP process session terminal: source=${source} outcome=${outcome} exitCode=${code} signal=${signal}.\n`,
);
};
const pendingRemoteEvents: Array<{
type?: string;
stream?: "stdout" | "stderr";
@@ -2191,22 +2211,30 @@ export async function startAdapterExecutionTargetProcessSessionBridge(input: {
const writeRemoteEventToSocket = (event: (typeof pendingRemoteEvents)[number]) => {
if (!socket) return false;
// `stopping` can already be set by a terminal frame buffered before auth.
// Use the socket's outbound state instead: once end() has been called,
// another write would destroy it and truncate the frame still draining.
// This also covers input-delivery failure, which ends the socket directly.
if (socket.writableEnded || socket.destroyed) return true;
socket.write(jsonLine(event));
if (event.type === "exit") {
if (event.type === "exit" || event.type === "error") {
stopping = true;
// Flush the terminal frame and all earlier output before closing. An
// immediate destroy can discard the cause and leave only connection_close.
socket.end();
} else if (event.type === "error") {
stopping = true;
socket.destroy();
}
return true;
};
const deliverRemoteEvent = (event: (typeof pendingRemoteEvents)[number]) => {
const deliverRemoteEvent = (
event: (typeof pendingRemoteEvents)[number],
source: "remote_event" | "output_poll" | "output_stream" = "remote_event",
) => {
if (event.type === "shutdownAck") {
signalShutdownAcknowledged();
return;
}
if (event.type === "exit" || event.type === "error") recordTerminalEvidence(source, event);
if (socket) {
writeRemoteEventToSocket(event);
return;
@@ -2248,6 +2276,7 @@ export async function startAdapterExecutionTargetProcessSessionBridge(input: {
nextSocket.on("close", () => {
clearTimeout(authTimer);
liveSockets.delete(nextSocket);
if (authenticated && !stopping) recordTerminalEvidence("proxy_close", { type: "error" });
});
nextSocket.on("data", (chunk) => {
connectionBuffer += chunk;
@@ -2304,11 +2333,17 @@ export async function startAdapterExecutionTargetProcessSessionBridge(input: {
} catch {
stdinDeliveryFailed = true;
stopping = true;
recordTerminalEvidence("input_delivery", { type: "error" });
const message = "ACP process session input delivery failed.";
// Flush the diagnostic before closing; destroy() can discard it
// and leave only ACP's generic connection_close error. Do not
// expose provider error text, which may contain a command payload.
nextSocket.end(jsonLine({ type: "error", message }));
// A remote terminal frame may already be draining while this
// accepted stdin write fails. end(data) would write after end
// and discard that first failure just like socket.write(data).
if (!nextSocket.writableEnded && !nextSocket.destroyed) {
nextSocket.end(jsonLine({ type: "error", message }));
}
// stop() awaits this input chain before sending shutdown. Run-log
// persistence must not hold teardown open when it stalls or fails.
logFailureWithoutWaiting(`[paperclip] ${message}\n`);
@@ -2340,8 +2375,8 @@ export async function startAdapterExecutionTargetProcessSessionBridge(input: {
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
await onLog("stderr", `[paperclip] ACP process session bridge poll failed: ${message}\n`);
deliverRemoteEvent({ type: "error", message });
// A stuck log sink must not strand the adapter waiting for this failure.
deliverRemoteEvent({ type: "error", message }, "output_poll");
return;
} finally {
if (!stopping) {
@@ -2467,14 +2502,15 @@ export async function startAdapterExecutionTargetProcessSessionBridge(input: {
deliverRemoteEvent({
type: "exit",
code: typeof result.exitCode === "number" ? result.exitCode : null,
});
signal: result.signal,
}, "output_stream");
}
} catch (error) {
if (!stopping) {
deliverRemoteEvent({
type: "error",
message: error instanceof Error ? error.message : String(error),
});
}, "output_stream");
}
}
})();
@@ -285,6 +285,34 @@ describe("claude remote execution", () => {
expect(call?.[2]).not.toContain("--resume");
});
it("explains a remote-to-local session reset even when the cwd matches", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-claude-local-reset-"));
cleanupDirs.push(rootDir);
vi.stubEnv("PAPERCLIP_HOME", rootDir);
const onLog = vi.fn(async () => {});
await execute({
runId: "run-local-reset",
agent: { id: "agent-1", companyId: "company-1", name: "Claude Coder", adapterType: "claude_local", adapterConfig: {} },
runtime: {
sessionId: "12345678-1234-4abc-9def-123456789012",
sessionParams: {
cwd: rootDir,
remoteExecution: { transport: "ssh", host: "remote.test", port: 22, username: "fixture", remoteCwd: rootDir },
},
sessionDisplayId: null,
taskKey: null,
},
config: { engine: "cli", command: "claude", cwd: rootDir, paperclipRuntimeSkills: [] },
context: {},
onLog,
});
expect(runChildProcess.mock.calls[0]?.[2]).not.toContain("--resume");
expect(onLog).toHaveBeenCalledWith("stdout", expect.stringContaining("does not match the current execution target"));
expect(onLog).not.toHaveBeenCalledWith("stdout", expect.stringContaining("was saved for cwd"));
});
it("resumes saved Claude sessions for remote SSH execution when the remote identity matches", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-claude-remote-resume-match-"));
cleanupDirs.push(rootDir);
@@ -499,19 +499,21 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
const billingType = resolveClaudeBillingType(effectiveEnv);
const claudeSkillEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
const desiredSkillNames = new Set(resolveClaudeDesiredSkillNames(config, claudeSkillEntries));
// When instructionsFilePath is configured, build a stable content-addressed
// file that includes both the file content and the path directive, so we only
// need --append-system-prompt-file (Claude CLI forbids using both flags together).
// Keep per-run working-copy paths out of the content-addressed system prompt.
// Resumed sessions retain that prompt, so refresh the location on every turn.
let combinedInstructionsContents: string | null = null;
let instructionsPathDirective = "";
if (instructionsFilePath) {
try {
const instructionsContent = await fs.readFile(instructionsFilePath, "utf-8");
const pathDirective =
`\nThe above agent instructions were loaded from ${instructionsFilePath}. ` +
instructionsPathDirective =
`Agent instructions for this run were loaded from ${instructionsFilePath}. ` +
`Resolve any relative file references from ${instructionsFileDir}. ` +
`This base directory is authoritative for sibling instruction files such as ` +
`./HEARTBEAT.md, ./SOUL.md, and ./TOOLS.md; do not resolve those from the parent agent directory.`;
combinedInstructionsContents = instructionsContent + pathDirective;
`./HEARTBEAT.md, ./SOUL.md, and ./TOOLS.md; do not resolve those from the parent agent directory. ` +
`This location replaces any instruction file location from earlier turns.`;
combinedInstructionsContents = instructionsContent +
"\nUse the agent instruction file location supplied in the current run prompt to resolve relative file references.";
} catch (err) {
const reason = err instanceof Error ? err.message : String(err);
await onLog(
@@ -778,18 +780,20 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
? runtimeMcpServers.length === 0
: runtimeMcpServerIdentity === runtimeMcpIdentity;
const isValidUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(runtimeSessionId);
const hasMatchingSessionCwd = claudeSessionCwdMatchesExecutionTarget({
runtimeSessionCwd,
effectiveExecutionCwd,
executionTargetIsRemote,
});
const hasMatchingExecutionTarget = adapterExecutionTargetSessionMatches(runtimeRemoteExecution, runtimeExecutionTarget);
const canResumeSession =
runtimeSessionId.length > 0 &&
isValidUuid &&
hasMatchingPromptBundle &&
// Each CLI invocation loads --mcp-config, including --resume invocations.
// Refreshing tools does not invalidate the saved conversation.
claudeSessionCwdMatchesExecutionTarget({
runtimeSessionCwd,
effectiveExecutionCwd,
executionTargetIsRemote,
}) &&
adapterExecutionTargetSessionMatches(runtimeRemoteExecution, runtimeExecutionTarget);
hasMatchingSessionCwd &&
hasMatchingExecutionTarget;
const sessionId = canResumeSession ? runtimeSessionId : null;
if (runtimeSessionId && !isValidUuid) {
await onLog(
@@ -798,26 +802,20 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
);
}
if (
executionTargetIsRemote &&
runtimeSessionId &&
isValidUuid &&
!canResumeSession
!hasMatchingExecutionTarget
) {
await onLog(
"stdout",
`[paperclip] Claude session "${runtimeSessionId}" does not match the current remote execution identity and will not be resumed in "${effectiveExecutionCwd}". Starting a fresh remote session.\n`,
`[paperclip] Claude session "${runtimeSessionId}" does not match the current execution target and will not be resumed in "${effectiveExecutionCwd}". Starting a fresh session.\n`,
);
} else if (
runtimeSessionId &&
isValidUuid &&
runtimeSessionCwd.length > 0 &&
path.resolve(runtimeSessionCwd) !== path.resolve(effectiveExecutionCwd)
!hasMatchingSessionCwd
) {
await onLog(
"stdout",
`[paperclip] Claude session "${runtimeSessionId}" does not match the current remote execution identity and will not be resumed in "${effectiveExecutionCwd}". Starting a fresh remote session.\n`,
);
} else if (runtimeSessionId && isValidUuid && !canResumeSession) {
await onLog(
"stdout",
`[paperclip] Claude session "${runtimeSessionId}" was saved for cwd "${runtimeSessionCwd}" and will not be resumed in "${effectiveExecutionCwd}".\n`,
@@ -916,6 +914,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
: renderTemplate(promptTemplate, templateData);
const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim();
const prompt = joinPromptSections([
instructionsPathDirective,
renderedBootstrapPrompt,
selectInitialCommunicationGuidance(context, { resumedSession: Boolean(resumeSessionId) }),
wakePrompt,
@@ -944,7 +943,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
}
if (attemptInstructionsFilePath && !resumeSessionId) {
commandNotes.push(
`Injected agent instructions via --append-system-prompt-file ${instructionsFilePath} (with path directive appended)`,
`Injected agent instructions via --append-system-prompt-file ${instructionsFilePath} (with current file location in the run prompt)`,
);
}
if (runtimeMcpServers.length > 0) {
@@ -1,12 +1,58 @@
import { describe, expect, it } from "vitest";
import {
CODEX_CHATGPT_MODEL_REJECTION_RE,
codexCliVersionAtLeast,
codexLocalReasoningEffortsForModel,
DEFAULT_CODEX_LOCAL_MODEL,
isCodexLocalFastModeSupported,
minimumCodexCliVersionForModel,
models,
normalizeCodexModel,
parseCodexCliVersionOutput,
} from "./index.js";
describe("codex model CLI floors", () => {
it("records the oldest Codex CLI verified to run each gated model with ChatGPT sign-in", () => {
expect(minimumCodexCliVersionForModel("gpt-6.1-sol")).toBe("0.159.0");
expect(minimumCodexCliVersionForModel(" gpt-6.1-sol ")).toBe("0.159.0");
expect(minimumCodexCliVersionForModel("gpt-6-sol")).toBe("0.157.0");
expect(minimumCodexCliVersionForModel("gpt-6-luna")).toBe("0.157.0");
});
it.each([DEFAULT_CODEX_LOCAL_MODEL, "gpt-5.6", "gpt-6-astra", "gpt-5.5", "custom-model", "", null, undefined])(
"has no floor for %s so callers skip the version probe", (model) => {
expect(minimumCodexCliVersionForModel(model)).toBeNull();
},
);
it("compares stable versions numerically and rejects prerelease or ambiguous input", () => {
expect(codexCliVersionAtLeast("0.159.0", "0.159.0")).toBe(true);
expect(codexCliVersionAtLeast("0.160.1", "0.159.0")).toBe(true);
expect(codexCliVersionAtLeast("1.0.0", "0.159.0")).toBe(true);
expect(codexCliVersionAtLeast("0.156.1", "0.159.0")).toBe(false);
expect(codexCliVersionAtLeast("0.16.0", "0.159.0")).toBe(false);
expect(codexCliVersionAtLeast("0.160.0-alpha.1", "0.159.0")).toBe(false);
expect(codexCliVersionAtLeast("", "0.159.0")).toBe(false);
});
it("parses only a single stable `codex-cli x.y.z` line from --version output", () => {
expect(parseCodexCliVersionOutput("codex-cli 0.160.0\n")).toBe("0.160.0");
expect(parseCodexCliVersionOutput("WARNING: PATH unchanged\r\ncodex-cli 0.156.0\r\n")).toBe("0.156.0");
expect(parseCodexCliVersionOutput("codex-cli 0.162.0-alpha.17")).toBeNull();
expect(parseCodexCliVersionOutput("codex 0.160.0")).toBeNull();
expect(parseCodexCliVersionOutput("codex-cli 0.149.0\ncodex-cli 0.160.0")).toBeNull();
expect(parseCodexCliVersionOutput("")).toBeNull();
});
it("matches the backend's ChatGPT model rejection and captures the model", () => {
const match = CODEX_CHATGPT_MODEL_REJECTION_RE.exec(
"{\"type\":\"error\",\"status\":400,\"error\":{\"type\":\"invalid_request_error\",\"message\":\"The 'gpt-6.1-sol' model is not supported when using Codex with a ChatGPT account.\"}}",
);
expect(match?.[1]).toBe("gpt-6.1-sol");
expect(CODEX_CHATGPT_MODEL_REJECTION_RE.test("Model metadata for `gpt-6.1-sol` not found")).toBe(false);
});
});
describe("codex local adapter metadata", () => {
it("advertises current Codex-capable OpenAI models without changing the default", () => {
const modelIds = models.map((model) => model.id);
@@ -85,6 +85,60 @@ export function codexLocalReasoningEffortsForModel(
}
}
// The Codex backend accepts a model for ChatGPT sign-in only from a client that identifies as a
// recent enough CLI (the request carries the CLI version). An older `codex` fails every turn with
// "The '<model>' model is not supported when using Codex with a ChatGPT account.", which reads
// like an account problem when it is really a stale install, typically a sandbox image baked
// before the catalog gained the model. Record the oldest stable release verified to work so the
// Test and the remote runner can name the actual gap. Omit models with no verified floor; the
// install pin in the runner and images is always at or above every floor listed here.
const CODEX_LOCAL_MODEL_MINIMUM_CLI_VERSIONS: Readonly<Record<string, string>> = {
// 0.156.1 and older are rejected (openai/codex#49396); 0.159.0 and 0.159.2 are accepted
// (openai/codex#49464). No stable release between those was verified either way.
"gpt-6.1-sol": "0.159.0",
// Codex 0.157.0 release notes: "Add GPT-6 Sol and Luna to the model catalog".
"gpt-6-sol": "0.157.0",
"gpt-6-luna": "0.157.0",
};
export function minimumCodexCliVersionForModel(model: string | null | undefined): string | null {
return CODEX_LOCAL_MODEL_MINIMUM_CLI_VERSIONS[normalizeCodexModel(model)] ?? null;
}
const STABLE_CODEX_CLI_VERSION = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
function stableCodexCliVersionParts(version: string): number[] | null {
const match = STABLE_CODEX_CLI_VERSION.exec(version.trim());
if (!match) return null;
const parts = match.slice(1).map(Number);
return parts.every(Number.isSafeInteger) ? parts : null;
}
/** Read the version from `codex --version` output; prerelease or ambiguous output yields null. */
export function parseCodexCliVersionOutput(output: string): string | null {
const versions = output
.split(/\r?\n/)
.map((line) => /^codex-cli (\S+)$/.exec(line.trim())?.[1])
.filter((version): version is string => version !== undefined);
if (versions.length !== 1 || !stableCodexCliVersionParts(versions[0]!)) return null;
return versions[0]!;
}
export function codexCliVersionAtLeast(version: string, minimum: string): boolean {
const parsedVersion = stableCodexCliVersionParts(version);
const parsedMinimum = stableCodexCliVersionParts(minimum);
if (!parsedVersion || !parsedMinimum) return false;
for (let index = 0; index < parsedMinimum.length; index += 1) {
if (parsedVersion[index] !== parsedMinimum[index]) {
return parsedVersion[index]! > parsedMinimum[index]!;
}
}
return true;
}
export const CODEX_CHATGPT_MODEL_REJECTION_RE =
/The '([^']+)' model is not supported when using Codex with a ChatGPT account\./;
export function isCodexLocalKnownModel(model: string | null | undefined): boolean {
const normalizedModel = normalizeModelId(model);
if (!normalizedModel) return false;
@@ -175,6 +229,7 @@ Notes:
- Paperclip injects desired local skills into the effective CODEX_HOME/skills/ directory at execution time so Codex can discover "$paperclip" and related skills without polluting the project working directory. For new and updated agents, Paperclip assigns an isolated managed home at ~/.paperclip/instances/<id>/companies/<companyId>/agents/<agentId>/codex-home/skills/; when CODEX_HOME is explicitly overridden in adapter config, that override is used instead.
- New and updated codex_local agents persist an empty OPENAI_API_KEY override by default so a host-level OPENAI_API_KEY cannot leak into Codex runs through process inheritance. Explicit CODEX_HOME overrides must not point at the shared company codex-home, $CODEX_HOME, or ~/.codex.
- Some model/tool combinations reject certain effort levels (for example minimal with web search enabled).
- With ChatGPT sign-in, the Codex backend accepts a model only from a recent enough Codex CLI: GPT-6.1 Sol needs 0.159.0 or newer, GPT-6 Sol and GPT-6 Luna need 0.157.0 or newer. An older CLI fails every turn with "The '<model>' model is not supported when using Codex with a ChatGPT account." The environment Test and the remote runner compare the installed \`codex --version\` against these floors before running; in a managed sandbox, the fix is a sandbox image that ships the Codex version Paperclip pins.
- Fast mode is supported on GPT-6 (astra/sol/luna), GPT-6.1 Sol, GPT-5.6 (sol/terra/luna), GPT-5.5, GPT-5.4 and manual model IDs. When enabled for those models, Paperclip applies \`service_tier="fast"\` and \`features.fast_mode=true\`.
- When Paperclip realizes a workspace/runtime for a run, it injects PAPERCLIP_WORKSPACE_* and PAPERCLIP_RUNTIME_* env vars for agent-side tooling.
- The ACP engine keeps its workspace sandbox and enables network access on each turn. Explicit sandbox_workspace_write.network_access overrides in extraArgs (or env.PAPERCLIP_CODEX_ACP_NETWORK_ACCESS="false") disable it; execution-target network denial wins. The bundled ACP patch is needed because upstream mode presets override Codex config.toml on every turn.
@@ -0,0 +1,182 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { AdapterExecutionTarget } from "@paperclipai/adapter-utils/execution-target";
const { ensureAdapterExecutionTargetCommandResolvable, runAdapterExecutionTargetProcess } = vi.hoisted(() => ({
ensureAdapterExecutionTargetCommandResolvable: vi.fn(async () => {}),
runAdapterExecutionTargetProcess: vi.fn(async () => ({
exitCode: 0,
signal: null,
timedOut: false,
stdout: "codex-cli 0.156.0\n",
stderr: "",
pid: 1,
startedAt: new Date().toISOString(),
})),
}));
vi.mock("@paperclipai/adapter-utils/execution-target", async () => {
const actual = await vi.importActual<typeof import("@paperclipai/adapter-utils/execution-target")>(
"@paperclipai/adapter-utils/execution-target",
);
return { ...actual, ensureAdapterExecutionTargetCommandResolvable, runAdapterExecutionTargetProcess };
});
import { testCodexAcpEnvironment } from "./acp.js";
describe("codex ACP lane model CLI floor", () => {
let cwd: string;
beforeEach(async () => {
cwd = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-acp-version-"));
vi.stubEnv("CODEX_HOME", await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-acp-home-")));
vi.stubEnv("PAPERCLIP_HOME", await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-acp-instance-")));
vi.stubEnv("PAPERCLIP_INSTANCE_ID", "default");
});
afterEach(async () => {
vi.clearAllMocks();
vi.unstubAllEnvs();
await fs.rm(cwd, { recursive: true, force: true }).catch(() => undefined);
});
function sandboxTarget(): AdapterExecutionTarget {
return {
kind: "remote",
transport: "sandbox",
providerKey: "daytona",
remoteCwd: "/remote/workspace",
runner: {
execute: async () => ({
exitCode: 0,
signal: null,
timedOut: false,
stdout: "",
stderr: "",
pid: null,
startedAt: new Date().toISOString(),
}),
},
};
}
it("fails the sandbox Test when the shared codex is older than the model's floor", async () => {
const result = await testCodexAcpEnvironment({
companyId: "company-1",
adapterType: "codex_local",
config: { cwd, model: "gpt-6.1-sol", env: { OPENAI_API_KEY: "sk-test" } },
executionTarget: sandboxTarget(),
environmentName: "QA Daytona",
});
expect(result.status).toBe("fail");
expect(result.checks.find((check) => check.code === "codex_cli_version_incompatible")).toMatchObject({
level: "error",
message: "gpt-6.1-sol requires Codex CLI 0.159.0 or newer with ChatGPT sign-in.",
detail: "Detected Codex CLI 0.156.0.",
});
expect(runAdapterExecutionTargetProcess).toHaveBeenCalledTimes(1);
const call = runAdapterExecutionTargetProcess.mock.calls[0] as unknown as [
string,
AdapterExecutionTarget,
string,
string[],
{ env: Record<string, string> },
];
expect(call[1]).toMatchObject({ kind: "remote", transport: "sandbox" });
expect(call[2]).toBe("codex");
expect(call[3]).toEqual(["--version"]);
expect(call[4].env).toEqual({ OPENAI_API_KEY: "sk-test" });
});
it("probes the codex the agent's configured env selects, not the image default", async () => {
runAdapterExecutionTargetProcess.mockResolvedValueOnce({
exitCode: 0,
signal: null,
timedOut: false,
stdout: "codex-cli 0.160.0\n",
stderr: "",
pid: 1,
startedAt: new Date().toISOString(),
});
const result = await testCodexAcpEnvironment({
companyId: "company-1",
adapterType: "codex_local",
config: {
cwd,
model: "gpt-6.1-sol",
env: {
OPENAI_API_KEY: "sk-test",
PATH: "/opt/codex-0.160.0/bin:/usr/local/bin:/usr/bin",
CODEX_HOME: "/remote/codex-home",
PAPERCLIP_CODEX_ACP_NETWORK_ACCESS: false,
},
},
executionTarget: sandboxTarget(),
environmentName: "QA Daytona",
});
expect(result.status).toBe("pass");
expect(result.checks.find((check) => check.code === "codex_cli_version_compatible")).toMatchObject({
message: "Codex CLI 0.160.0 satisfies the 0.159.0 minimum for gpt-6.1-sol.",
});
expect(runAdapterExecutionTargetProcess).toHaveBeenCalledTimes(1);
const call = runAdapterExecutionTargetProcess.mock.calls[0] as unknown as [
string,
AdapterExecutionTarget,
string,
string[],
{ env: Record<string, string> },
];
expect(call[3]).toEqual(["--version"]);
// String-valued entries are forwarded exactly as the ACP run receives them;
// non-string values are dropped the same way buildCodexAcpConfig consumers do.
expect(call[4].env).toEqual({
OPENAI_API_KEY: "sk-test",
PATH: "/opt/codex-0.160.0/bin:/usr/local/bin:/usr/bin",
CODEX_HOME: "/remote/codex-home",
});
});
it("passes the sandbox Test when the shared codex satisfies the floor", async () => {
runAdapterExecutionTargetProcess.mockResolvedValueOnce({
exitCode: 0,
signal: null,
timedOut: false,
stdout: "codex-cli 0.160.0\n",
stderr: "",
pid: 1,
startedAt: new Date().toISOString(),
});
const result = await testCodexAcpEnvironment({
companyId: "company-1",
adapterType: "codex_local",
config: { cwd, model: "gpt-6.1-sol", env: { OPENAI_API_KEY: "sk-test" } },
executionTarget: sandboxTarget(),
environmentName: "QA Daytona",
});
expect(result.status).toBe("pass");
expect(result.checks.find((check) => check.code === "codex_cli_version_compatible")).toMatchObject({
message: "Codex CLI 0.160.0 satisfies the 0.159.0 minimum for gpt-6.1-sol.",
});
});
it("does not probe the sandbox for models without a verified floor", async () => {
const result = await testCodexAcpEnvironment({
companyId: "company-1",
adapterType: "codex_local",
config: { cwd, model: "gpt-5.6-sol", env: { OPENAI_API_KEY: "sk-test" } },
executionTarget: sandboxTarget(),
environmentName: "QA Daytona",
});
expect(result.status).toBe("pass");
expect(runAdapterExecutionTargetProcess).not.toHaveBeenCalled();
expect(result.checks.some((check) => check.code.startsWith("codex_cli_version_"))).toBe(false);
});
});
@@ -49,6 +49,7 @@ import {
stageCodexHomeForSync,
} from "./codex-home.js";
import { ADAPTER_AUTH_MISSING_CHECK_CODE } from "./auth-check.js";
import { checkCodexCliVersionForModel } from "./cli-version.js";
const moduleDir = path.dirname(fileURLToPath(import.meta.url));
const packageRootDir = path.resolve(moduleDir, "../..");
@@ -648,6 +649,32 @@ export async function testCodexAcpEnvironment(
}
}
if (targetIsRemote && commandResolvable) {
// The ACP server spawns the shared `codex` installed in the environment.
// Models with a verified CLI floor are checked against that executable so
// a stale sandbox image is named here, before a run fails on every turn
// with the backend's "not supported when using Codex with a ChatGPT
// account" rejection. Models without a floor skip the probe.
//
// Remote ACP runs receive the adapter's string-valued env entries (see
// buildCodexAcpConfig), so the probe gets the same ones. An agent that
// selects its Codex install through PATH is then measured against that
// install rather than the image default.
const probeEnv: Record<string, string> = {};
for (const [key, value] of Object.entries(envConfig)) {
if (typeof value === "string") probeEnv[key] = value;
}
const versionCheck = await checkCodexCliVersionForModel({
runId: `codex-acp-envtest-${Date.now()}-${Math.random().toString(16).slice(2)}`,
model: asString(config.model, ""),
command: "codex",
target,
cwd: resolveAdapterExecutionTargetCwd(target, asString(config.cwd, ""), process.cwd()),
env: probeEnv,
});
if (versionCheck) checks.push(versionCheck.check);
}
const mode = firstNonEmptyString(config.mode, config.acpMode) ?? DEFAULT_ACP_ENGINE_MODE;
const warmHandleIdleMs = asNumber(
config.warmHandleIdleMs ?? config.acpWarmHandleIdleMs,
@@ -0,0 +1,125 @@
import type { AdapterEnvironmentCheck } from "@paperclipai/adapter-utils";
import type { AdapterExecutionTarget } from "@paperclipai/adapter-utils/execution-target";
import { runAdapterExecutionTargetProcess } from "@paperclipai/adapter-utils/execution-target";
import {
CODEX_CHATGPT_MODEL_REJECTION_RE,
codexCliVersionAtLeast,
minimumCodexCliVersionForModel,
normalizeCodexModel,
parseCodexCliVersionOutput,
} from "../index.js";
export const CODEX_CLI_VERSION_INCOMPATIBLE_CHECK_CODE = "codex_cli_version_incompatible";
export const CODEX_CLI_VERSION_COMPATIBLE_CHECK_CODE = "codex_cli_version_compatible";
export const CODEX_HELLO_PROBE_MODEL_REJECTED_CHECK_CODE = "codex_hello_probe_model_rejected";
/**
* Run `codex --version` where the run would execute it. Never cached: an
* operator may upgrade Codex, or promote a new sandbox image, while the
* Paperclip server keeps running, and the next Test should see the change.
*/
export async function readCodexCommandVersion(input: {
runId: string;
command: string;
target: AdapterExecutionTarget | null | undefined;
cwd: string;
env: Record<string, string>;
}): Promise<string | null> {
try {
const result = await runAdapterExecutionTargetProcess(
input.runId,
input.target,
input.command,
["--version"],
{
cwd: input.cwd,
env: input.env,
timeoutSec: 20,
graceSec: 5,
onLog: async () => {},
},
);
if (result.timedOut || result.exitCode !== 0) return null;
return parseCodexCliVersionOutput(`${result.stdout}\n${result.stderr}`);
} catch {
return null;
}
}
function sandboxUpgradeHint(targetIsSandbox: boolean): string {
return targetIsSandbox
? "Promote a sandbox image that ships the Codex CLI version Paperclip pins, then retry the Test. Adding a model to the catalog does not update the Codex CLI baked into existing sandbox images."
: "Upgrade Codex (for example `npm install -g @openai/codex@latest`) where this agent runs, then retry the Test.";
}
/**
* Compare the installed Codex CLI with the floor the configured model needs
* for ChatGPT sign-in. Returns null when the model has no verified floor, so
* callers skip the version probe entirely for such models.
*/
export async function checkCodexCliVersionForModel(input: {
runId: string;
model: string | null | undefined;
command: string;
target: AdapterExecutionTarget | null | undefined;
cwd: string;
env: Record<string, string>;
}): Promise<{ check: AdapterEnvironmentCheck; compatible: boolean; detectedVersion: string | null } | null> {
const minimum = minimumCodexCliVersionForModel(input.model);
if (!minimum) return null;
const model = normalizeCodexModel(input.model);
const targetIsSandbox = input.target?.kind === "remote" && input.target.transport === "sandbox";
const detected = await readCodexCommandVersion(input);
if (detected && codexCliVersionAtLeast(detected, minimum)) {
return {
compatible: true,
detectedVersion: detected,
check: {
code: CODEX_CLI_VERSION_COMPATIBLE_CHECK_CODE,
level: "info",
message: `Codex CLI ${detected} satisfies the ${minimum} minimum for ${model}.`,
},
};
}
return {
compatible: false,
detectedVersion: detected,
check: {
code: CODEX_CLI_VERSION_INCOMPATIBLE_CHECK_CODE,
level: "error",
message: `${model} requires Codex CLI ${minimum} or newer with ChatGPT sign-in.`,
detail: detected
? `Detected Codex CLI ${detected}.`
: "Could not determine the installed Codex CLI version from `codex --version`.",
hint: sandboxUpgradeHint(targetIsSandbox),
},
};
}
/**
* The backend rejects a model for ChatGPT sign-in with one fixed sentence.
* Map it to a dedicated check so the cause (stale CLI, or a plan that does
* not include the model) is named instead of a generic probe failure.
*/
export function codexHelloProbeModelRejectionCheck(input: {
evidence: string;
detectedVersion: string | null;
targetIsSandbox: boolean;
}): AdapterEnvironmentCheck | null {
const match = CODEX_CHATGPT_MODEL_REJECTION_RE.exec(input.evidence);
if (!match) return null;
const model = match[1]!;
const minimum = minimumCodexCliVersionForModel(model);
const versionNote = input.detectedVersion
? ` Detected Codex CLI ${input.detectedVersion}.`
: "";
return {
code: CODEX_HELLO_PROBE_MODEL_REJECTED_CHECK_CODE,
level: "error",
message: `The Codex backend rejected ${model} for this ChatGPT account.`,
detail: `${match[0]}${versionNote}`,
hint: minimum
? `${model} needs Codex CLI ${minimum} or newer with ChatGPT sign-in. ${sandboxUpgradeHint(input.targetIsSandbox)} If the CLI is already new enough, the plan does not include this model (Enterprise and Edu administrators must enable it) or the rollout has not reached the account; choose another model or use an OpenAI API key.`
: `The ChatGPT plan does not include ${model}, the rollout has not reached the account, or the installed Codex CLI is older than the model requires. ${sandboxUpgradeHint(input.targetIsSandbox)} Otherwise choose another model or use an OpenAI API key.`,
};
}
@@ -253,6 +253,125 @@ describe("codex remote environment diagnostics", () => {
expect(probeCall?.[3]).toContain("--skip-git-repo-check");
});
// `sandboxTarget()` is declared later in this describe block; function
// declarations hoist, so the version-floor cases below reuse it.
function versionProbeResult(stdout: string) {
return {
exitCode: 0,
signal: null,
timedOut: false,
stdout,
stderr: "",
pid: 111,
startedAt: new Date().toISOString(),
};
}
it("fails before the hello probe when the sandbox Codex CLI is older than the model's floor", async () => {
// A sandbox image baked before gpt-6.1-sol reached the catalog still runs
// Codex 0.156.0. The backend would reject every turn with "not supported
// when using Codex with a ChatGPT account"; the Test must name the real
// gap (the CLI in the image) instead of running that doomed probe.
runAdapterExecutionTargetProcess.mockResolvedValueOnce(versionProbeResult("codex-cli 0.156.0\n"));
const result = await testEnvironment({
companyId: "company-1",
adapterType: "codex_local",
config: { engine: "cli", command: "codex", model: "gpt-6.1-sol" },
executionTarget: sandboxTarget(),
environmentName: "QA Daytona",
});
expect(result.status).toBe("fail");
const incompatible = result.checks.find((check) => check.code === "codex_cli_version_incompatible");
expect(incompatible).toMatchObject({
level: "error",
message: "gpt-6.1-sol requires Codex CLI 0.159.0 or newer with ChatGPT sign-in.",
detail: "Detected Codex CLI 0.156.0.",
});
expect(incompatible?.hint).toContain("sandbox image");
expect(result.checks.some((check) => check.code === "codex_hello_probe_skipped_cli_version")).toBe(true);
expect(result.checks.some((check) => check.code.startsWith("codex_hello_probe_") && !check.code.includes("skipped"))).toBe(false);
expect(runAdapterExecutionTargetProcess).toHaveBeenCalledTimes(1);
const versionCall = runAdapterExecutionTargetProcess.mock.calls[0] as unknown as [string, AdapterExecutionTarget, string, string[]];
expect(versionCall[2]).toBe("codex");
expect(versionCall[3]).toEqual(["--version"]);
expect(prepareAdapterExecutionTargetRuntime).not.toHaveBeenCalled();
});
it("runs the hello probe when the sandbox Codex CLI satisfies the model's floor", async () => {
runAdapterExecutionTargetProcess.mockResolvedValueOnce(versionProbeResult("codex-cli 0.160.0\n"));
const result = await testEnvironment({
companyId: "company-1",
adapterType: "codex_local",
config: { engine: "cli", command: "codex", model: "gpt-6.1-sol" },
executionTarget: sandboxTarget(),
environmentName: "QA Daytona",
});
expect(result.status).toBe("pass");
expect(result.checks.find((check) => check.code === "codex_cli_version_compatible")).toMatchObject({
level: "info",
message: "Codex CLI 0.160.0 satisfies the 0.159.0 minimum for gpt-6.1-sol.",
});
expect(result.checks.some((check) => check.code === "codex_hello_probe_passed")).toBe(true);
expect(runAdapterExecutionTargetProcess).toHaveBeenCalledTimes(2);
});
it("skips the version probe for models without a verified floor", async () => {
const result = await testEnvironment({
companyId: "company-1",
adapterType: "codex_local",
config: { engine: "cli", command: "codex", model: "gpt-5.6-sol" },
executionTarget: sandboxTarget(),
environmentName: "QA Daytona",
});
expect(result.status).toBe("pass");
expect(result.checks.some((check) => check.code.startsWith("codex_cli_version_"))).toBe(false);
expect(runAdapterExecutionTargetProcess).toHaveBeenCalledTimes(1);
const helloCall = runAdapterExecutionTargetProcess.mock.calls[0] as unknown as [string, AdapterExecutionTarget, string, string[]];
expect(helloCall[3]).not.toEqual(["--version"]);
});
it("names the backend's ChatGPT model rejection instead of a generic probe failure", async () => {
// The CLI is new enough, but the account's plan or rollout does not include
// the model. The backend answers with one fixed sentence; surface it with
// the detected CLI version so the operator can tell the two causes apart.
runAdapterExecutionTargetProcess.mockResolvedValueOnce(versionProbeResult("codex-cli 0.160.0\n"));
runAdapterExecutionTargetProcess.mockResolvedValueOnce({
exitCode: 1,
signal: null,
timedOut: false,
stdout: "",
stderr: "ERROR: {\"type\":\"error\",\"status\":400,\"error\":{\"type\":\"invalid_request_error\",\"message\":\"The 'gpt-6.1-sol' model is not supported when using Codex with a ChatGPT account.\"}}",
pid: 222,
startedAt: new Date().toISOString(),
});
const result = await testEnvironment({
companyId: "company-1",
adapterType: "codex_local",
config: { engine: "cli", command: "codex", model: "gpt-6.1-sol" },
executionTarget: sandboxTarget(),
environmentName: "QA Daytona",
});
expect(result.status).toBe("fail");
const rejected = result.checks.find((check) => check.code === "codex_hello_probe_model_rejected");
expect(rejected).toMatchObject({
level: "error",
message: "The Codex backend rejected gpt-6.1-sol for this ChatGPT account.",
detail: "The 'gpt-6.1-sol' model is not supported when using Codex with a ChatGPT account. Detected Codex CLI 0.160.0.",
});
expect(rejected?.hint).toContain("0.159.0");
expect(rejected?.hint).toContain("plan");
expect(result.checks.some((check) => check.code === "codex_hello_probe_failed")).toBe(false);
// Not an auth failure: the managed-connection flow must not invalidate the credential.
expect(result.checks.some((check) => check.code === "codex_hello_probe_auth_required")).toBe(false);
});
it("emits the canonical adapter_auth_missing check when a sandbox hello probe reports missing auth", async () => {
// The sandbox has no seedable credentials, so the hello probe returns an
// authentication-required error. The Test must emit the neutral canonical
@@ -37,6 +37,7 @@ import {
} from "./codex-auth-cache.js";
import { resolveCodexExecutionEngineForRun, testCodexAcpEnvironment } from "./acp.js";
import { ADAPTER_AUTH_MISSING_CHECK_CODE } from "./auth-check.js";
import { checkCodexCliVersionForModel, codexHelloProbeModelRejectionCheck } from "./cli-version.js";
function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentTestResult["status"] {
if (checks.some((check) => check.level === "error")) return "fail";
@@ -383,7 +384,34 @@ export async function testEnvironment(
const canRunProbe =
checks.every((check) => check.code !== "codex_cwd_invalid" && check.code !== "codex_command_unresolvable");
if (canRunProbe) {
// Models with a verified CLI floor are compared against the installed Codex
// before the hello probe. A probe on an older CLI only produces the backend's
// "not supported when using Codex with a ChatGPT account" rejection, which
// hides the real gap: the CLI (often a stale sandbox image), not the account.
let detectedCliVersion: string | null = null;
let configuredModelIsCompatible = true;
if (canRunProbe && commandLooksLike(command, "codex")) {
const versionCheck = await checkCodexCliVersionForModel({
runId,
model: asString(config.model, ""),
command,
target,
cwd,
env,
});
if (versionCheck) {
checks.push(versionCheck.check);
configuredModelIsCompatible = versionCheck.compatible;
detectedCliVersion = versionCheck.detectedVersion;
}
}
if (canRunProbe && !configuredModelIsCompatible) {
checks.push({
code: "codex_hello_probe_skipped_cli_version",
level: "info",
message: "Skipped hello probe because the installed Codex CLI cannot use the configured model.",
});
} else if (canRunProbe) {
if (!commandLooksLike(command, "codex")) {
checks.push({
code: "codex_hello_probe_skipped_custom_command",
@@ -525,7 +553,12 @@ export async function testEnvironment(
});
}
} else {
checks.push({
const modelRejection = codexHelloProbeModelRejectionCheck({
evidence: `${parsed.errorMessage ?? ""}\n${providerStderr}\n${probe.stdout}`,
detectedVersion: detectedCliVersion,
targetIsSandbox,
});
checks.push(modelRejection ?? {
code: "codex_hello_probe_failed",
level: "error",
message: "Codex hello probe failed.",
@@ -291,6 +291,15 @@ exit 7
const remoteWorkspace = path.join(rootDir, "remote-workspace");
const systemHomeDir = path.join(rootDir, "system-home");
const managedCaptureDir = path.join(rootDir, "managed-capture");
const fixtureBinDir = path.join(rootDir, "fixture-bin");
const networkAttemptPath = path.join(rootDir, "network-attempted");
await fs.mkdir(fixtureBinDir, { recursive: true });
// A regression in installer interception must fail locally, not download
// and execute the real CLI or depend on an external server's latency.
await fs.writeFile(path.join(fixtureBinDir, "curl"), `#!/bin/sh
: > "$FIXTURE_CURL_ATTEMPT_PATH"
exit 97
`, { mode: 0o755 });
await fs.mkdir(managedCaptureDir, { recursive: true });
await fs.mkdir(workspaceDir, { recursive: true });
await fs.mkdir(remoteWorkspace, { recursive: true });
@@ -337,6 +346,7 @@ printf '%s\\n' '{"type":"result","subtype":"success","session_id":"cursor-sessio
const runnerState = {
commands: [] as string[],
installCommands: [] as string[],
};
// The managed-runtime restore path probes the generated archive with
// `wc -c` before reading bounded `dd | base64` chunks. Keep this fixture's
@@ -345,11 +355,23 @@ printf '%s\\n' '{"type":"result","subtype":"success","session_id":"cursor-sessio
const runner = {
execute: async (input: { command: string; args?: string[]; env?: Record<string, string>; stdin?: string }) => {
runnerState.commands.push(input.command);
const args = [...(input.args ?? [])];
if (args[1] === SANDBOX_INSTALL_COMMAND) {
runnerState.installCommands.push(args[1]);
args[1] = buildInstallSimulationCommand(
path.join(systemHomeDir, ".local", "bin", "agent"),
managedCaptureDir,
);
}
// Exercise actual bounded file reads during managed-home restoration;
// reporting empty success for every shell command hides missing bytes.
return runChildProcess(`cursor-fresh-lease-${runnerState.commands.length}`, input.command, input.args ?? [], {
return runChildProcess(`cursor-fresh-lease-${runnerState.commands.length}`, input.command, args, {
cwd: remoteWorkspace,
env: { ...input.env, PATH: `${input.env?.PATH ?? ""}:/usr/bin:/bin` },
env: {
...input.env,
PATH: `${fixtureBinDir}:${input.env?.PATH ?? ""}:/usr/bin:/bin`,
FIXTURE_CURL_ATTEMPT_PATH: networkAttemptPath,
},
stdin: input.stdin,
timeoutSec: 30,
graceSec: 5,
@@ -402,7 +424,11 @@ printf '%s\\n' '{"type":"result","subtype":"success","session_id":"cursor-sessio
});
expect(result.exitCode).toBe(0);
await expect(fs.stat(networkAttemptPath)).rejects.toMatchObject({ code: "ENOENT" });
expect(runnerState.installCommands).toEqual([SANDBOX_INSTALL_COMMAND]);
expect(prepareInputs).toHaveLength(2);
expect(prepareInputs[1].env.HOME).toBeTruthy();
expect(prepareInputs[1].env.HOME).not.toBe(systemHomeDir);
expect(finalPreparedCommand).not.toBeNull();
expect(finalPreparedCommand).toMatch(/\.local\/(bin|sbin)\/agent$/);
const resolvedCommand = runMeta.find(Boolean)?.command as string | undefined;
+37
View File
@@ -312,6 +312,43 @@ up where the last one left off, maintaining conversation context,
memories, and tool state across heartbeats. The `sessionCodec` validates
and migrates session state between runs.
### Managed instructions and resumed turns
The local adapter reads `instructionsFilePath` on every run. It sends that
bundle, its relative-reference base directory, and the standard Paperclip API
guidance through Hermes's native `HERMES_EPHEMERAL_SYSTEM_PROMPT` overlay.
Hermes applies this overlay at each model request, including after context
compression. It does not append the overlay to saved user turns. This requires
a Hermes CLI that supports the [documented environment hook](https://github.com/NousResearch/hermes-agent/blob/main/website/docs/reference/environment-variables.md).
Fresh sessions, resumed sessions, and session resets all receive the current
bundle. Edits take effect on the next run; removing the bundle removes it from
the next overlay. The server's session compatibility and conversation generation
checks still control session reuse. A missing or failed resume remains a failed
run and requires an explicit session reset; it does not automatically restart
work. The next fresh run receives the complete current instruction overlay.
The `-q` user turn contains current runtime identity, task/wake context, handoff
content, and the rendered custom `promptTemplate`, if configured. Custom templates
run on every normal wake. `quiet` changes CLI display only. Disabling persistence
uses full fresh-session context even if old session parameters are supplied.
An explicit `HERMES_EPHEMERAL_SYSTEM_PROMPT` in `env` is preserved before the
Paperclip overlay. Hermes gives this environment hook precedence over its profile's
configured personality/system prompt. Put required additional profile guidance in
that explicit environment value or in the managed bundle. Paperclip does not write
Hermes profile files or modify the parent process environment. Existing copies in
old user turns remain in history until Hermes compresses them or the session resets.
Run the credential-free capture-process regression tests with:
```sh
pnpm --filter @paperclipai/hermes-paperclip-adapter test -- src/server/execute.instructions.test.ts
```
These tests verify actual child-process arguments and environment, not provider
billing, model behavior, or measured token savings.
### Skills Integration
The adapter scans two skill sources and merges them:
+8
View File
@@ -114,6 +114,14 @@ tools, persistent memory, session persistence, skills, and MCP support.
| env | object | {} | Extra environment variables |
| promptTemplate | string | (default) | Custom prompt template with {{variable}} placeholders |
Managed \`instructionsFilePath\` bundles and standard Paperclip API guidance use
Hermes's native \`HERMES_EPHEMERAL_SYSTEM_PROMPT\` overlay on every run, including
resume and reset. They are applied at request time and are not appended to user
history. The query keeps current runtime identity, wake/task content, and custom
templates. Use a Hermes CLI that supports this environment hook. An explicit
overlay in \`env\` is preserved; the hook takes precedence over Hermes's configured
profile personality/system prompt.
## Hermes-Originated Paperclip Tasks
This adapter package also ships a Hermes-facing Paperclip task bridge skill:
@@ -0,0 +1,210 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import type { AdapterExecutionContext } from "@paperclipai/adapter-utils";
import { execute } from "./execute.js";
import { sessionCodec } from "./index.js";
const SESSION_ID = "20261006_200000_abcdef";
const INSTRUCTIONS = "Synthetic governance marker: request approval before governed actions.";
interface Invocation {
args: string[];
systemPrompt: string | null;
runId: string;
}
describe("Hermes managed instruction delivery (real capture process)", () => {
let dir: string;
let command: string;
let instructionsFilePath: string;
let capturePath: string;
beforeEach(async () => {
dir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-hermes-instructions-"));
command = path.join(dir, "hermes.cjs");
instructionsFilePath = path.join(dir, "instructions.md");
capturePath = path.join(dir, "invocations.jsonl");
await fs.writeFile(instructionsFilePath, INSTRUCTIONS);
await fs.writeFile(command, `#!${process.execPath}
const fs = require("node:fs");
const args = process.argv.slice(2);
fs.appendFileSync(process.env.CAPTURE_PATH, JSON.stringify({
args,
systemPrompt: process.env.HERMES_EPHEMERAL_SYSTEM_PROMPT ?? null,
runId: process.env.PAPERCLIP_RUN_ID,
}) + "\\n");
if (process.env.FIXTURE_STDOUT) console.log(process.env.FIXTURE_STDOUT);
if (args.includes("missing-session")) {
console.error("\\u001b[1;31mSession missing-session not found.\\u001b[0m");
process.exit(1);
}
if (process.env.FIXTURE_PROVIDER_ERROR) {
console.error(process.env.FIXTURE_PROVIDER_ERROR);
process.exit(1);
}
console.log("Fixture completed\\nsession_id: ${SESSION_ID}");
`, { mode: 0o700 });
});
afterEach(async () => {
await fs.rm(dir, { recursive: true, force: true });
});
function context(overrides: Partial<AdapterExecutionContext> = {}): AdapterExecutionContext {
return {
runId: "run-fresh",
agent: {
id: "agent-synthetic", companyId: "company-synthetic", name: "Hermes fixture",
adapterType: "hermes_local", adapterConfig: {},
},
runtime: { sessionId: null, sessionParams: null, sessionDisplayId: null, taskKey: "task-synthetic" },
config: {
hermesCommand: command, provider: "openrouter", model: "fixture-model", cwd: dir,
instructionsFilePath, env: { CAPTURE_PATH: capturePath }, timeoutSec: 10, graceSec: 1,
},
context: {
issueId: "task-synthetic",
paperclipTaskMarkdownAssignment: "Complete current synthetic task.",
paperclipTaskMarkdownCompact: "Current synthetic task.",
paperclipWake: {
reason: "issue_commented",
issue: { id: "task-synthetic", title: "Synthetic task", status: "in_progress" },
comments: [{ id: "comment-synthetic", body: "Fresh human direction." }],
commentWindow: { requestedCount: 1, includedCount: 1, missingCount: 0 },
fallbackFetchNeeded: false,
},
},
onLog: async () => {},
...overrides,
};
}
async function invocations(): Promise<Invocation[]> {
return (await fs.readFile(capturePath, "utf8")).trim().split("\n").map((line) => JSON.parse(line));
}
function query(invocation: Invocation): string {
return invocation.args[invocation.args.indexOf("-q") + 1];
}
it.each([false, true])("keeps instructions out of fresh and resumed user turns (conversation=%s)", async (conversationMode) => {
const fresh = context();
fresh.context.conversationMode = conversationMode;
const first = await execute(fresh);
const params = sessionCodec.deserialize(sessionCodec.serialize(first.sessionParams ?? null));
expect(params?.sessionId).toBe(SESSION_ID);
await execute({ ...fresh, runId: "run-resumed", runtime: { ...fresh.runtime, sessionParams: params } });
const [initial, resumed] = await invocations();
expect(initial.args).not.toContain("--resume");
expect(resumed.args.slice(resumed.args.indexOf("--resume"))).toEqual(["--resume", SESSION_ID]);
for (const invocation of [initial, resumed]) {
expect(invocation.systemPrompt).toContain(INSTRUCTIONS);
expect(invocation.systemPrompt).toContain(`Resolve any relative file references from ${dir}/`);
expect(query(invocation)).not.toContain(INSTRUCTIONS);
expect(query(invocation)).not.toContain("Safe multiline update pattern:");
expect(query(invocation)).toContain("Fresh human direction.");
expect(query(invocation)).toContain(invocation.runId);
expect(query(invocation)).toContain("company-synthetic");
expect(invocation.systemPrompt).toContain("$PAPERCLIP_API_KEY");
expect(invocation.systemPrompt).toContain('-H "X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID"');
expect(invocation.systemPrompt).toContain("body=$(cat <<'MD'");
expect(invocation.systemPrompt).toContain("--data-binary @-");
expect(invocation.systemPrompt).toContain('$api/issues/$PAPERCLIP_TASK_ID');
expect(invocation.systemPrompt).not.toContain(invocation.runId);
}
});
it("reapplies current instructions after edits and a session reset", async () => {
const ctx = context();
await execute(ctx);
await fs.writeFile(instructionsFilePath, "Updated governance marker.");
await execute({ ...ctx, runtime: { ...ctx.runtime, sessionParams: { sessionId: SESSION_ID } } });
await execute({ ...ctx, runId: "run-reset" });
const [, edited, reset] = await invocations();
for (const invocation of [edited, reset]) {
expect(invocation.systemPrompt).toContain("Updated governance marker.");
expect(invocation.systemPrompt).not.toContain(INSTRUCTIONS);
expect(query(invocation)).not.toContain("Updated governance marker.");
}
expect(reset.args).not.toContain("--resume");
});
it("uses fresh wake context when persistence is disabled despite stored session params", async () => {
const ctx = context();
const result = await execute({ ...ctx, config: { ...ctx.config, persistSession: false }, runtime: { ...ctx.runtime, sessionParams: { sessionId: SESSION_ID } } });
const [invocation] = await invocations();
expect(invocation.args).not.toContain("--resume");
expect(query(invocation)).toContain("## Paperclip Wake Payload");
expect(query(invocation)).not.toContain("## Paperclip Resume Delta");
expect(invocation.systemPrompt).toContain(INSTRUCTIONS);
expect(result.sessionParams).toBeUndefined();
});
it("keeps custom templates and quiet mode dynamic while preserving a configured system overlay", async () => {
const ctx = context();
await execute({ ...ctx, config: {
...ctx.config, quiet: true, promptTemplate: "Current custom direction for {{runId}}.",
env: { CAPTURE_PATH: capturePath, HERMES_EPHEMERAL_SYSTEM_PROMPT: "User configured system guidance." },
}, runtime: { ...ctx.runtime, sessionParams: { sessionId: SESSION_ID } } });
const [invocation] = await invocations();
expect(invocation.args).toContain("-Q");
expect(query(invocation)).toContain("Current custom direction for run-fresh.");
expect(query(invocation)).not.toContain(INSTRUCTIONS);
expect(invocation.systemPrompt).toContain("User configured system guidance.");
expect(invocation.systemPrompt).toContain(INSTRUCTIONS);
});
it.each([false, true])("keeps an empty wake runnable with current identity (conversation=%s)", async (conversationMode) => {
await execute(context({ context: { conversationMode } }));
const [invocation] = await invocations();
expect(query(invocation)).toContain("run-fresh");
expect(query(invocation)).not.toContain(INSTRUCTIONS);
expect(invocation.systemPrompt).toContain(INSTRUCTIONS);
});
it("surfaces a missing session without automatically restarting work", async () => {
const ctx = context();
const result = await execute({ ...ctx, runtime: { ...ctx.runtime, sessionParams: { sessionId: "missing-session" } } });
const calls = await invocations();
expect(calls).toHaveLength(1);
expect(calls[0].args).toContain("--resume");
expect(calls[0].systemPrompt).toContain(INSTRUCTIONS);
expect(result.exitCode).toBe(1);
expect(result.sessionParams).toBeUndefined();
});
it("does not retry provider failures as a missing session", async () => {
const ctx = context();
const result = await execute({ ...ctx, config: { ...ctx.config, env: { CAPTURE_PATH: capturePath, FIXTURE_PROVIDER_ERROR: "Error: provider unavailable" } }, runtime: { ...ctx.runtime, sessionParams: { sessionId: SESSION_ID } } });
expect(await invocations()).toHaveLength(1);
expect(result.exitCode).toBe(1);
expect(result.errorMessage).toBe("Error: provider unavailable");
});
it("preserves failed work even when its output contains a missing-session diagnostic", async () => {
const ctx = context();
const result = await execute({ ...ctx, config: { ...ctx.config, env: {
CAPTURE_PATH: capturePath,
FIXTURE_STDOUT: `Already updated the task.\nSession ${SESSION_ID} not found.`,
FIXTURE_PROVIDER_ERROR: "Error: provider unavailable after tool execution",
} }, runtime: { ...ctx.runtime, sessionParams: { sessionId: SESSION_ID } } });
expect(await invocations()).toHaveLength(1);
expect(result.exitCode).toBe(1);
expect(result.summary).toContain("Already updated the task.");
expect(result.errorMessage).toBe("Error: provider unavailable after tool execution");
});
it("removes an obsolete instruction overlay when the bundle is removed", async () => {
const ctx = context();
await execute(ctx);
await execute({ ...ctx, config: { ...ctx.config, instructionsFilePath: undefined }, runtime: { ...ctx.runtime, sessionParams: { sessionId: SESSION_ID } } });
const [, resumed] = await invocations();
expect(resumed.systemPrompt).not.toContain(INSTRUCTIONS);
expect(resumed.systemPrompt).toContain("Paperclip API guidance:");
expect(query(resumed)).toContain("Fresh human direction.");
});
});
+31 -24
View File
@@ -34,7 +34,6 @@ import {
renderTemplate,
ensureAbsoluteDirectory,
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
DEFAULT_PAPERCLIP_CONVERSATION_PROMPT_TEMPLATE,
joinPromptSections,
selectPaperclipPromptSections,
stringifyPaperclipWakePayload,
@@ -82,7 +81,7 @@ export function resolveHermesCommand(config: Record<string, unknown>): string {
// Wake-up prompt builder
// ---------------------------------------------------------------------------
const HERMES_DEFAULT_PROMPT_TEMPLATE = [
const HERMES_RUNTIME_IDENTITY_TEMPLATE = [
'You are "{{agent.name}}", an AI agent employee in a Paperclip-managed company.',
"",
"Paperclip runtime identity:",
@@ -90,7 +89,11 @@ const HERMES_DEFAULT_PROMPT_TEMPLATE = [
"- Company ID: {{agent.companyId}}",
"- Run ID: {{run.id}}",
"- API base: {{paperclipApiUrl}}",
"",
].join("\n");
// Hermes applies this overlay at each API call, including after compaction,
// without storing it in conversation history. Keep run/task deltas in -q.
const HERMES_SYSTEM_PROMPT_TEMPLATE = [
"Paperclip API guidance:",
"- Use `curl` from the terminal for Paperclip API calls; browser/web extraction tools may not reach localhost.",
"- Use `$PAPERCLIP_API_URL`, `$PAPERCLIP_API_KEY`, and `$PAPERCLIP_RUN_ID`; do not hard-code local ports or copy secrets into comments.",
@@ -112,7 +115,7 @@ const HERMES_DEFAULT_PROMPT_TEMPLATE = [
"MD",
")",
"jq -n --arg status done --arg comment \"$body\" '{status:$status, comment:$comment}' | \\",
" curl -sS -X PATCH \"$api/issues/{{context.issueId}}\" \\",
" curl -sS -X PATCH \"$api/issues/$PAPERCLIP_TASK_ID\" \\",
" -H \"Authorization: Bearer $PAPERCLIP_API_KEY\" \\",
" -H \"X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID\" \\",
" -H \"Content-Type: application/json\" \\",
@@ -141,9 +144,7 @@ export function buildPrompt(
options: { resumedSession?: boolean } = {},
): string {
const context = (ctx as any).context || {};
const template = cfgString(config.promptTemplate) || (context.conversationMode === true
? DEFAULT_PAPERCLIP_CONVERSATION_PROMPT_TEMPLATE
: HERMES_DEFAULT_PROMPT_TEMPLATE);
const template = cfgString(config.promptTemplate);
const taskId = cfgString(context.taskId) || cfgString(context.issueId) || cfgString(ctx.config?.taskId);
const taskTitle = cfgString(context.taskTitle) || cfgString(ctx.config?.taskTitle) || "";
const taskBody = cfgString(context.taskBody) || cfgString(ctx.config?.taskBody) || "";
@@ -200,10 +201,11 @@ export function buildPrompt(
paperclipRunIdEnv: "PAPERCLIP_RUN_ID",
};
const rendered = isPaperclipRecoveryWakePayload(context.paperclipWake)
? ""
: renderTemplate(renderConditionalSections(template, vars), vars);
const rendered = template && !isPaperclipRecoveryWakePayload(context.paperclipWake)
? renderTemplate(renderConditionalSections(template, vars), vars)
: "";
return joinPromptSections([
renderTemplate(HERMES_RUNTIME_IDENTITY_TEMPLATE, vars),
wakePrompt,
sessionHandoffMarkdown,
taskContextMarkdown,
@@ -404,7 +406,7 @@ export async function execute(
// ── Load agent instructions file (Paperclip instruction bundles) ──────
// Paperclip can materialize managed instructions into instructionsFilePath;
// when present, inject that bundle into the Hermes prompt.
// when present, reapply that bundle through Hermes's native system overlay.
const instructionsFilePath = cfgString(config.instructionsFilePath);
let agentInstructions = "";
if (instructionsFilePath) {
@@ -430,10 +432,8 @@ export async function execute(
}
// ── Build prompt ───────────────────────────────────────────────────────
let prompt = buildPrompt(ctx, config, { resumedSession: Boolean(prevSessionId) });
if (agentInstructions) {
prompt = agentInstructions + "\n\n---\n\n" + prompt;
}
const sessionId = persistSession ? prevSessionId : undefined;
const prompt = buildPrompt(ctx, config, { resumedSession: Boolean(sessionId) });
// ── Build command args ─────────────────────────────────────────────────
// Use -Q (quiet) to get clean output: just response + session_id line
@@ -474,13 +474,9 @@ export async function execute(
// system is designed for human-attended interactive sessions.
args.push("--yolo");
if (persistSession && prevSessionId) {
args.push("--resume", prevSessionId);
}
if (extraArgs?.length) {
args.push(...extraArgs);
}
// Failed resumes remain failures. Do not infer a safe restart from CLI text.
if (sessionId) args.push("--resume", sessionId);
if (extraArgs?.length) args.push(...extraArgs);
// ── Build environment ──────────────────────────────────────────────────
const userEnv = config.env as Record<string, string> | undefined;
@@ -491,6 +487,17 @@ export async function execute(
...buildRuntimeToolsEnv(ctx.runtimeTools),
};
// Scope the overlay to this child process. Read the current bundle on every
// invocation; never infer its availability from a persisted session ID.
// Preserve an operator's explicit overlay before Paperclip's instructions.
env.HERMES_EPHEMERAL_SYSTEM_PROMPT = joinPromptSections([
env.HERMES_EPHEMERAL_SYSTEM_PROMPT,
agentInstructions,
renderTemplate(HERMES_SYSTEM_PROMPT_TEMPLATE, {
agent: ctx.agent,
}),
]);
if (ctx.runId) env.PAPERCLIP_RUN_ID = ctx.runId;
// PAPERCLIP_API_KEY is never accepted from config — the harness-minted run
@@ -527,10 +534,10 @@ export async function execute(
"stdout",
`[hermes] Starting Hermes Agent (model=${model}, provider=${resolvedProvider} [${resolvedFrom}], timeout=${timeoutSec}s${maxTurns ? `, max_turns=${maxTurns}` : ""})\n`,
);
if (prevSessionId) {
if (sessionId) {
await ctx.onLog(
"stdout",
`[hermes] Resuming session: ${prevSessionId}\n`,
`[hermes] Resuming session: ${sessionId}\n`,
);
}
@@ -234,19 +234,16 @@ test("keeps authoritative parent and ancestor context from task markdown", () =>
expect(prompt).not.toContain("check the issue body or comments for references");
});
test("renders safe Paperclip API examples from environment variables with multiline update preservation", () => {
test("keeps current runtime identity in the user turn without repeating static API examples", () => {
const prompt = buildPrompt(baseContext(), {
paperclipApiUrl: "http://paperclip.local/api",
});
expect(prompt).toContain("Use `$PAPERCLIP_API_URL`, `$PAPERCLIP_API_KEY`, and `$PAPERCLIP_RUN_ID`");
expect(prompt).toContain("Displayed command logs may redact secrets");
expect(prompt).toContain('-H "Authorization: Bearer $PAPERCLIP_API_KEY"');
expect(prompt).toContain('-H "X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID"');
expect(prompt).toContain("body=$(cat <<'MD'");
expect(prompt).toContain("jq -n --arg status done --arg comment \"$body\"");
expect(prompt).toContain("--data-binary @-");
expect(prompt).not.toContain("Authorization: Bearer <");
expect(prompt).toContain("- Agent ID: agent-1");
expect(prompt).toContain("- Company ID: company-1");
expect(prompt).toContain("- Run ID: run-1");
expect(prompt).toContain("- API base: http://paperclip.local/api");
expect(prompt).not.toContain("Safe multiline update pattern:");
});
test("preserves custom prompt templates while exposing runtime and wake variables", () => {
@@ -276,7 +273,7 @@ test("preserves custom prompt templates while exposing runtime and wake variable
expect(prompt).toContain('"reason":"issue_assigned"');
expect(prompt).toContain("## Paperclip Wake Payload");
expect(prompt).toContain("Issue description:\n```text\nUse the wake payload as runtime authority.\n```");
expect(prompt).not.toContain("Paperclip runtime identity:");
expect(prompt).toContain("Paperclip runtime identity:");
});
test("keeps historical task markdown available to custom templates while automatic context uses assignment markdown", () => {
+78 -54
View File
@@ -1,6 +1,5 @@
import { createHash } from "node:crypto";
import { drizzle as drizzlePg } from "drizzle-orm/postgres-js";
import { migrate as migratePg } from "drizzle-orm/postgres-js/migrator";
import { readFile, readdir } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import postgres from "postgres";
@@ -463,6 +462,14 @@ async function recordMigrationHistoryEntry(
);
}
// These idempotent privacy migrations commit keyset batches inside their DO
// blocks. Execute each statement at top level so DDL locks and completed batches
// are released before the next batch. History is recorded only after completion.
const MIGRATIONS_WITH_BATCH_COMMITS = new Set([
"0313_private_task_access.sql",
"0314_private_task_draft_assets.sql",
]);
async function applyPendingMigrationsManually(
url: string,
pendingMigrations: string[],
@@ -475,47 +482,81 @@ async function applyPendingMigrationsManually(
journalEntries.map((entry) => [entry.fileName, normalizeFolderMillis(entry.folderMillis)]),
);
const sql = createUtilitySql(url);
const pool = createUtilitySql(url);
try {
const { migrationTableSchema, columnNames } = await ensureMigrationJournalTable(sql);
const qualifiedTable = `${quoteIdentifier(migrationTableSchema)}.${quoteIdentifier(DRIZZLE_MIGRATIONS_TABLE)}`;
const sql = await pool.reserve();
try {
// A session lock survives each batch COMMIT. Reserve the connection so
// pool rotation cannot release it while another migrator is waiting.
await sql`SELECT pg_advisory_lock(hashtextextended('paperclip:migrations', 0))`;
const { migrationTableSchema, columnNames } = await ensureMigrationJournalTable(sql);
const qualifiedTable = `${quoteIdentifier(migrationTableSchema)}.${quoteIdentifier(DRIZZLE_MIGRATIONS_TABLE)}`;
for (const migrationFile of orderedPendingMigrations) {
const migrationContent = await readMigrationFileContent(migrationFile);
const hash = createHash("sha256").update(migrationContent).digest("hex");
const existingEntry = await migrationHistoryEntryExists(
sql,
qualifiedTable,
columnNames,
migrationFile,
hash,
);
if (existingEntry) continue;
await runInTransaction(sql, async () => {
for (const statement of splitMigrationStatements(migrationContent)) {
// Older dev schemas can already lack a constraint that the next
// migration replaces. Keep the published SQL/hash intact and let
// that harmless drop proceed; the replacement still runs normally.
const executable = statement.replace(
/^(\s*ALTER TABLE "[^"]+" DROP CONSTRAINT) (?!IF EXISTS\b)/i,
"$1 IF EXISTS ",
);
await sql.unsafe(executable);
}
await recordMigrationHistoryEntry(
for (const migrationFile of orderedPendingMigrations) {
const migrationContent = await readMigrationFileContent(migrationFile);
const hash = createHash("sha256").update(migrationContent).digest("hex");
const existingEntry = await migrationHistoryEntryExists(
sql,
qualifiedTable,
columnNames,
migrationFile,
hash,
folderMillisByFileName.get(migrationFile) ?? Date.now(),
);
});
if (existingEntry) continue;
const applyMigration = async () => {
for (const statement of splitMigrationStatements(migrationContent)) {
if (MIGRATIONS_WITH_BATCH_COMMITS.has(migrationFile)) {
// A cancelled concurrent build leaves an invalid index. IF NOT
// EXISTS alone would skip it on retry and journal an incomplete index.
const index = statement.replace(/^\s*--.*$/gm, "").trim()
.match(/^CREATE (?:UNIQUE )?INDEX CONCURRENTLY IF NOT EXISTS "([A-Za-z_][A-Za-z0-9_]*)"/i);
if (index) {
const invalid = await sql<{ invalid: boolean }[]>`
SELECT NOT i.indisvalid AS invalid FROM pg_index i
JOIN pg_class c ON c.oid = i.indexrelid
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = 'public' AND c.relname = ${index[1]}`;
if (invalid[0]?.invalid) {
await sql.unsafe(`DROP INDEX CONCURRENTLY ${quoteIdentifier("public")}.${quoteIdentifier(index[1])}`);
}
}
}
// Older dev schemas can lack a replaced constraint. Preserve the
// published SQL/hash while allowing the harmless drop to proceed.
const executable = statement.replace(
/^(\s*ALTER TABLE "[^"]+" DROP CONSTRAINT) (?!IF EXISTS\b)/i,
"$1 IF EXISTS ",
);
await sql.unsafe(executable);
}
await recordMigrationHistoryEntry(
sql,
qualifiedTable,
columnNames,
migrationFile,
hash,
folderMillisByFileName.get(migrationFile) ?? Date.now(),
);
};
if (MIGRATIONS_WITH_BATCH_COMMITS.has(migrationFile)) {
await applyMigration();
} else {
await runInTransaction(sql, applyMigration);
}
}
} finally {
try {
await sql`SELECT pg_advisory_unlock(hashtextextended('paperclip:migrations', 0))`;
} catch {
// Preserve the migration error if the session died; PostgreSQL releases
// its lock on disconnect, and pool.end() closes any remaining connection.
}
sql.release();
}
} finally {
await sql.end();
await pool.end();
}
}
@@ -1040,26 +1081,10 @@ export async function applyPendingMigrations(url: string): Promise<void> {
if (initialState.status === "upToDate") return;
if (initialState.reason === "no-migration-journal-empty-db") {
const sql = createUtilitySql(url);
try {
const db = drizzlePg(sql);
await migratePg(db, { migrationsFolder: MIGRATIONS_FOLDER });
} finally {
await sql.end();
}
let bootstrappedState = await inspectMigrations(url);
if (bootstrappedState.status === "upToDate") return;
if (bootstrappedState.reason === "pending-migrations") {
const repair = await reconcilePendingMigrationHistory(url);
if (repair.repairedMigrations.length > 0) {
bootstrappedState = await inspectMigrations(url);
}
if (bootstrappedState.status === "needsMigrations" && bootstrappedState.reason === "pending-migrations") {
await applyPendingMigrationsManually(url, bootstrappedState.pendingMigrations);
bootstrappedState = await inspectMigrations(url);
}
}
// Use the same per-file executor for bootstrap and upgrades: Drizzle wraps
// the whole journal in one transaction and cannot run committing backfills.
await applyPendingMigrationsManually(url, initialState.pendingMigrations);
const bootstrappedState = await inspectMigrations(url);
if (bootstrappedState.status === "upToDate") return;
throw new Error(
`Failed to bootstrap migrations: ${bootstrappedState.pendingMigrations.join(", ")}`,
@@ -1123,8 +1148,7 @@ export async function migratePostgresIfEmpty(url: string): Promise<MigrationBoot
return { migrated: false, reason: "not-empty-no-migration-journal", tableCount };
}
const db = drizzlePg(sql);
await migratePg(db, { migrationsFolder: MIGRATIONS_FOLDER });
await applyPendingMigrationsManually(url, await listMigrationFiles());
return { migrated: true, reason: "migrated-empty-db", tableCount: 0 };
} finally {
@@ -0,0 +1,285 @@
import { createHash, randomUUID } from "node:crypto";
import fs from "node:fs";
import { afterEach, describe, expect, it } from "vitest";
import postgres from "postgres";
import { applyPendingMigrations } from "./client.js";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./test-embedded-postgres.js";
const MIGRATION_FILE = "0313_private_task_access.sql";
const cleanups: Array<() => Promise<void>> = [];
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
if (!embeddedPostgresSupport.supported) console.warn(`Private-task migration checks unavailable: ${embeddedPostgresSupport.reason}`);
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
async function migrationHash() {
const content = await fs.promises.readFile(new URL(`./migrations/${MIGRATION_FILE}`, import.meta.url), "utf8");
return createHash("sha256").update(content).digest("hex");
}
afterEach(async () => {
while (cleanups.length > 0) await cleanups.pop()?.();
});
describeEmbeddedPostgres("heartbeat run issue privacy migration", () => {
it("backfills every valid context issue binding and preserves maintenance runs", async () => {
const database = await startEmbeddedPostgresTestDatabase("paperclip-run-issue-privacy-");
cleanups.push(database.cleanup);
const sql = postgres(database.connectionString, { max: 1, onnotice: () => {} });
cleanups.push(async () => sql.end());
await sql`DELETE FROM "drizzle"."__drizzle_migrations" WHERE "hash" = ${await migrationHash()}`;
await sql`DROP TRIGGER IF EXISTS heartbeat_runs_set_scope_kind ON heartbeat_runs`;
const companyId = randomUUID();
const agentId = randomUUID();
const firstIssueId = randomUUID();
const secondIssueId = randomUUID();
const firstRunId = randomUUID();
const secondRunId = randomUUID();
const maintenanceRunId = randomUUID();
await sql`
INSERT INTO "companies" ("id", "name", "issue_prefix")
VALUES (${companyId}, 'Run Privacy', 'RPR')
`;
await sql`
INSERT INTO "agents" ("id", "company_id", "name", "role", "adapter_type", "adapter_config")
VALUES (${agentId}, ${companyId}, 'Runner', 'engineer', 'process', '{}'::jsonb)
`;
await sql`
INSERT INTO "issues" ("id", "company_id", "title", "identifier")
VALUES
(${firstIssueId}, ${companyId}, 'First private task', 'RPR-1'),
(${secondIssueId}, ${companyId}, 'Second private task', 'RPR-2')
`;
await sql`
INSERT INTO "heartbeat_runs" (
"id", "company_id", "agent_id", "status", "issue_id", "context_snapshot"
)
VALUES
(${firstRunId}, ${companyId}, ${agentId}, 'succeeded', NULL, ${sql.json({ issueId: firstIssueId })}),
(${secondRunId}, ${companyId}, ${agentId}, 'failed', NULL, ${sql.json({ taskId: secondIssueId })}),
(${maintenanceRunId}, ${companyId}, ${agentId}, 'succeeded', NULL, ${sql.json({ wakeReason: "heartbeat_timer" })})
`;
const legacyProjectId = randomUUID();
await sql`INSERT INTO projects (id, company_id, name, visibility) VALUES (${legacyProjectId}, ${companyId}, 'Legacy private project', 'private')`;
await sql`INSERT INTO activity_log (company_id, actor_type, actor_id, action, entity_type, entity_id)
VALUES (${companyId}, 'user', 'original-owner', 'project.created', 'project', ${legacyProjectId})`;
await sql`INSERT INTO project_access_members (company_id, project_id, subject_type, subject_id)
VALUES (${companyId}, ${legacyProjectId}, 'user', 'ordinary-reader')`;
const agentProjectId = randomUUID(), runProjectId = randomUUID(), preferredProjectId = randomUUID();
await sql`UPDATE heartbeat_runs SET responsible_user_id = 'run-owner' WHERE id = ${secondRunId}`;
await sql`INSERT INTO projects (id, company_id, name, visibility) VALUES
(${agentProjectId}, ${companyId}, 'Agent API project', 'private'),
(${runProjectId}, ${companyId}, 'Run-backed project', 'private'),
(${preferredProjectId}, ${companyId}, 'Recorded owner project', 'private')`;
await sql`INSERT INTO activity_log (company_id, actor_type, actor_id, responsible_user_id, run_id, action, entity_type, entity_id) VALUES
(${companyId}, 'agent', ${agentId}, 'activity-owner', NULL, 'project.created', 'project', ${agentProjectId}),
(${companyId}, 'agent', ${agentId}, NULL, ${secondRunId}, 'project.created', 'project', ${runProjectId}),
(${companyId}, 'agent', ${agentId}, 'activity-owner', ${secondRunId}, 'project.created', 'project', ${preferredProjectId})`;
await applyPendingMigrations(database.connectionString);
for (const [projectId, owner] of [[agentProjectId, 'activity-owner'], [runProjectId, 'run-owner'], [preferredProjectId, 'activity-owner']]) {
expect(await sql`SELECT privacy_owner_user_id FROM projects WHERE id = ${projectId}`).toEqual([{ privacy_owner_user_id: owner }]);
expect(await sql`SELECT subject_id FROM project_access_members WHERE project_id = ${projectId}`).toEqual([{ subject_id: owner }]);
}
const [legacyProject] = await sql`SELECT privacy_owner_user_id FROM projects WHERE id = ${legacyProjectId}`;
expect(legacyProject.privacy_owner_user_id).toBe('original-owner');
expect(await sql`SELECT subject_id FROM project_access_members WHERE project_id = ${legacyProjectId} ORDER BY subject_id`)
.toEqual([{ subject_id: 'ordinary-reader' }, { subject_id: 'original-owner' }]);
const rows = await sql<{ id: string; issue_id: string | null; scope_kind: string }[]>`
SELECT "id", "issue_id", "scope_kind"
FROM "heartbeat_runs"
WHERE "id" IN (${firstRunId}, ${secondRunId}, ${maintenanceRunId})
ORDER BY "id"
`;
const issueByRun = new Map(rows.map((row) => [row.id, row.issue_id]));
expect(issueByRun.get(firstRunId)).toBe(firstIssueId);
expect(issueByRun.get(secondRunId)).toBe(secondIssueId);
expect(issueByRun.get(maintenanceRunId)).toBeNull();
expect(rows.find((row) => row.id === firstRunId)?.scope_kind).toBe("issue");
expect(rows.find((row) => row.id === secondRunId)?.scope_kind).toBe("issue");
expect(rows.find((row) => row.id === maintenanceRunId)?.scope_kind).toBe("company");
const [parity] = await sql<{ expected_count: number; populated_count: number }[]>`
SELECT
count(*) FILTER (WHERE "context_snapshot" ? 'issueId' OR "context_snapshot" ? 'taskId')::int AS "expected_count",
count(*) FILTER (WHERE ("context_snapshot" ? 'issueId' OR "context_snapshot" ? 'taskId') AND "issue_id" IS NOT NULL)::int AS "populated_count"
FROM "heartbeat_runs"
WHERE "id" IN (${firstRunId}, ${secondRunId}, ${maintenanceRunId})
`;
expect(parity).toEqual({ expected_count: 2, populated_count: 2 });
const [newRun] = await sql<{ id: string; issue_id: string | null; scope_kind: string }[]>`
INSERT INTO heartbeat_runs (company_id, agent_id, status, context_snapshot)
VALUES (${companyId}, ${agentId}, 'succeeded', ${sql.json({ taskId: secondIssueId })})
RETURNING id, issue_id, scope_kind`;
expect(newRun).toMatchObject({ issue_id: secondIssueId, scope_kind: "issue" });
await sql`UPDATE heartbeat_runs SET context_snapshot = '{}'::jsonb, scope_kind = 'company' WHERE id = ${newRun.id}`;
expect(await sql`SELECT issue_id, scope_kind FROM heartbeat_runs WHERE id = ${newRun.id}`)
.toEqual([{ issue_id: secondIssueId, scope_kind: "issue" }]);
const indexes = await sql<{ indexname: string }[]>`
SELECT "indexname"
FROM "pg_indexes"
WHERE "schemaname" = 'public'
AND "indexname" = 'heartbeat_runs_company_issue_created_idx'
`;
expect(indexes).toHaveLength(1);
}, 30_000);
it("fails closed when a legacy issue claim cannot be resolved", async () => {
const database = await startEmbeddedPostgresTestDatabase("paperclip-run-issue-privacy-invalid-");
cleanups.push(database.cleanup);
const sql = postgres(database.connectionString, { max: 1, onnotice: () => {} });
cleanups.push(async () => sql.end());
await sql`DELETE FROM "drizzle"."__drizzle_migrations" WHERE "hash" = ${await migrationHash()}`;
await sql`DROP TRIGGER IF EXISTS heartbeat_runs_set_scope_kind ON heartbeat_runs`;
const companyId = randomUUID();
const otherCompanyId = randomUUID();
const agentId = randomUUID();
const foreignIssueId = randomUUID();
await sql`
INSERT INTO "companies" ("id", "name", "issue_prefix")
VALUES
(${companyId}, 'Run Privacy', 'RPI'),
(${otherCompanyId}, 'Other Company', 'RPO')
`;
await sql`
INSERT INTO "agents" ("id", "company_id", "name", "role", "adapter_type", "adapter_config")
VALUES (${agentId}, ${companyId}, 'Runner', 'engineer', 'process', '{}'::jsonb)
`;
await sql`
INSERT INTO "issues" ("id", "company_id", "title", "identifier")
VALUES (${foreignIssueId}, ${otherCompanyId}, 'Foreign task', 'RPO-1')
`;
await sql`
INSERT INTO "heartbeat_runs" ("company_id", "agent_id", "status", "context_snapshot")
VALUES
(${companyId}, ${agentId}, 'succeeded', ${sql.json({ issueId: "not-a-uuid" })}),
(${companyId}, ${agentId}, 'succeeded', ${sql.json({ issueId: randomUUID() })}),
(${companyId}, ${agentId}, 'succeeded', ${sql.json({ issueId: foreignIssueId })}),
(${companyId}, ${agentId}, 'succeeded', ${sql.json({ taskId: "not-a-uuid" })}),
(${companyId}, ${agentId}, 'succeeded', ${sql.json({ taskId: randomUUID() })}),
(${companyId}, ${agentId}, 'succeeded', ${sql.json({ taskId: foreignIssueId })}),
(${companyId}, ${agentId}, 'succeeded', ${sql.json({ taskId: null })})
`;
await applyPendingMigrations(database.connectionString);
const rows = await sql<{ issue_id: string | null; scope_kind: string }[]>`
SELECT issue_id, scope_kind FROM heartbeat_runs WHERE company_id = ${companyId}`;
expect(rows).toHaveLength(7);
expect(rows.every(row => row.scope_kind === "issue" && row.issue_id === null)).toBe(true);
// Re-running the migration preserves fail-closed tombstones and constraints.
await sql`DELETE FROM "drizzle"."__drizzle_migrations" WHERE "hash" = ${await migrationHash()}`;
await applyPendingMigrations(database.connectionString);
}, 30_000);
it("releases schema and completed-batch row locks and resumes after interruption", async () => {
const database = await startEmbeddedPostgresTestDatabase("paperclip-privacy-batch-commits-");
cleanups.push(database.cleanup);
const sql = postgres(database.connectionString, { max: 1, onnotice: () => {} });
cleanups.push(async () => sql.end());
const companyId = randomUUID(), agentId = randomUUID(), issueId = randomUUID();
const runId = (n: number) => `00000000-0000-0000-0000-${String(n).padStart(12, "0")}`;
await sql`INSERT INTO companies (id, name, issue_prefix) VALUES (${companyId}, 'Batch commits', 'BCH')`;
await sql`INSERT INTO agents (id, company_id, name, adapter_type) VALUES (${agentId}, ${companyId}, 'Runner', 'process')`;
await sql`INSERT INTO issues (id, company_id, title, visibility) VALUES (${issueId}, ${companyId}, 'Private task', 'private')`;
await sql`DROP TRIGGER heartbeat_runs_set_scope_kind ON heartbeat_runs`;
await sql`INSERT INTO heartbeat_runs (id, company_id, agent_id, status, context_snapshot)
SELECT ('00000000-0000-0000-0000-' || lpad(n::text, 12, '0'))::uuid,
${companyId}::uuid, ${agentId}::uuid, 'succeeded', ${sql.json({ taskId: issueId })}::jsonb
FROM generate_series(1, 1001) n`;
// A failed concurrent build must be repaired rather than skipped by
// CREATE INDEX IF NOT EXISTS when the migration is retried.
await sql`DROP INDEX heartbeat_runs_company_issue_created_idx`;
await expect(sql.unsafe("CREATE UNIQUE INDEX CONCURRENTLY heartbeat_runs_company_issue_created_idx ON heartbeat_runs (company_id)"))
.rejects.toThrow();
await sql.unsafe(`CREATE FUNCTION interrupt_privacy_batch_fixture() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
PERFORM pg_advisory_xact_lock(736319937);
RAISE EXCEPTION 'fixture interrupted second batch';
END $$`);
await sql.unsafe(`CREATE TRIGGER zzz_interrupt_privacy_batch_fixture BEFORE UPDATE ON heartbeat_runs
FOR EACH ROW WHEN (OLD.scope_kind = 'company' AND NEW.id = '${runId(1001)}'::uuid)
EXECUTE FUNCTION interrupt_privacy_batch_fixture()`);
const hash = await migrationHash();
await sql`DELETE FROM drizzle.__drizzle_migrations WHERE hash = ${hash}`;
await sql`SELECT pg_advisory_lock(736319937)`;
const migration = applyPendingMigrations(database.connectionString).then(() => null, error => error);
let contender: Promise<unknown> | undefined;
try {
// The second batch is blocked while another session can see the first
// committed batch and write a row whose lock has already been released.
await expect.poll(async () => {
const [row] = await sql`SELECT count(*)::int AS n FROM heartbeat_runs
WHERE company_id = ${companyId} AND scope_kind = 'issue'`;
return row.n;
}, { timeout: 10_000 }).toBe(1000);
// The session lock survives batch commits and blocks another migrator.
contender = applyPendingMigrations(database.connectionString).then(() => null, error => error);
await expect.poll(async () => {
const [row] = await sql`SELECT count(*)::int AS n FROM pg_stat_activity
WHERE datname = current_database() AND wait_event = 'advisory'
AND strpos(query, 'paperclip:migrations') > 0`;
return row.n;
}, { timeout: 10_000 }).toBe(1);
expect(await sql`SELECT hash FROM drizzle.__drizzle_migrations WHERE hash = ${hash}`).toHaveLength(0);
await sql`SET lock_timeout = '500ms'`;
await sql`UPDATE heartbeat_runs SET context_snapshot = context_snapshot || '{"probe":"visible"}'::jsonb
WHERE id = ${runId(1)}`;
expect(await sql`SELECT context_snapshot->>'probe' AS probe FROM heartbeat_runs WHERE id = ${runId(1)}`)
.toEqual([{ probe: "visible" }]);
} finally {
await sql`SELECT pg_advisory_unlock(736319937)`;
await sql`RESET lock_timeout`;
await migration;
await contender;
}
expect(await migration).toMatchObject({ message: "fixture interrupted second batch" });
expect(await contender).toMatchObject({ message: "fixture interrupted second batch" });
expect(await sql`SELECT hash FROM drizzle.__drizzle_migrations WHERE hash = ${hash}`).toHaveLength(0);
await sql`DROP TRIGGER zzz_interrupt_privacy_batch_fixture ON heartbeat_runs`;
await sql`DROP FUNCTION interrupt_privacy_batch_fixture()`;
await Promise.all([applyPendingMigrations(database.connectionString), applyPendingMigrations(database.connectionString)]);
const [finished] = await sql`SELECT count(*)::int AS n FROM heartbeat_runs
WHERE company_id = ${companyId} AND scope_kind = 'issue' AND issue_id = ${issueId}`;
expect(finished.n).toBe(1001);
expect(await sql`SELECT indisvalid FROM pg_index WHERE indexrelid = 'heartbeat_runs_company_issue_created_idx'::regclass`)
.toEqual([{ indisvalid: true }]);
expect(await sql`SELECT hash FROM drizzle.__drizzle_migrations WHERE hash = ${hash}`).toHaveLength(1);
}, 30_000);
it("keeps all privacy triggers installed when replacement is interrupted", async () => {
const database = await startEmbeddedPostgresTestDatabase("paperclip-privacy-trigger-atomic-");
cleanups.push(database.cleanup);
const sql = postgres(database.connectionString, { max: 1, onnotice: () => {} });
cleanups.push(async () => sql.end());
const content = await fs.promises.readFile(new URL(`./migrations/${MIGRATION_FILE}`, import.meta.url), "utf8");
const replacements = content.split("--> statement-breakpoint").filter(statement => /DROP TRIGGER IF EXISTS/.test(statement));
expect(replacements).toHaveLength(4);
for (const statement of replacements) {
const name = statement.match(/DROP TRIGGER IF EXISTS ([A-Za-z_]+)/)![1];
const interrupted = statement.replace("CREATE TRIGGER", "RAISE EXCEPTION 'fixture interrupted trigger replacement';\n CREATE TRIGGER");
await expect(sql.unsafe(interrupted)).rejects.toThrow("fixture interrupted trigger replacement");
expect(await sql`SELECT tgname FROM pg_trigger WHERE tgname = ${name} AND NOT tgisinternal`).toEqual([{ tgname: name }]);
}
const companyId = randomUUID(), agentId = randomUUID(), issueId = randomUUID();
await sql`INSERT INTO companies (id, name, issue_prefix) VALUES (${companyId}, 'Atomic triggers', 'ATR')`;
await sql`INSERT INTO agents (id, company_id, name, adapter_type) VALUES (${agentId}, ${companyId}, 'Runner', 'process')`;
await sql`INSERT INTO issues (id, company_id, title, visibility) VALUES (${issueId}, ${companyId}, 'Private', 'private')`;
expect(await sql`INSERT INTO heartbeat_runs (company_id, agent_id, status, context_snapshot)
VALUES (${companyId}, ${agentId}, 'succeeded', ${sql.json({ taskId: issueId })}) RETURNING issue_id, scope_kind`)
.toEqual([{ issue_id: issueId, scope_kind: "issue" }]);
}, 30_000);
});
@@ -0,0 +1,232 @@
-- Preserve values while upgrading exact accounting. Additions are replay-safe.
CREATE TABLE IF NOT EXISTS "accounting_runtime_baselines" (
"agent_id" uuid PRIMARY KEY NOT NULL,
"company_id" uuid NOT NULL,
"cost_cents" numeric(24, 7) NOT NULL,
"input_tokens" numeric(30, 0) NOT NULL,
"cached_input_tokens" numeric(30, 0) NOT NULL,
"output_tokens" numeric(30, 0) NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "billing_invoice_lines" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"company_id" uuid NOT NULL,
"invoice_id" uuid NOT NULL,
"external_id" text NOT NULL,
"kind" text NOT NULL,
"amount_cents" numeric(24, 7) NOT NULL,
"occurred_at" timestamp with time zone NOT NULL,
"cost_event_id" uuid,
"run_id" uuid,
"provider_request_id" text,
"model" text,
"pricing" jsonb DEFAULT '{}'::jsonb NOT NULL,
CONSTRAINT "billing_invoice_lines_amount_check" CHECK ("billing_invoice_lines"."amount_cents" >= 0),
CONSTRAINT "billing_invoice_lines_kind_check" CHECK ("billing_invoice_lines"."kind" in ('inference', 'fee', 'credit'))
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "billing_invoices" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"company_id" uuid NOT NULL,
"biller" text NOT NULL,
"external_id" text NOT NULL,
"currency" text NOT NULL,
"receipt_hash" text NOT NULL,
"imported_by" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "budget_reservations" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"company_id" uuid NOT NULL,
"run_id" uuid NOT NULL,
"agent_id" uuid NOT NULL,
"project_id" uuid,
"amount_cents" numeric(24, 7) NOT NULL,
"state" text DEFAULT 'held' NOT NULL,
"provider_started_at" timestamp with time zone,
"settled_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "budget_reservations_state_check" CHECK ("budget_reservations"."state" in ('held', 'settled', 'released')),
CONSTRAINT "budget_reservations_amount_check" CHECK ("budget_reservations"."amount_cents" >= 0)
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "cost_adjustments" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"company_id" uuid NOT NULL,
"cost_event_id" uuid NOT NULL,
"invoice_line_id" uuid,
"idempotency_key" text NOT NULL,
"receipt_hash" text NOT NULL,
"previous_cents" numeric(24, 7) NOT NULL,
"corrected_cents" numeric(24, 7) NOT NULL,
"previous_status" text NOT NULL,
"previous_pricing" jsonb DEFAULT '{}'::jsonb NOT NULL,
"reason" text NOT NULL,
"actor_id" text NOT NULL,
"pricing" jsonb DEFAULT '{}'::jsonb NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "cost_adjustments_amount_check" CHECK ("cost_adjustments"."previous_cents" >= 0 and "cost_adjustments"."corrected_cents" >= 0)
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "provider_billing_snapshots" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"company_id" uuid NOT NULL,
"provider" text NOT NULL,
"account_id" text NOT NULL,
"scope_id" text NOT NULL,
"day" text NOT NULL,
"amount_cents" numeric(24, 7) NOT NULL,
"revision" integer DEFAULT 1 NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "provider_billing_snapshots_amount_check" CHECK ("provider_billing_snapshots"."amount_cents" >= 0)
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "run_usage_receipts" (
"id" uuid PRIMARY KEY NOT NULL,
"company_id" uuid NOT NULL,
"run_id" uuid NOT NULL,
"source_id" uuid NOT NULL,
"sequence" integer NOT NULL,
"receipt_hash" text NOT NULL,
"receipt_json" jsonb NOT NULL,
"complete" boolean NOT NULL,
"received_at" timestamp with time zone NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "run_usage_receipts_positive_sequence" CHECK ("run_usage_receipts"."sequence" > 0)
);
--> statement-breakpoint
DROP INDEX IF EXISTS "budget_incidents_policy_window_threshold_idx";--> statement-breakpoint
ALTER TABLE "agent_runtime_state" ALTER COLUMN "total_cost_cents" SET DATA TYPE numeric(24, 7);--> statement-breakpoint
ALTER TABLE "agents" ALTER COLUMN "spent_monthly_cents" SET DATA TYPE numeric(24, 7);--> statement-breakpoint
ALTER TABLE "budget_incidents" ALTER COLUMN "amount_observed" SET DATA TYPE numeric(24, 7);--> statement-breakpoint
ALTER TABLE "companies" ALTER COLUMN "spent_monthly_cents" SET DATA TYPE numeric(24, 7);--> statement-breakpoint
ALTER TABLE "cost_events" ALTER COLUMN "cost_cents" SET DATA TYPE numeric(24, 7);--> statement-breakpoint
ALTER TABLE "finance_events" ALTER COLUMN "amount_cents" SET DATA TYPE numeric(24, 7);--> statement-breakpoint
ALTER TABLE "agents" ADD COLUMN IF NOT EXISTS "spend_month_utc" text;--> statement-breakpoint
ALTER TABLE "budget_policies" ADD COLUMN IF NOT EXISTS "reservation_cents" numeric(24, 7) DEFAULT '0' NOT NULL;--> statement-breakpoint
ALTER TABLE "budget_policies" ADD COLUMN IF NOT EXISTS "unpriced_usage_policy" text DEFAULT 'block' NOT NULL;--> statement-breakpoint
ALTER TABLE "budget_policies" ADD COLUMN IF NOT EXISTS "enforcement_version" integer DEFAULT 0 NOT NULL;--> statement-breakpoint
ALTER TABLE "budget_policies" ADD COLUMN IF NOT EXISTS "enforcement_delivered_version" integer DEFAULT 0 NOT NULL;--> statement-breakpoint
ALTER TABLE "companies" ADD COLUMN IF NOT EXISTS "spend_month_utc" text;--> statement-breakpoint
ALTER TABLE "cost_events" ADD COLUMN IF NOT EXISTS "idempotency_key" text;--> statement-breakpoint
ALTER TABLE "cost_events" ADD COLUMN IF NOT EXISTS "receipt_hash" text;--> statement-breakpoint
ALTER TABLE "cost_events" ADD COLUMN IF NOT EXISTS "provider_request_id" text;--> statement-breakpoint
ALTER TABLE "cost_events" ADD COLUMN IF NOT EXISTS "reported_cost_cents" numeric(24, 7);--> statement-breakpoint
ALTER TABLE "cost_events" ADD COLUMN IF NOT EXISTS "pricing_provenance" jsonb;--> statement-breakpoint
ALTER TABLE "finance_events" ADD COLUMN IF NOT EXISTS "idempotency_key" text;--> statement-breakpoint
ALTER TABLE "finance_events" ADD COLUMN IF NOT EXISTS "receipt_hash" text;--> statement-breakpoint
ALTER TABLE "heartbeat_runs" ADD COLUMN IF NOT EXISTS "cost_accounting_pending" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "heartbeat_runs" ADD COLUMN IF NOT EXISTS "cost_accounted_at" timestamp with time zone;--> statement-breakpoint
ALTER TABLE "heartbeat_runs" ADD COLUMN IF NOT EXISTS "accounting_projection_version" text;--> statement-breakpoint
ALTER TABLE "heartbeat_runs" ADD COLUMN IF NOT EXISTS "accounting_last_attempt_at" timestamp with time zone;--> statement-breakpoint
ALTER TABLE "heartbeat_runs" ADD COLUMN IF NOT EXISTS "accounting_last_error" text;--> statement-breakpoint
ALTER TABLE "heartbeat_runs" ADD COLUMN IF NOT EXISTS "accounting_attempt_count" integer DEFAULT 0 NOT NULL;--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'accounting_runtime_baselines_agent_id_agents_id_fk' AND conrelid = 'public.accounting_runtime_baselines'::regclass) THEN
ALTER TABLE "accounting_runtime_baselines" ADD CONSTRAINT "accounting_runtime_baselines_agent_id_agents_id_fk" FOREIGN KEY ("agent_id") REFERENCES "public"."agents"("id") ON DELETE cascade ON UPDATE no action;
END IF;
END $$;--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'accounting_runtime_baselines_company_id_companies_id_fk' AND conrelid = 'public.accounting_runtime_baselines'::regclass) THEN
ALTER TABLE "accounting_runtime_baselines" ADD CONSTRAINT "accounting_runtime_baselines_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;
END IF;
END $$;--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'billing_invoice_lines_company_id_companies_id_fk' AND conrelid = 'public.billing_invoice_lines'::regclass) THEN
ALTER TABLE "billing_invoice_lines" ADD CONSTRAINT "billing_invoice_lines_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;
END IF;
END $$;--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'billing_invoice_lines_invoice_id_billing_invoices_id_fk' AND conrelid = 'public.billing_invoice_lines'::regclass) THEN
ALTER TABLE "billing_invoice_lines" ADD CONSTRAINT "billing_invoice_lines_invoice_id_billing_invoices_id_fk" FOREIGN KEY ("invoice_id") REFERENCES "public"."billing_invoices"("id") ON DELETE cascade ON UPDATE no action;
END IF;
END $$;--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'billing_invoice_lines_cost_event_id_cost_events_id_fk' AND conrelid = 'public.billing_invoice_lines'::regclass) THEN
ALTER TABLE "billing_invoice_lines" ADD CONSTRAINT "billing_invoice_lines_cost_event_id_cost_events_id_fk" FOREIGN KEY ("cost_event_id") REFERENCES "public"."cost_events"("id") ON DELETE cascade ON UPDATE no action;
END IF;
END $$;--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'billing_invoices_company_id_companies_id_fk' AND conrelid = 'public.billing_invoices'::regclass) THEN
ALTER TABLE "billing_invoices" ADD CONSTRAINT "billing_invoices_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;
END IF;
END $$;--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'budget_reservations_company_id_companies_id_fk' AND conrelid = 'public.budget_reservations'::regclass) THEN
ALTER TABLE "budget_reservations" ADD CONSTRAINT "budget_reservations_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;
END IF;
END $$;--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'budget_reservations_run_id_heartbeat_runs_id_fk' AND conrelid = 'public.budget_reservations'::regclass) THEN
ALTER TABLE "budget_reservations" ADD CONSTRAINT "budget_reservations_run_id_heartbeat_runs_id_fk" FOREIGN KEY ("run_id") REFERENCES "public"."heartbeat_runs"("id") ON DELETE cascade ON UPDATE no action;
END IF;
END $$;--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'budget_reservations_agent_id_agents_id_fk' AND conrelid = 'public.budget_reservations'::regclass) THEN
ALTER TABLE "budget_reservations" ADD CONSTRAINT "budget_reservations_agent_id_agents_id_fk" FOREIGN KEY ("agent_id") REFERENCES "public"."agents"("id") ON DELETE cascade ON UPDATE no action;
END IF;
END $$;--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'cost_adjustments_company_id_companies_id_fk' AND conrelid = 'public.cost_adjustments'::regclass) THEN
ALTER TABLE "cost_adjustments" ADD CONSTRAINT "cost_adjustments_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;
END IF;
END $$;--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'cost_adjustments_cost_event_id_cost_events_id_fk' AND conrelid = 'public.cost_adjustments'::regclass) THEN
ALTER TABLE "cost_adjustments" ADD CONSTRAINT "cost_adjustments_cost_event_id_cost_events_id_fk" FOREIGN KEY ("cost_event_id") REFERENCES "public"."cost_events"("id") ON DELETE cascade ON UPDATE no action;
END IF;
END $$;--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'cost_adjustments_invoice_line_id_billing_invoice_lines_id_fk' AND conrelid = 'public.cost_adjustments'::regclass) THEN
ALTER TABLE "cost_adjustments" ADD CONSTRAINT "cost_adjustments_invoice_line_id_billing_invoice_lines_id_fk" FOREIGN KEY ("invoice_line_id") REFERENCES "public"."billing_invoice_lines"("id") ON DELETE cascade ON UPDATE no action;
END IF;
END $$;--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'provider_billing_snapshots_company_id_companies_id_fk' AND conrelid = 'public.provider_billing_snapshots'::regclass) THEN
ALTER TABLE "provider_billing_snapshots" ADD CONSTRAINT "provider_billing_snapshots_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;
END IF;
END $$;--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'run_usage_receipts_company_id_companies_id_fk' AND conrelid = 'public.run_usage_receipts'::regclass) THEN
ALTER TABLE "run_usage_receipts" ADD CONSTRAINT "run_usage_receipts_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;
END IF;
END $$;--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'run_usage_receipts_run_id_heartbeat_runs_id_fk' AND conrelid = 'public.run_usage_receipts'::regclass) THEN
ALTER TABLE "run_usage_receipts" ADD CONSTRAINT "run_usage_receipts_run_id_heartbeat_runs_id_fk" FOREIGN KEY ("run_id") REFERENCES "public"."heartbeat_runs"("id") ON DELETE cascade ON UPDATE no action;
END IF;
END $$;--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "accounting_runtime_baselines_company_idx" ON "accounting_runtime_baselines" USING btree ("company_id");--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "billing_invoice_lines_identity_idx" ON "billing_invoice_lines" USING btree ("invoice_id","external_id");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "billing_invoice_lines_company_idx" ON "billing_invoice_lines" USING btree ("company_id","invoice_id");--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "billing_invoices_identity_idx" ON "billing_invoices" USING btree ("company_id","biller","external_id");--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "budget_reservations_run_idx" ON "budget_reservations" USING btree ("company_id","run_id");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "budget_reservations_active_idx" ON "budget_reservations" USING btree ("company_id","agent_id","project_id") WHERE "budget_reservations"."state" = 'held';--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "cost_adjustments_receipt_idx" ON "cost_adjustments" USING btree ("company_id","idempotency_key");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "cost_adjustments_event_idx" ON "cost_adjustments" USING btree ("company_id","cost_event_id","created_at");--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "cost_adjustments_invoice_line_idx" ON "cost_adjustments" USING btree ("company_id","invoice_line_id");--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "provider_billing_snapshots_identity_idx" ON "provider_billing_snapshots" USING btree ("company_id","provider","account_id","scope_id","day");--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "run_usage_receipts_source_sequence_idx" ON "run_usage_receipts" USING btree ("company_id","run_id","source_id","sequence");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "run_usage_receipts_run_latest_idx" ON "run_usage_receipts" USING btree ("company_id","run_id","received_at");--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "cost_events_company_receipt_idx" ON "cost_events" USING btree ("company_id","idempotency_key");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "cost_events_provider_request_idx" ON "cost_events" USING btree ("company_id","biller","provider_request_id");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "cost_events_company_project_occurred_idx" ON "cost_events" USING btree ("company_id","project_id","occurred_at");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "cost_events_unpriced_idx" ON "cost_events" USING btree ("company_id","occurred_at","id") WHERE "cost_events"."cost_status" = 'unpriced' and "cost_events"."billing_type" <> 'subscription_included';--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "finance_events_company_receipt_idx" ON "finance_events" USING btree ("company_id","idempotency_key");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "heartbeat_runs_cost_accounting_pending_idx" ON "heartbeat_runs" USING btree ("updated_at","id") WHERE "heartbeat_runs"."cost_accounting_pending" = true;--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "budget_incidents_policy_window_threshold_idx" ON "budget_incidents" USING btree ("policy_id","window_start","threshold_type") WHERE "budget_incidents"."status" = 'open';--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'budget_policies_reservation_nonnegative' AND conrelid = 'public.budget_policies'::regclass) THEN
ALTER TABLE "budget_policies" ADD CONSTRAINT "budget_policies_reservation_nonnegative" CHECK ("budget_policies"."reservation_cents" >= 0);
END IF;
END $$;--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'cost_events_nonnegative_amounts' AND conrelid = 'public.cost_events'::regclass) THEN
ALTER TABLE "cost_events" ADD CONSTRAINT "cost_events_nonnegative_amounts" CHECK ("cost_events"."cost_cents" >= 0 and "cost_events"."input_tokens" >= 0 and "cost_events"."cached_input_tokens" >= 0 and "cost_events"."output_tokens" >= 0);
END IF;
END $$;
--> statement-breakpoint
-- Preserve the ledger precision in status-card update snapshots. Reapplying this type widening is safe.
ALTER TABLE "status_card_updates" ALTER COLUMN "cost_cents" SET DATA TYPE numeric(24, 7);
@@ -0,0 +1,278 @@
-- Runs through the Paperclip migration executor outside a file-wide transaction.
-- Each idempotent keyset batch commits before advancing; history is recorded last.
CREATE TABLE IF NOT EXISTS "issue_access_grants" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"issue_id" uuid NOT NULL,
"subject_type" text NOT NULL,
"subject_id" text NOT NULL,
"source" text NOT NULL,
"granted_by_user_id" text,
"granted_by_agent_id" uuid,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"revoked_at" timestamp with time zone,
CONSTRAINT "issue_access_grants_subject_type_check" CHECK ("issue_access_grants"."subject_type" in ('user', 'agent')),
CONSTRAINT "issue_access_grants_source_check" CHECK ("issue_access_grants"."source" in ('explicit', 'assignment', 'project'))
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "project_access_members" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"company_id" uuid NOT NULL,
"project_id" uuid NOT NULL,
"subject_type" text NOT NULL,
"subject_id" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "project_access_members_subject_type_check" CHECK ("project_access_members"."subject_type" in ('user', 'agent'))
);
--> statement-breakpoint
ALTER TABLE "workspace_operations" DROP CONSTRAINT IF EXISTS "workspace_operations_heartbeat_run_id_heartbeat_runs_id_fk";
--> statement-breakpoint
ALTER TABLE "workspace_operations" DROP CONSTRAINT IF EXISTS "workspace_operations_issue_id_issues_id_fk";
--> statement-breakpoint
ALTER TABLE "heartbeat_runs" ADD COLUMN IF NOT EXISTS "scope_kind" text DEFAULT 'company' NOT NULL;--> statement-breakpoint
ALTER TABLE "heartbeat_runs" ADD COLUMN IF NOT EXISTS "issue_id" uuid;--> statement-breakpoint
ALTER TABLE "issues" ADD COLUMN IF NOT EXISTS "visibility" text DEFAULT 'open' NOT NULL;--> statement-breakpoint
ALTER TABLE "issues" ADD COLUMN IF NOT EXISTS "privacy_root_issue_id" uuid;--> statement-breakpoint
ALTER TABLE "issues" ADD COLUMN IF NOT EXISTS "privacy_parent_issue_id" uuid;--> statement-breakpoint
ALTER TABLE "projects" ADD COLUMN IF NOT EXISTS "visibility" text DEFAULT 'open' NOT NULL;--> statement-breakpoint
ALTER TABLE "projects" ADD COLUMN IF NOT EXISTS "personal_owner_user_id" text;--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'issue_access_grants_issue_id_issues_id_fk' AND conrelid = 'public.issue_access_grants'::regclass) THEN
ALTER TABLE "issue_access_grants" ADD CONSTRAINT "issue_access_grants_issue_id_issues_id_fk" FOREIGN KEY ("issue_id") REFERENCES "public"."issues"("id") ON DELETE cascade ON UPDATE no action;
END IF;
END $$;--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'issue_access_grants_granted_by_agent_id_agents_id_fk' AND conrelid = 'public.issue_access_grants'::regclass) THEN
ALTER TABLE "issue_access_grants" ADD CONSTRAINT "issue_access_grants_granted_by_agent_id_agents_id_fk" FOREIGN KEY ("granted_by_agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action;
END IF;
END $$;--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'project_access_members_company_id_companies_id_fk' AND conrelid = 'public.project_access_members'::regclass) THEN
ALTER TABLE "project_access_members" ADD CONSTRAINT "project_access_members_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;
END IF;
END $$;--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'project_access_members_project_id_projects_id_fk' AND conrelid = 'public.project_access_members'::regclass) THEN
ALTER TABLE "project_access_members" ADD CONSTRAINT "project_access_members_project_id_projects_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("id") ON DELETE cascade ON UPDATE no action;
END IF;
END $$;--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "issue_access_grants_subject_active_issue_idx" ON "issue_access_grants" USING btree ("subject_type","subject_id","revoked_at","issue_id");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "issue_access_grants_issue_idx" ON "issue_access_grants" USING btree ("issue_id","created_at");--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "project_access_members_project_subject_uq" ON "project_access_members" USING btree ("project_id","subject_type","subject_id");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "project_access_members_subject_lookup_idx" ON "project_access_members" USING btree ("company_id","subject_type","subject_id","project_id");--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'heartbeat_runs_issue_id_issues_id_fk' AND conrelid = 'public.heartbeat_runs'::regclass) THEN
ALTER TABLE "heartbeat_runs" ADD CONSTRAINT "heartbeat_runs_issue_id_issues_id_fk" FOREIGN KEY ("issue_id") REFERENCES "public"."issues"("id") ON DELETE set null ON UPDATE no action;
END IF;
END $$;--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'issues_privacy_root_issue_id_issues_id_fk' AND conrelid = 'public.issues'::regclass) THEN
ALTER TABLE "issues" ADD CONSTRAINT "issues_privacy_root_issue_id_issues_id_fk" FOREIGN KEY ("privacy_root_issue_id") REFERENCES "public"."issues"("id") ON DELETE no action ON UPDATE no action;
END IF;
END $$;--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'issues_privacy_parent_issue_id_issues_id_fk' AND conrelid = 'public.issues'::regclass) THEN
ALTER TABLE "issues" ADD CONSTRAINT "issues_privacy_parent_issue_id_issues_id_fk" FOREIGN KEY ("privacy_parent_issue_id") REFERENCES "public"."issues"("id") ON DELETE set null ON UPDATE no action;
END IF;
END $$;--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'workspace_operations_heartbeat_run_id_heartbeat_runs_id_fk' AND conrelid = 'public.workspace_operations'::regclass) THEN
ALTER TABLE "workspace_operations" ADD CONSTRAINT "workspace_operations_heartbeat_run_id_heartbeat_runs_id_fk" FOREIGN KEY ("heartbeat_run_id") REFERENCES "public"."heartbeat_runs"("id") ON DELETE set null ON UPDATE no action;
END IF;
END $$;--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'workspace_operations_issue_id_issues_id_fk' AND conrelid = 'public.workspace_operations'::regclass) THEN
ALTER TABLE "workspace_operations" ADD CONSTRAINT "workspace_operations_issue_id_issues_id_fk" FOREIGN KEY ("issue_id") REFERENCES "public"."issues"("id") ON DELETE set null ON UPDATE no action;
END IF;
END $$;--> statement-breakpoint
CREATE INDEX CONCURRENTLY IF NOT EXISTS "heartbeat_runs_company_issue_created_idx" ON "heartbeat_runs" USING btree ("company_id","issue_id","created_at");--> statement-breakpoint
CREATE INDEX CONCURRENTLY IF NOT EXISTS "issues_company_privacy_parent_idx" ON "issues" USING btree ("company_id","privacy_parent_issue_id");--> statement-breakpoint
CREATE INDEX CONCURRENTLY IF NOT EXISTS "issues_company_privacy_root_idx" ON "issues" USING btree ("company_id","privacy_root_issue_id");--> statement-breakpoint
CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS "projects_company_personal_owner_uq" ON "projects" USING btree ("company_id","personal_owner_user_id") WHERE "projects"."personal_owner_user_id" is not null;--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'heartbeat_runs_scope_binding_check' AND conrelid = 'public.heartbeat_runs'::regclass) THEN
ALTER TABLE "heartbeat_runs" ADD CONSTRAINT "heartbeat_runs_scope_binding_check" CHECK (("heartbeat_runs"."scope_kind" = 'company' AND "heartbeat_runs"."issue_id" IS NULL)
OR "heartbeat_runs"."scope_kind" = 'issue');
END IF;
END $$;--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'issues_visibility_check' AND conrelid = 'public.issues'::regclass) THEN
ALTER TABLE "issues" ADD CONSTRAINT "issues_visibility_check" CHECK ("issues"."visibility" in ('open', 'private'));
END IF;
END $$;--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'projects_visibility_check' AND conrelid = 'public.projects'::regclass) THEN
ALTER TABLE "projects" ADD CONSTRAINT "projects_visibility_check" CHECK ("projects"."visibility" in ('open', 'private'));
END IF;
END $$;
--> statement-breakpoint
CREATE OR REPLACE FUNCTION set_heartbeat_run_scope_kind() RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE candidate uuid;
BEGIN
-- A private run never becomes a company run through context edits or deletion.
IF TG_OP = 'UPDATE' AND OLD.scope_kind = 'issue' THEN
IF OLD.issue_id IS NOT NULL AND NEW.issue_id IS NOT NULL AND OLD.issue_id <> NEW.issue_id THEN
RAISE EXCEPTION 'run task binding is immutable';
END IF;
IF OLD.issue_id IS NOT NULL AND NEW.native_issue_id IS NOT NULL AND OLD.issue_id <> NEW.native_issue_id THEN
RAISE EXCEPTION 'native run task binding disagrees with privacy binding';
END IF;
NEW.scope_kind := 'issue';
RETURN NEW;
END IF;
candidate := coalesce(NEW.native_issue_id, NEW.issue_id);
IF candidate IS NULL AND coalesce(NEW.context_snapshot->>'issueId', NEW.context_snapshot->>'taskId') ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' THEN
candidate := coalesce(NEW.context_snapshot->>'issueId', NEW.context_snapshot->>'taskId')::uuid;
END IF;
IF candidate IS NOT NULL OR NEW.context_snapshot ? 'issueId' OR NEW.context_snapshot ? 'taskId' OR NEW.scope_kind = 'issue' THEN
NEW.scope_kind := 'issue';
SELECT id INTO NEW.issue_id FROM issues WHERE id = candidate AND company_id = NEW.company_id;
ELSE
NEW.scope_kind := 'company';
END IF;
RETURN NEW;
END $$;
--> statement-breakpoint
DO $$ BEGIN
DROP TRIGGER IF EXISTS heartbeat_runs_set_scope_kind ON heartbeat_runs;
CREATE TRIGGER heartbeat_runs_set_scope_kind BEFORE INSERT OR UPDATE OF issue_id, native_issue_id, context_snapshot, scope_kind
ON heartbeat_runs FOR EACH ROW EXECUTE FUNCTION set_heartbeat_run_scope_kind();
END $$;
--> statement-breakpoint
-- Indexed primary-key keyset batches visit each historical run once. Missing
-- source tasks become issue-scoped tombstones, never company-visible run content.
DO $$ DECLARE cursor_id uuid := '00000000-0000-0000-0000-000000000000'; next_id uuid;
BEGIN LOOP
WITH batch AS MATERIALIZED (SELECT id FROM heartbeat_runs WHERE id > cursor_id ORDER BY id LIMIT 1000),
changed AS (UPDATE heartbeat_runs r SET scope_kind = r.scope_kind FROM batch b WHERE r.id = b.id RETURNING r.id)
SELECT id INTO next_id FROM changed ORDER BY id DESC LIMIT 1;
EXIT WHEN next_id IS NULL;
cursor_id := next_id;
COMMIT;
END LOOP; END $$;
--> statement-breakpoint
-- Existing structural children acquire downward inheritance without widening
-- any task's visibility. New provenance edges are written at task creation.
DO $$ DECLARE cursor_id uuid := '00000000-0000-0000-0000-000000000000'; next_id uuid;
BEGIN LOOP
WITH batch AS MATERIALIZED (SELECT id FROM issues WHERE id > cursor_id ORDER BY id LIMIT 1000),
changed AS (UPDATE issues i SET privacy_parent_issue_id = coalesce(i.privacy_parent_issue_id, i.parent_id)
FROM batch b WHERE i.id = b.id RETURNING i.id)
SELECT id INTO next_id FROM changed ORDER BY id DESC LIMIT 1;
EXIT WHEN next_id IS NULL;
cursor_id := next_id;
COMMIT;
END LOOP; END $$;
--> statement-breakpoint
-- Keep workspace provenance after reassignment/deletion. A previous task may
-- have left files behind; losing the live FK must not publish those files.
CREATE OR REPLACE FUNCTION retain_workspace_privacy_sources() RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE sources jsonb := '{}'::jsonb; linked jsonb;
BEGIN
IF TG_OP = 'UPDATE' AND jsonb_typeof(OLD.metadata->'_issuePrivacySources') = 'object' THEN
sources := OLD.metadata->'_issuePrivacySources';
END IF;
IF NEW.source_issue_id IS NOT NULL THEN
sources := sources || jsonb_build_object(NEW.source_issue_id::text, true);
END IF;
SELECT jsonb_object_agg(id::text, true) INTO linked FROM issues
WHERE execution_workspace_id = NEW.id AND company_id = NEW.company_id;
sources := sources || coalesce(linked, '{}'::jsonb);
NEW.metadata := coalesce(NEW.metadata, '{}'::jsonb) || jsonb_build_object('_issuePrivacySources', sources);
RETURN NEW;
END $$;
--> statement-breakpoint
DO $$ BEGIN
DROP TRIGGER IF EXISTS execution_workspaces_privacy_sources ON execution_workspaces;
CREATE TRIGGER execution_workspaces_privacy_sources BEFORE INSERT OR UPDATE OF source_issue_id, metadata
ON execution_workspaces FOR EACH ROW EXECUTE FUNCTION retain_workspace_privacy_sources();
END $$;
--> statement-breakpoint
CREATE OR REPLACE FUNCTION record_issue_workspace_privacy_source() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
IF NEW.execution_workspace_id IS NOT NULL THEN
UPDATE execution_workspaces SET metadata = metadata
WHERE id = NEW.execution_workspace_id AND company_id = NEW.company_id;
END IF;
RETURN NEW;
END $$;
--> statement-breakpoint
DO $$ BEGIN
DROP TRIGGER IF EXISTS issues_record_workspace_privacy_source ON issues;
CREATE TRIGGER issues_record_workspace_privacy_source AFTER INSERT OR UPDATE OF execution_workspace_id
ON issues FOR EACH ROW EXECUTE FUNCTION record_issue_workspace_privacy_source();
END $$;
--> statement-breakpoint
DO $$ DECLARE cursor_id uuid := '00000000-0000-0000-0000-000000000000'; next_id uuid;
BEGIN LOOP
WITH batch AS MATERIALIZED (SELECT id FROM execution_workspaces WHERE id > cursor_id ORDER BY id LIMIT 1000),
changed AS (UPDATE execution_workspaces w SET metadata = metadata FROM batch b WHERE w.id = b.id RETURNING w.id)
SELECT id INTO next_id FROM changed ORDER BY id DESC LIMIT 1;
EXIT WHEN next_id IS NULL;
cursor_id := next_id;
COMMIT;
END LOOP; END $$;
--> statement-breakpoint
-- Preserve operation history while retaining every original task/run boundary.
CREATE OR REPLACE FUNCTION paperclip_keep_operation_privacy_sources() RETURNS trigger AS $$
DECLARE issue_sources jsonb := '{}'::jsonb; run_sources jsonb := '{}'::jsonb;
BEGIN
IF TG_OP = 'UPDATE' THEN
issue_sources := COALESCE(OLD.metadata->'_issuePrivacySources', '{}'::jsonb);
run_sources := COALESCE(OLD.metadata->'_runPrivacySources', '{}'::jsonb);
IF OLD.issue_id IS NOT NULL THEN issue_sources := issue_sources || jsonb_build_object(OLD.issue_id::text, true); END IF;
IF OLD.heartbeat_run_id IS NOT NULL THEN run_sources := run_sources || jsonb_build_object(OLD.heartbeat_run_id::text, true); END IF;
END IF;
IF NEW.issue_id IS NOT NULL THEN issue_sources := issue_sources || jsonb_build_object(NEW.issue_id::text, true); END IF;
IF NEW.heartbeat_run_id IS NOT NULL THEN run_sources := run_sources || jsonb_build_object(NEW.heartbeat_run_id::text, true); END IF;
NEW.metadata := COALESCE(NEW.metadata, '{}'::jsonb) || jsonb_build_object('_issuePrivacySources', issue_sources, '_runPrivacySources', run_sources);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
--> statement-breakpoint
DO $$ BEGIN
DROP TRIGGER IF EXISTS workspace_operations_keep_privacy_sources ON workspace_operations;
CREATE TRIGGER workspace_operations_keep_privacy_sources BEFORE INSERT OR UPDATE ON workspace_operations
FOR EACH ROW EXECUTE FUNCTION paperclip_keep_operation_privacy_sources();
END $$;
--> statement-breakpoint
DO $$
DECLARE cursor_id uuid := '00000000-0000-0000-0000-000000000000'; batch_ids uuid[];
BEGIN
LOOP
SELECT array_agg(id ORDER BY id) INTO batch_ids FROM (
SELECT id FROM workspace_operations WHERE id > cursor_id ORDER BY id LIMIT 1000
) batch;
EXIT WHEN batch_ids IS NULL;
UPDATE workspace_operations SET metadata = metadata WHERE id = ANY(batch_ids);
cursor_id := batch_ids[array_length(batch_ids, 1)];
COMMIT;
END LOOP;
END $$;
--> statement-breakpoint
ALTER TABLE projects ADD COLUMN IF NOT EXISTS privacy_owner_user_id text;
--> statement-breakpoint
-- Recover management authority from creation evidence, never from read grants.
DO $$
DECLARE cursor_id uuid := '00000000-0000-0000-0000-000000000000'; batch_ids uuid[];
BEGIN
LOOP
SELECT array_agg(id ORDER BY id) INTO batch_ids FROM (
SELECT id FROM projects WHERE id > cursor_id ORDER BY id LIMIT 1000
) batch;
EXIT WHEN batch_ids IS NULL;
UPDATE projects p SET privacy_owner_user_id = COALESCE(p.personal_owner_user_id, (
SELECT CASE WHEN a.actor_type = 'user' THEN a.actor_id ELSE coalesce(a.responsible_user_id, r.responsible_user_id) END
FROM activity_log a LEFT JOIN heartbeat_runs r ON r.id = a.run_id AND r.company_id = a.company_id
WHERE a.company_id = p.company_id AND a.entity_type = 'project' AND a.entity_id = p.id::text AND a.action = 'project.created'
ORDER BY a.created_at, a.id LIMIT 1
)) WHERE p.id = ANY(batch_ids) AND p.privacy_owner_user_id IS NULL;
INSERT INTO project_access_members (company_id, project_id, subject_type, subject_id)
SELECT company_id, id, 'user', privacy_owner_user_id FROM projects
WHERE id = ANY(batch_ids) AND visibility = 'private' AND privacy_owner_user_id IS NOT NULL
ON CONFLICT (project_id, subject_type, subject_id) DO NOTHING;
cursor_id := batch_ids[array_length(batch_ids, 1)];
COMMIT;
END LOOP;
END $$;
@@ -0,0 +1,28 @@
-- Runs through the Paperclip migration executor outside a file-wide transaction.
-- Each idempotent keyset batch commits before advancing; history is recorded last.
-- Bind historical inline draft images to their first owned task. Unbound drafts
-- remain uploader-only. Attachment deletion never makes the draft company-open.
DO $$
DECLARE cursor_id uuid := '00000000-0000-0000-0000-000000000000'; next_id uuid;
BEGIN
LOOP
SELECT max(id::text)::uuid INTO next_id FROM (
SELECT id FROM assets WHERE id > cursor_id AND object_key LIKE company_id::text || '/assets/issues/drafts/%'
ORDER BY id LIMIT 1000
) batch;
EXIT WHEN next_id IS NULL;
INSERT INTO issue_attachments (company_id, issue_id, asset_id)
SELECT a.company_id, first_issue.id, a.id FROM assets a
CROSS JOIN LATERAL (
SELECT i.id FROM issues i WHERE i.company_id = a.company_id
AND (i.created_by_user_id = a.created_by_user_id OR i.responsible_user_id = a.created_by_user_id
OR i.created_by_agent_id = a.created_by_agent_id)
AND strpos(coalesce(i.description, ''), '/api/assets/' || a.id::text || '/content') > 0
ORDER BY i.created_at, i.id LIMIT 1
) first_issue
WHERE a.id > cursor_id AND a.id <= next_id AND a.object_key LIKE a.company_id::text || '/assets/issues/drafts/%'
ON CONFLICT DO NOTHING;
cursor_id := next_id;
COMMIT;
END LOOP;
END $$;
@@ -0,0 +1,44 @@
CREATE TABLE IF NOT EXISTS "user_company_preferences" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"company_id" uuid NOT NULL,
"user_id" text NOT NULL,
"primary_agent_id" uuid,
"primary_agent_initialized" boolean DEFAULT false NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "user_company_preferences" ADD CONSTRAINT "user_company_preferences_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "user_company_preferences" ADD CONSTRAINT "user_company_preferences_primary_agent_id_agents_id_fk" FOREIGN KEY ("primary_agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "user_company_preferences_company_user_uq" ON "user_company_preferences" USING btree ("company_id","user_id");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "user_company_preferences_primary_agent_idx" ON "user_company_preferences" USING btree ("primary_agent_id");--> statement-breakpoint
-- Attribute only explicit human creation events. Select the original event
-- before joining the agent: deletion/termination must not promote a later hire.
WITH first_creations AS (
SELECT DISTINCT ON (company_id, actor_id)
company_id, actor_id AS user_id, entity_id AS agent_id
FROM activity_log
WHERE actor_type = 'user'
AND actor_id NOT IN ('', 'board', 'system', 'unknown', 'unknown-user')
AND entity_type = 'agent'
AND action IN ('agent.created', 'agent.hire_created')
ORDER BY company_id, actor_id, created_at, id
)
INSERT INTO user_company_preferences (company_id, user_id, primary_agent_id, primary_agent_initialized)
SELECT c.company_id, c.user_id,
CASE WHEN a.status != 'terminated' AND m.state IS DISTINCT FROM 'left'
THEN a.id ELSE NULL END,
true
FROM first_creations c
LEFT JOIN agents a ON a.id::text = c.agent_id AND a.company_id = c.company_id
LEFT JOIN agent_memberships m ON m.company_id = c.company_id AND m.user_id = c.user_id AND m.agent_id = a.id
WHERE a.metadata->'paperclipBuiltInAgent' IS NULL
ON CONFLICT (company_id, user_id) DO NOTHING;
@@ -0,0 +1,81 @@
-- Idempotent for installations that applied the pre-merge decision migration.
CREATE TABLE IF NOT EXISTS "company_decision_models" (
"company_id" uuid PRIMARY KEY NOT NULL,
"connection_id" uuid,
"grant_id" uuid,
"enabled" boolean DEFAULT false NOT NULL,
"allow_background" boolean DEFAULT true NOT NULL,
"provider" text,
"model" text,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "company_decisions_configured" CHECK (("company_decision_models"."connection_id" is null) = ("company_decision_models"."grant_id" is null) and (not "company_decision_models"."enabled" or ("company_decision_models"."connection_id" is not null and "company_decision_models"."provider" is not null and "company_decision_models"."model" is not null)))
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "decision_invocations" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"company_id" uuid NOT NULL,
"feature" text NOT NULL,
"actor_type" text NOT NULL,
"actor_id" text NOT NULL,
"responsible_user_id" text,
"agent_id" uuid,
"issue_id" uuid,
"project_id" uuid,
"run_id" uuid,
"identity_context_id" uuid,
"connection_id" uuid NOT NULL,
"grant_id" uuid NOT NULL,
"provider" text NOT NULL,
"model" text NOT NULL,
"question_types" jsonb NOT NULL,
"status" text DEFAULT 'running' NOT NULL,
"error_code" text,
"provider_request_id" text,
"cost_event_id" uuid,
"started_at" timestamp with time zone DEFAULT now() NOT NULL,
"finished_at" timestamp with time zone,
"duration_ms" integer,
"input_tokens" integer,
"output_tokens" integer,
CONSTRAINT "decision_invocations_status_check" CHECK ("decision_invocations"."status" in ('running', 'succeeded', 'failed', 'unknown'))
);
--> statement-breakpoint
ALTER TABLE "budget_reservations" ALTER COLUMN "run_id" DROP NOT NULL;--> statement-breakpoint
ALTER TABLE "budget_reservations" ALTER COLUMN "agent_id" DROP NOT NULL;--> statement-breakpoint
ALTER TABLE "cost_events" ALTER COLUMN "agent_id" DROP NOT NULL;--> statement-breakpoint
ALTER TABLE "budget_reservations" ADD COLUMN IF NOT EXISTS "decision_invocation_id" uuid;--> statement-breakpoint
ALTER TABLE "cost_events" ADD COLUMN IF NOT EXISTS "usage_kind" text DEFAULT 'agent' NOT NULL;--> statement-breakpoint
ALTER TABLE "cost_events" ADD COLUMN IF NOT EXISTS "responsible_user_id" text;--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'company_decision_models_company_id_companies_id_fk' AND conrelid = 'company_decision_models'::regclass) THEN
ALTER TABLE "company_decision_models" ADD CONSTRAINT "company_decision_models_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;
END IF;
END $$;--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'decision_invocations_company_id_companies_id_fk' AND conrelid = 'decision_invocations'::regclass) THEN
ALTER TABLE "decision_invocations" ADD CONSTRAINT "decision_invocations_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;
END IF;
END $$;--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'decision_invocations_cost_event_id_cost_events_id_fk' AND conrelid = 'decision_invocations'::regclass) THEN
ALTER TABLE "decision_invocations" ADD CONSTRAINT "decision_invocations_cost_event_id_cost_events_id_fk" FOREIGN KEY ("cost_event_id") REFERENCES "public"."cost_events"("id") ON DELETE no action ON UPDATE no action;
END IF;
END $$;--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "decision_invocations_company_started_idx" ON "decision_invocations" USING btree ("company_id","started_at","id");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "decision_invocations_pending_idx" ON "decision_invocations" USING btree ("started_at") WHERE "decision_invocations"."status" = 'running';--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "decision_invocations_cost_idx" ON "decision_invocations" USING btree ("cost_event_id");--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "budget_reservations_decision_idx" ON "budget_reservations" USING btree ("company_id","decision_invocation_id");--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'budget_reservations_source_check' AND conrelid = 'budget_reservations'::regclass) THEN
ALTER TABLE "budget_reservations" ADD CONSTRAINT "budget_reservations_source_check" CHECK (("budget_reservations"."run_id" is not null)::int + ("budget_reservations"."decision_invocation_id" is not null)::int = 1);
END IF;
END $$;--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'cost_events_usage_kind_check' AND conrelid = 'cost_events'::regclass) THEN
ALTER TABLE "cost_events" ADD CONSTRAINT "cost_events_usage_kind_check" CHECK ("cost_events"."usage_kind" = 'decision' or ("cost_events"."usage_kind" = 'agent' and "cost_events"."agent_id" is not null));
END IF;
END $$;
--> statement-breakpoint
ALTER TABLE "company_decision_models" DROP CONSTRAINT IF EXISTS "company_decision_models_connection_id_tool_connections_id_fk";
--> statement-breakpoint
ALTER TABLE "company_decision_models" DROP CONSTRAINT IF EXISTS "company_decision_models_grant_id_connection_grants_id_fk";
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+37 -2
View File
@@ -2175,8 +2175,43 @@
{
"idx": 312,
"version": "7",
"when": 1791339842209,
"tag": "0312_broken_sentinels",
"when": 1791336871975,
"tag": "0312_easy_eternity",
"breakpoints": true
},
{
"idx": 313,
"version": "7",
"when": 1791371746435,
"tag": "0313_private_task_access",
"breakpoints": true
},
{
"idx": 314,
"version": "7",
"when": 1791371819805,
"tag": "0314_private_task_draft_assets",
"breakpoints": true
},
{
"idx": 315,
"version": "7",
"when": 1791390905429,
"tag": "0315_rapid_emma_frost",
"breakpoints": true
},
{
"idx": 316,
"version": "7",
"when": 1791400295370,
"tag": "0316_premium_slayback",
"breakpoints": true
},
{
"idx": 317,
"version": "7",
"when": 1791416259455,
"tag": "0317_messy_famine",
"breakpoints": true
}
]
@@ -0,0 +1,35 @@
import { createHash, randomUUID } from "node:crypto";
import fs from "node:fs/promises";
import postgres from "postgres";
import { expect, it } from "vitest";
import { applyPendingMigrations } from "./client.js";
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./test-embedded-postgres.js";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const itEmbeddedPostgres = embeddedPostgresSupport.supported ? it : it.skip;
itEmbeddedPostgres("backfills owned draft images without attaching another uploader's images", async () => {
const database = await startEmbeddedPostgresTestDatabase("paperclip-draft-privacy-");
const sql = postgres(database.connectionString, { max: 1, onnotice: () => {} });
try {
const migration = await fs.readFile(new URL("./migrations/0314_private_task_draft_assets.sql", import.meta.url), "utf8");
const hash = createHash("sha256").update(migration).digest("hex");
const company = randomUUID(), task = randomUUID(), owned = randomUUID(), foreign = randomUUID();
await sql`INSERT INTO companies (id, name, issue_prefix) VALUES (${company}, 'Draft privacy', 'DRAFT')`;
for (const [id, owner] of [[owned, 'owner'], [foreign, 'someone-else']]) {
await sql`INSERT INTO assets (id, company_id, provider, object_key, content_type, byte_size, sha256, created_by_user_id)
VALUES (${id}, ${company}, 'local_disk', ${`${company}/assets/issues/drafts/${id}`}, 'image/png', 1, 'test', ${owner})`;
}
await sql`INSERT INTO issues (id, company_id, title, description, visibility, created_by_user_id)
VALUES (${task}, ${company}, 'Private task', ${`![owned](/api/assets/${owned}/content) ![foreign](/api/assets/${foreign}/content)`}, 'private', 'owner')`;
for (let run = 0; run < 2; run += 1) {
await sql`DELETE FROM drizzle.__drizzle_migrations WHERE hash = ${hash}`;
await applyPendingMigrations(database.connectionString);
expect(await sql`SELECT issue_id, asset_id FROM issue_attachments WHERE company_id = ${company}`)
.toEqual([{ issue_id: task, asset_id: owned }]);
}
} finally {
await sql.end();
await database.cleanup();
}
}, 60_000);
+124
View File
@@ -0,0 +1,124 @@
import { sql } from "drizzle-orm";
import { boolean, check, index, integer, jsonb, numeric, pgTable, text, timestamp, uniqueIndex, uuid } from "drizzle-orm/pg-core";
import { companies } from "./companies.js";
import { agents } from "./agents.js";
import { heartbeatRuns } from "./heartbeat_runs.js";
export const providerBillingSnapshots = pgTable("provider_billing_snapshots", {
id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
provider: text("provider").notNull(),
accountId: text("account_id").notNull(),
scopeId: text("scope_id").notNull(),
day: text("day").notNull(),
amountCents: numeric("amount_cents", { precision: 24, scale: 7 }).notNull(),
revision: integer("revision").notNull().default(1),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
}, table => ({
identity: uniqueIndex("provider_billing_snapshots_identity_idx").on(table.companyId, table.provider, table.accountId, table.scopeId, table.day),
amount: check("provider_billing_snapshots_amount_check", sql`${table.amountCents} >= 0`),
}));
import { costEvents } from "./cost_events.js";
export const runUsageReceipts = pgTable("run_usage_receipts", {
id: uuid("id").primaryKey(),
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
runId: uuid("run_id").notNull().references(() => heartbeatRuns.id, { onDelete: "cascade" }),
sourceId: uuid("source_id").notNull(),
sequence: integer("sequence").notNull(),
receiptHash: text("receipt_hash").notNull(),
receiptJson: jsonb("receipt_json").$type<Record<string, unknown>>().notNull(),
complete: boolean("complete").notNull(),
receivedAt: timestamp("received_at", { withTimezone: true }).notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
}, table => ({
sourceSequence: uniqueIndex("run_usage_receipts_source_sequence_idx").on(table.companyId, table.runId, table.sourceId, table.sequence),
runLatest: index("run_usage_receipts_run_latest_idx").on(table.companyId, table.runId, table.receivedAt),
positiveSequence: check("run_usage_receipts_positive_sequence", sql`${table.sequence} > 0`),
}));
export const accountingRuntimeBaselines = pgTable("accounting_runtime_baselines", {
agentId: uuid("agent_id").primaryKey().references(() => agents.id, { onDelete: "cascade" }),
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
// Historical totals are preserved explicitly. Only v2 acknowledgements add
// to this baseline, including receipts whose occurrence date is backdated.
costCents: numeric("cost_cents", { precision: 24, scale: 7 }).notNull(),
inputTokens: numeric("input_tokens", { precision: 30, scale: 0 }).notNull(),
cachedInputTokens: numeric("cached_input_tokens", { precision: 30, scale: 0 }).notNull(),
outputTokens: numeric("output_tokens", { precision: 30, scale: 0 }).notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
}, table => ({ company: index("accounting_runtime_baselines_company_idx").on(table.companyId) }));
export const budgetReservations = pgTable("budget_reservations", {
id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
runId: uuid("run_id").references(() => heartbeatRuns.id, { onDelete: "cascade" }),
decisionInvocationId: uuid("decision_invocation_id"),
agentId: uuid("agent_id").references(() => agents.id, { onDelete: "cascade" }),
projectId: uuid("project_id"),
amountCents: numeric("amount_cents", { precision: 24, scale: 7 }).notNull(),
state: text("state").notNull().default("held"),
providerStartedAt: timestamp("provider_started_at", { withTimezone: true }),
settledAt: timestamp("settled_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
}, table => ({
decision: uniqueIndex("budget_reservations_decision_idx").on(table.companyId, table.decisionInvocationId),
source: check("budget_reservations_source_check", sql`(${table.runId} is not null)::int + (${table.decisionInvocationId} is not null)::int = 1`),
run: uniqueIndex("budget_reservations_run_idx").on(table.companyId, table.runId),
active: index("budget_reservations_active_idx").on(table.companyId, table.agentId, table.projectId).where(sql`${table.state} = 'held'`),
stateCheck: check("budget_reservations_state_check", sql`${table.state} in ('held', 'settled', 'released')`),
amountCheck: check("budget_reservations_amount_check", sql`${table.amountCents} >= 0`),
}));
export const billingInvoices = pgTable("billing_invoices", {
id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
biller: text("biller").notNull(),
externalId: text("external_id").notNull(),
currency: text("currency").notNull(),
receiptHash: text("receipt_hash").notNull(),
importedBy: text("imported_by").notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
}, table => ({ identity: uniqueIndex("billing_invoices_identity_idx").on(table.companyId, table.biller, table.externalId) }));
export const billingInvoiceLines = pgTable("billing_invoice_lines", {
id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
invoiceId: uuid("invoice_id").notNull().references(() => billingInvoices.id, { onDelete: "cascade" }),
externalId: text("external_id").notNull(),
kind: text("kind").notNull(),
amountCents: numeric("amount_cents", { precision: 24, scale: 7 }).notNull(),
occurredAt: timestamp("occurred_at", { withTimezone: true }).notNull(),
costEventId: uuid("cost_event_id").references(() => costEvents.id, { onDelete: "cascade" }),
runId: uuid("run_id"),
providerRequestId: text("provider_request_id"),
model: text("model"),
pricing: jsonb("pricing").$type<Record<string, unknown>>().notNull().default({}),
}, table => ({
identity: uniqueIndex("billing_invoice_lines_identity_idx").on(table.invoiceId, table.externalId),
company: index("billing_invoice_lines_company_idx").on(table.companyId, table.invoiceId),
amount: check("billing_invoice_lines_amount_check", sql`${table.amountCents} >= 0`),
kindCheck: check("billing_invoice_lines_kind_check", sql`${table.kind} in ('inference', 'fee', 'credit')`),
}));
export const costAdjustments = pgTable("cost_adjustments", {
id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
costEventId: uuid("cost_event_id").notNull().references(() => costEvents.id, { onDelete: "cascade" }),
invoiceLineId: uuid("invoice_line_id").references(() => billingInvoiceLines.id, { onDelete: "cascade" }),
idempotencyKey: text("idempotency_key").notNull(),
receiptHash: text("receipt_hash").notNull(),
previousCents: numeric("previous_cents", { precision: 24, scale: 7 }).notNull(),
correctedCents: numeric("corrected_cents", { precision: 24, scale: 7 }).notNull(),
previousStatus: text("previous_status").notNull(),
previousPricing: jsonb("previous_pricing").$type<Record<string, unknown>>().notNull().default({}),
reason: text("reason").notNull(),
actorId: text("actor_id").notNull(),
pricing: jsonb("pricing").$type<Record<string, unknown>>().notNull().default({}),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
}, table => ({
identity: uniqueIndex("cost_adjustments_receipt_idx").on(table.companyId, table.idempotencyKey),
event: index("cost_adjustments_event_idx").on(table.companyId, table.costEventId, table.createdAt),
invoiceLine: uniqueIndex("cost_adjustments_invoice_line_idx").on(table.companyId, table.invoiceLineId),
amount: check("cost_adjustments_amount_check", sql`${table.previousCents} >= 0 and ${table.correctedCents} >= 0`),
}));
@@ -1,4 +1,4 @@
import { pgTable, uuid, text, timestamp, jsonb, bigint, index } from "drizzle-orm/pg-core";
import { numeric, pgTable, uuid, text, timestamp, jsonb, bigint, index } from "drizzle-orm/pg-core";
import { agents } from "./agents.js";
import { companies } from "./companies.js";
@@ -15,7 +15,7 @@ export const agentRuntimeState = pgTable(
totalInputTokens: bigint("total_input_tokens", { mode: "number" }).notNull().default(0),
totalOutputTokens: bigint("total_output_tokens", { mode: "number" }).notNull().default(0),
totalCachedInputTokens: bigint("total_cached_input_tokens", { mode: "number" }).notNull().default(0),
totalCostCents: bigint("total_cost_cents", { mode: "number" }).notNull().default(0),
totalCostCents: numeric("total_cost_cents", { precision: 24, scale: 7, mode: "number" }).notNull().default(0),
lastError: text("last_error"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
+3 -1
View File
@@ -2,6 +2,7 @@ import type { AgentAppearance } from "@paperclipai/shared";
import {
type AnyPgColumn,
pgTable,
numeric,
uuid,
text,
integer,
@@ -31,7 +32,8 @@ export const agents = pgTable(
runtimeConfig: jsonb("runtime_config").$type<Record<string, unknown>>().notNull().default({}),
defaultEnvironmentId: uuid("default_environment_id").references(() => environments.id, { onDelete: "set null" }),
budgetMonthlyCents: integer("budget_monthly_cents").notNull().default(0),
spentMonthlyCents: integer("spent_monthly_cents").notNull().default(0),
spendMonthUtc: text("spend_month_utc"),
spentMonthlyCents: numeric("spent_monthly_cents", { precision: 24, scale: 7, mode: "number" }).notNull().default(0),
pauseReason: text("pause_reason"),
pausedAt: timestamp("paused_at", { withTimezone: true }),
errorReason: text("error_reason"),
+3 -3
View File
@@ -1,5 +1,5 @@
import { sql } from "drizzle-orm";
import { index, integer, pgTable, text, timestamp, uuid, uniqueIndex } from "drizzle-orm/pg-core";
import { numeric, index, integer, pgTable, text, timestamp, uuid, uniqueIndex } from "drizzle-orm/pg-core";
import { approvals } from "./approvals.js";
import { budgetPolicies } from "./budget_policies.js";
import { companies } from "./companies.js";
@@ -18,7 +18,7 @@ export const budgetIncidents = pgTable(
windowEnd: timestamp("window_end", { withTimezone: true }).notNull(),
thresholdType: text("threshold_type").notNull(),
amountLimit: integer("amount_limit").notNull(),
amountObserved: integer("amount_observed").notNull(),
amountObserved: numeric("amount_observed", { precision: 24, scale: 7, mode: "number" }).notNull(),
status: text("status").notNull().default("open"),
approvalId: uuid("approval_id").references(() => approvals.id),
resolvedAt: timestamp("resolved_at", { withTimezone: true }),
@@ -37,6 +37,6 @@ export const budgetIncidents = pgTable(
table.policyId,
table.windowStart,
table.thresholdType,
).where(sql`${table.status} <> 'dismissed'`),
).where(sql`${table.status} = 'open'`),
}),
);
+7 -1
View File
@@ -1,4 +1,5 @@
import { boolean, index, integer, pgTable, text, timestamp, uuid, uniqueIndex } from "drizzle-orm/pg-core";
import { sql } from "drizzle-orm";
import { check, boolean, index, integer, pgTable, text, timestamp, uuid, uniqueIndex, numeric } from "drizzle-orm/pg-core";
import { companies } from "./companies.js";
export const budgetPolicies = pgTable(
@@ -11,16 +12,21 @@ export const budgetPolicies = pgTable(
metric: text("metric").notNull().default("billed_cents"),
windowKind: text("window_kind").notNull(),
amount: integer("amount").notNull().default(0),
reservationCents: numeric("reservation_cents", { precision: 24, scale: 7 }).notNull().default("0"),
warnPercent: integer("warn_percent").notNull().default(80),
hardStopEnabled: boolean("hard_stop_enabled").notNull().default(true),
unpricedUsagePolicy: text("unpriced_usage_policy").notNull().default("block"),
notifyEnabled: boolean("notify_enabled").notNull().default(true),
isActive: boolean("is_active").notNull().default(true),
enforcementVersion: integer("enforcement_version").notNull().default(0),
enforcementDeliveredVersion: integer("enforcement_delivered_version").notNull().default(0),
createdByUserId: text("created_by_user_id"),
updatedByUserId: text("updated_by_user_id"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
reservationNonnegative: check("budget_policies_reservation_nonnegative", sql`${table.reservationCents} >= 0`),
companyScopeActiveIdx: index("budget_policies_company_scope_active_idx").on(
table.companyId,
table.scopeType,
+3 -2
View File
@@ -1,5 +1,5 @@
import type { InteractionResolverGovernance } from "@paperclipai/shared";
import { pgTable, uuid, text, integer, timestamp, boolean, jsonb, uniqueIndex } from "drizzle-orm/pg-core";
import { numeric, pgTable, uuid, text, integer, timestamp, boolean, jsonb, uniqueIndex } from "drizzle-orm/pg-core";
export const companies = pgTable(
"companies",
@@ -13,7 +13,8 @@ export const companies = pgTable(
issuePrefix: text("issue_prefix").notNull().default("PAP"),
issueCounter: integer("issue_counter").notNull().default(0),
budgetMonthlyCents: integer("budget_monthly_cents").notNull().default(0),
spentMonthlyCents: integer("spent_monthly_cents").notNull().default(0),
spendMonthUtc: text("spend_month_utc"),
spentMonthlyCents: numeric("spent_monthly_cents", { precision: 24, scale: 7, mode: "number" }).notNull().default(0),
defaultResponsibleUserId: text("default_responsible_user_id"),
requireBoardApprovalForNewAgents: boolean("require_board_approval_for_new_agents")
.notNull()
+17 -3
View File
@@ -1,4 +1,5 @@
import { pgTable, uuid, text, timestamp, integer, index } from "drizzle-orm/pg-core";
import { sql } from "drizzle-orm";
import { pgTable, uuid, text, timestamp, integer, index, numeric, uniqueIndex, check, jsonb } from "drizzle-orm/pg-core";
import { companies } from "./companies.js";
import { agents } from "./agents.js";
import { issues } from "./issues.js";
@@ -11,12 +12,19 @@ export const costEvents = pgTable(
{
id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id").notNull().references(() => companies.id),
agentId: uuid("agent_id").notNull().references(() => agents.id),
agentId: uuid("agent_id").references(() => agents.id),
usageKind: text("usage_kind").notNull().default("agent"),
responsibleUserId: text("responsible_user_id"),
issueId: uuid("issue_id").references(() => issues.id, { onDelete: "set null" }),
projectId: uuid("project_id").references(() => projects.id),
goalId: uuid("goal_id").references(() => goals.id),
heartbeatRunId: uuid("heartbeat_run_id").references(() => heartbeatRuns.id),
billingCode: text("billing_code"),
idempotencyKey: text("idempotency_key"),
receiptHash: text("receipt_hash"),
providerRequestId: text("provider_request_id"),
reportedCostCents: numeric("reported_cost_cents", { precision: 24, scale: 7 }),
pricingProvenance: jsonb("pricing_provenance").$type<Record<string, unknown>>(),
provider: text("provider").notNull(),
biller: text("biller").notNull().default("unknown"),
billingType: text("billing_type").notNull().default("unknown"),
@@ -25,11 +33,17 @@ export const costEvents = pgTable(
inputTokens: integer("input_tokens").notNull().default(0),
cachedInputTokens: integer("cached_input_tokens").notNull().default(0),
outputTokens: integer("output_tokens").notNull().default(0),
costCents: integer("cost_cents").notNull(),
costCents: numeric("cost_cents", { precision: 24, scale: 7, mode: "number" }).notNull(),
occurredAt: timestamp("occurred_at", { withTimezone: true }).notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
usageKindCheck: check("cost_events_usage_kind_check", sql`${table.usageKind} = 'decision' or (${table.usageKind} = 'agent' and ${table.agentId} is not null)`),
receiptUniqueIdx: uniqueIndex("cost_events_company_receipt_idx").on(table.companyId, table.idempotencyKey),
providerRequestIdx: index("cost_events_provider_request_idx").on(table.companyId, table.biller, table.providerRequestId),
nonnegativeAmounts: check("cost_events_nonnegative_amounts", sql`${table.costCents} >= 0 and ${table.inputTokens} >= 0 and ${table.cachedInputTokens} >= 0 and ${table.outputTokens} >= 0`),
companyProjectOccurredIdx: index("cost_events_company_project_occurred_idx").on(table.companyId, table.projectId, table.occurredAt),
unpricedIdx: index("cost_events_unpriced_idx").on(table.companyId, table.occurredAt, table.id).where(sql`${table.costStatus} = 'unpriced' and ${table.billingType} <> 'subscription_included'`),
companyOccurredIdx: index("cost_events_company_occurred_idx").on(table.companyId, table.occurredAt),
companyAgentOccurredIdx: index("cost_events_company_agent_occurred_idx").on(
table.companyId,
+51
View File
@@ -0,0 +1,51 @@
import { sql } from "drizzle-orm";
import { boolean, check, index, integer, jsonb, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
import { companies } from "./companies.js";
import { costEvents } from "./cost_events.js";
export const companyDecisionModels = pgTable("company_decision_models", {
companyId: uuid("company_id").primaryKey().references(() => companies.id, { onDelete: "cascade" }),
// Retain company preferences when a connection/grant is deleted. Resolution
// fails closed for stale IDs; choosing a replacement preserves sponsorship.
connectionId: uuid("connection_id"),
grantId: uuid("grant_id"),
enabled: boolean("enabled").notNull().default(false),
allowBackground: boolean("allow_background").notNull().default(true),
provider: text("provider"),
model: text("model"),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
}, t => ({ configured: check("company_decisions_configured", sql`(${t.connectionId} is null) = (${t.grantId} is null) and (not ${t.enabled} or (${t.connectionId} is not null and ${t.provider} is not null and ${t.model} is not null))`) }));
/** Attribution references are archival: history survives deleted runs and connections. No input or answer content. */
export const decisionInvocations = pgTable("decision_invocations", {
id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
feature: text("feature").notNull(),
actorType: text("actor_type").notNull(),
actorId: text("actor_id").notNull(),
responsibleUserId: text("responsible_user_id"),
agentId: uuid("agent_id"),
issueId: uuid("issue_id"),
projectId: uuid("project_id"),
runId: uuid("run_id"),
identityContextId: uuid("identity_context_id"),
connectionId: uuid("connection_id").notNull(),
grantId: uuid("grant_id").notNull(),
provider: text("provider").notNull(),
model: text("model").notNull(),
questionTypes: jsonb("question_types").$type<string[]>().notNull(),
status: text("status").notNull().default("running"),
errorCode: text("error_code"),
providerRequestId: text("provider_request_id"),
costEventId: uuid("cost_event_id").references(() => costEvents.id),
startedAt: timestamp("started_at", { withTimezone: true }).notNull().defaultNow(),
finishedAt: timestamp("finished_at", { withTimezone: true }),
durationMs: integer("duration_ms"),
inputTokens: integer("input_tokens"),
outputTokens: integer("output_tokens"),
}, t => ({
history: index("decision_invocations_company_started_idx").on(t.companyId, t.startedAt, t.id),
pending: index("decision_invocations_pending_idx").on(t.startedAt).where(sql`${t.status} = 'running'`),
cost: index("decision_invocations_cost_idx").on(t.costEventId),
statusCheck: check("decision_invocations_status_check", sql`${t.status} in ('running', 'succeeded', 'failed', 'unknown')`),
}));
+5 -2
View File
@@ -1,4 +1,4 @@
import { pgTable, uuid, text, timestamp, integer, index, boolean, jsonb } from "drizzle-orm/pg-core";
import { pgTable, uuid, text, timestamp, integer, numeric, index, uniqueIndex, boolean, jsonb } from "drizzle-orm/pg-core";
import { companies } from "./companies.js";
import { agents } from "./agents.js";
import { issues } from "./issues.js";
@@ -18,6 +18,8 @@ export const financeEvents = pgTable(
goalId: uuid("goal_id").references(() => goals.id),
heartbeatRunId: uuid("heartbeat_run_id").references(() => heartbeatRuns.id),
costEventId: uuid("cost_event_id").references(() => costEvents.id),
idempotencyKey: text("idempotency_key"),
receiptHash: text("receipt_hash"),
billingCode: text("billing_code"),
description: text("description"),
eventKind: text("event_kind").notNull(),
@@ -30,7 +32,7 @@ export const financeEvents = pgTable(
model: text("model"),
quantity: integer("quantity"),
unit: text("unit"),
amountCents: integer("amount_cents").notNull(),
amountCents: numeric("amount_cents", { precision: 24, scale: 7, mode: "number" }).notNull(),
currency: text("currency").notNull().default("USD"),
estimated: boolean("estimated").notNull().default(false),
externalInvoiceId: text("external_invoice_id"),
@@ -39,6 +41,7 @@ export const financeEvents = pgTable(
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
receiptUniqueIdx: uniqueIndex("finance_events_company_receipt_idx").on(table.companyId, table.idempotencyKey),
companyOccurredIdx: index("finance_events_company_occurred_idx").on(table.companyId, table.occurredAt),
companyBillerOccurredIdx: index("finance_events_company_biller_occurred_idx").on(
table.companyId,
+24
View File
@@ -10,12 +10,14 @@ import {
integer,
bigint,
boolean,
check,
unique,
uniqueIndex,
} from "drizzle-orm/pg-core";
import { companies } from "./companies.js";
import { agents } from "./agents.js";
import { agentWakeupRequests } from "./agent_wakeup_requests.js";
import { issues } from "./issues.js";
export const heartbeatRuns = pgTable(
"heartbeat_runs",
@@ -23,6 +25,11 @@ export const heartbeatRuns = pgTable(
id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id").notNull().references(() => companies.id),
agentId: uuid("agent_id").notNull().references(() => agents.id),
scopeKind: text("scope_kind")
.$type<"company" | "issue">()
.notNull()
.default("company"),
issueId: uuid("issue_id").references((): AnyPgColumn => issues.id, { onDelete: "set null" }),
invocationSource: text("invocation_source").notNull().default("on_demand"),
triggerDetail: text("trigger_detail"),
status: text("status").notNull().default("queued"),
@@ -39,6 +46,12 @@ export const heartbeatRuns = pgTable(
wakeupRequestId: uuid("wakeup_request_id").references(() => agentWakeupRequests.id),
exitCode: integer("exit_code"),
signal: text("signal"),
costAccountingPending: boolean("cost_accounting_pending").notNull().default(false),
costAccountedAt: timestamp("cost_accounted_at", { withTimezone: true }),
accountingProjectionVersion: text("accounting_projection_version"),
accountingLastAttemptAt: timestamp("accounting_last_attempt_at", { withTimezone: true }),
accountingLastError: text("accounting_last_error"),
accountingAttemptCount: integer("accounting_attempt_count").notNull().default(0),
usageJson: jsonb("usage_json").$type<Record<string, unknown>>(),
resultJson: jsonb("result_json").$type<Record<string, unknown>>(),
runtimeMode: text("runtime_mode").notNull().default("legacy"),
@@ -98,6 +111,12 @@ export const heartbeatRuns = pgTable(
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
scopeBindingCheck: check(
"heartbeat_runs_scope_binding_check",
sql`(${table.scopeKind} = 'company' AND ${table.issueId} IS NULL)
OR ${table.scopeKind} = 'issue'`,
),
costAccountingPendingIdx: index("heartbeat_runs_cost_accounting_pending_idx").on(table.updatedAt, table.id).where(sql`${table.costAccountingPending} = true`),
executionStatusDeliveryIdx: index("heartbeat_runs_execution_status_delivery_idx")
.on(table.executionStatusDeliveryId).where(sql`${table.executionStatusDeliveryId} is not null`),
executionControlDeadlineIdx: index("heartbeat_runs_execution_control_deadline_idx")
@@ -123,6 +142,11 @@ export const heartbeatRuns = pgTable(
table.agentId,
table.startedAt,
),
companyIssueCreatedIdx: index("heartbeat_runs_company_issue_created_idx").on(
table.companyId,
table.issueId,
table.createdAt,
),
companyResponsibleUserIdx: index("heartbeat_runs_company_responsible_user_idx").on(
table.companyId,
table.responsibleUserId,
+5
View File
@@ -26,6 +26,7 @@ export { agentTaskSessions, agentSessionGoalActions } from "./agent_task_session
export { agentWakeupRequests } from "./agent_wakeup_requests.js";
export { projects } from "./projects.js";
export { projectMemberships } from "./project_memberships.js";
export { projectAccessMembers } from "./project_access_members.js";
export { documentMemberships } from "./document_memberships.js";
export { projectWorkspaces } from "./project_workspaces.js";
export { executionWorkspaces } from "./execution_workspaces.js";
@@ -41,6 +42,7 @@ export { projectGoals } from "./project_goals.js";
export { goals } from "./goals.js";
export { folders } from "./folders.js";
export { issues } from "./issues.js";
export { issueAccessGrants } from "./issue_access_grants.js";
export { issueWatchdogs } from "./issue_watchdogs.js";
export { issuePlanDecompositions } from "./issue_plan_decompositions.js";
export { issueRecoveryActions } from "./issue_recovery_actions.js";
@@ -224,6 +226,7 @@ export * from "./public_mcp.js";
export * from "./dot_runner.js";
export * from "./accounting.js";
export * from "./ai_connection_routing.js";
export { toolConnectionAppSnapshots } from "./tool_connection_app_snapshots.js";
export { toolConnectionAppSyncs } from "./tool_connection_app_syncs.js";
@@ -231,3 +234,5 @@ export { agentCommentary } from "./agent_commentary.js";
export { agentIdentityKeys } from "./agent_identity_keys.js";
export * from "./decision_models.js";
export { userCompanyPreferences } from "./user_company_preferences.js";
@@ -0,0 +1,36 @@
import { sql } from "drizzle-orm";
import { check, index, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
import { agents } from "./agents.js";
import { issues } from "./issues.js";
export const issueAccessGrants = pgTable(
"issue_access_grants",
{
id: uuid("id").primaryKey().defaultRandom(),
issueId: uuid("issue_id").notNull().references(() => issues.id, { onDelete: "cascade" }),
subjectType: text("subject_type").notNull(),
subjectId: text("subject_id").notNull(),
source: text("source").notNull(),
grantedByUserId: text("granted_by_user_id"),
grantedByAgentId: uuid("granted_by_agent_id").references(() => agents.id, { onDelete: "set null" }),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
revokedAt: timestamp("revoked_at", { withTimezone: true }),
},
(table) => ({
subjectTypeCheck: check(
"issue_access_grants_subject_type_check",
sql`${table.subjectType} in ('user', 'agent')`,
),
sourceCheck: check(
"issue_access_grants_source_check",
sql`${table.source} in ('explicit', 'assignment', 'project')`,
),
predicateIdx: index("issue_access_grants_subject_active_issue_idx").on(
table.subjectType,
table.subjectId,
table.revokedAt,
table.issueId,
),
issueIdx: index("issue_access_grants_issue_idx").on(table.issueId, table.createdAt),
}),
);
+6
View File
@@ -38,6 +38,9 @@ export const issues = pgTable(
projectWorkspaceId: uuid("project_workspace_id").references(() => projectWorkspaces.id, { onDelete: "set null" }),
goalId: uuid("goal_id").references(() => goals.id),
parentId: uuid("parent_id").references((): AnyPgColumn => issues.id),
visibility: text("visibility").notNull().default("open"),
privacyRootIssueId: uuid("privacy_root_issue_id").references((): AnyPgColumn => issues.id),
privacyParentIssueId: uuid("privacy_parent_issue_id").references((): AnyPgColumn => issues.id, { onDelete: "set null" }),
title: text("title").notNull(),
titleNeedsGeneration: boolean("title_needs_generation").notNull().default(false),
description: text("description"),
@@ -92,6 +95,7 @@ export const issues = pgTable(
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
visibilityCheck: check("issues_visibility_check", sql`${table.visibility} in ('open', 'private')`),
conversationIdentityIdx: uniqueIndex("issues_conversation_identity_idx").on(table.companyId, table.conversationAgentId, table.conversationUserId),
conversationIdentityCheck: check("issues_conversation_identity_check", sql`(
${table.conversationAgentId} is null and ${table.conversationUserId} is null and ${table.conversationState} is null
@@ -117,6 +121,8 @@ export const issues = pgTable(
),
responsibleUserIdx: index("issues_company_responsible_user_idx").on(table.companyId, table.responsibleUserId),
parentIdx: index("issues_company_parent_idx").on(table.companyId, table.parentId),
privacyParentIdx: index("issues_company_privacy_parent_idx").on(table.companyId, table.privacyParentIssueId),
privacyRootIdx: index("issues_company_privacy_root_idx").on(table.companyId, table.privacyRootIssueId),
projectIdx: index("issues_company_project_idx").on(table.companyId, table.projectId),
originIdx: index("issues_company_origin_idx").on(table.companyId, table.originKind, table.originId),
projectWorkspaceIdx: index("issues_company_project_workspace_idx").on(table.companyId, table.projectWorkspaceId),
@@ -0,0 +1,34 @@
import { sql } from "drizzle-orm";
import { check, index, pgTable, text, timestamp, uniqueIndex, uuid } from "drizzle-orm/pg-core";
import { companies } from "./companies.js";
import { projects } from "./projects.js";
/** Authorization membership for private projects; unrelated to sidebar project_memberships. */
export const projectAccessMembers = pgTable(
"project_access_members",
{
id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
projectId: uuid("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }),
subjectType: text("subject_type").notNull(),
subjectId: text("subject_id").notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
subjectTypeCheck: check(
"project_access_members_subject_type_check",
sql`${table.subjectType} in ('user', 'agent')`,
),
projectSubjectUq: uniqueIndex("project_access_members_project_subject_uq").on(
table.projectId,
table.subjectType,
table.subjectId,
),
subjectLookupIdx: index("project_access_members_subject_lookup_idx").on(
table.companyId,
table.subjectType,
table.subjectId,
table.projectId,
),
}),
);
+9 -1
View File
@@ -1,4 +1,5 @@
import { pgTable, uuid, text, timestamp, date, index, jsonb } from "drizzle-orm/pg-core";
import { sql } from "drizzle-orm";
import { pgTable, uuid, text, timestamp, date, index, jsonb, check, uniqueIndex } from "drizzle-orm/pg-core";
import type { AgentEnvConfig } from "@paperclipai/shared";
import { companies } from "./companies.js";
import { goals } from "./goals.js";
@@ -12,6 +13,9 @@ export const projects = pgTable(
goalId: uuid("goal_id").references(() => goals.id),
name: text("name").notNull(),
description: text("description"),
visibility: text("visibility").notNull().default("open"),
privacyOwnerUserId: text("privacy_owner_user_id"),
personalOwnerUserId: text("personal_owner_user_id"),
status: text("status").notNull().default("backlog"),
leadAgentId: uuid("lead_agent_id").references(() => agents.id),
targetDate: date("target_date"),
@@ -27,5 +31,9 @@ export const projects = pgTable(
},
(table) => ({
companyIdx: index("projects_company_idx").on(table.companyId),
visibilityCheck: check("projects_visibility_check", sql`${table.visibility} in ('open', 'private')`),
personalOwnerUq: uniqueIndex("projects_company_personal_owner_uq")
.on(table.companyId, table.personalOwnerUserId)
.where(sql`${table.personalOwnerUserId} is not null`),
}),
);
+2 -2
View File
@@ -1,5 +1,5 @@
import { sql } from "drizzle-orm";
import { boolean, index, integer, jsonb, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
import { boolean, index, integer, numeric, jsonb, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
import type { CompanySearchQuery, StatusCardRefreshPolicy } from "@paperclipai/shared";
import { agents } from "./agents.js";
import { companies } from "./companies.js";
@@ -81,7 +81,7 @@ export const statusCardUpdates = pgTable(
changes: jsonb("changes").$type<StatusCardUpdateChange[]>().notNull().default(sql`'[]'::jsonb`),
inputTokens: integer("input_tokens").notNull().default(0),
outputTokens: integer("output_tokens").notNull().default(0),
costCents: integer("cost_cents").notNull().default(0),
costCents: numeric("cost_cents", { precision: 24, scale: 7, mode: "number" }).notNull().default(0),
model: text("model"),
queryVersion: integer("query_version"),
changeSummary: text("change_summary"),
@@ -0,0 +1,16 @@
import { boolean, index, pgTable, text, timestamp, uniqueIndex, uuid } from "drizzle-orm/pg-core";
import { agents } from "./agents.js";
import { companies } from "./companies.js";
export const userCompanyPreferences = pgTable("user_company_preferences", {
id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
userId: text("user_id").notNull(),
primaryAgentId: uuid("primary_agent_id").references(() => agents.id, { onDelete: "set null" }),
primaryAgentInitialized: boolean("primary_agent_initialized").notNull().default(false),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
}, table => ({
companyUserUq: uniqueIndex("user_company_preferences_company_user_uq").on(table.companyId, table.userId),
primaryAgentIdx: index("user_company_preferences_primary_agent_idx").on(table.primaryAgentId),
}));
@@ -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 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
@@ -2577,6 +2577,120 @@
"title": "Comment on approval",
"version": 1
},
{
"allowedModes": [
"standard"
],
"description": "Schedule, replace or clear a persisted one-shot monitor on an owned task.",
"effect": "write",
"inputSchema": {
"additionalProperties": false,
"properties": {
"idempotencyKey": {
"description": "Reuse with identical arguments on retries.",
"maxLength": 240,
"minLength": 1,
"type": "string"
},
"monitor": {
"additionalProperties": false,
"properties": {
"externalRef": {
"maxLength": 500,
"minLength": 1,
"type": [
"string",
"null"
]
},
"kind": {
"enum": [
"external_service",
null
],
"type": [
"string",
"null"
]
},
"maxAttempts": {
"description": "Optional cumulative attempt limit for this task's monitor.",
"maximum": 100,
"minimum": 1,
"type": [
"integer",
"null"
]
},
"nextCheckAt": {
"description": "Future UTC timestamp for the next check. This is one shot, not a recurring interval.",
"type": "string"
},
"notes": {
"description": "What to check on the next run; do not include secrets.",
"maxLength": 500,
"minLength": 1,
"type": "string"
},
"recoveryPolicy": {
"enum": [
"wake_owner",
"create_recovery_issue",
"escalate_to_board",
null
],
"type": [
"string",
"null"
]
},
"serviceName": {
"maxLength": 120,
"minLength": 1,
"type": [
"string",
"null"
]
},
"timeoutAt": {
"description": "Optional deadline later than nextCheckAt.",
"type": [
"string",
"null"
]
}
},
"required": [
"nextCheckAt",
"notes"
],
"type": [
"object",
"null"
]
},
"taskId": {
"description": "Owned task UUID. Omit for the active task.",
"type": "string"
}
},
"required": [
"idempotencyKey",
"monitor"
],
"type": "object"
},
"operationId": "set_task_monitor",
"outputSchema": {
"additionalProperties": true,
"type": "object"
},
"placement": "optional",
"requiredClaims": [],
"schema": "paperclip.semantic-action.v1",
"title": "Set task monitor",
"version": 1
},
{
"allowedModes": [
"standard",
@@ -24,7 +24,7 @@
"prpVersion": 1,
"nativeExecutionVersion": 1,
"catalogVersion": 1,
"catalogSha256": "sha256:2fba572bf222f7a770dfaf1ac23bfddc0a34ac125fed865b2ae36091fe0cae61",
"catalogSha256": "sha256:c9b3d3a06f08f045c272b13f8f8148f01a0942a32bf4cfe6f2c1f5a540e69e3b",
"driverContractVersion": 1,
"driverKind": "paperclip-deterministic",
"driverVersion": "1.0.0"
@@ -175,7 +175,7 @@
},
{
"path": "fixtures/evals/native-execution-seeded.json",
"sha256": "28a7a9e36758954829fa2490cc186be5357e350b8ec06ef13b97d1c3274ad8f4",
"sha256": "adbfc38d274256936cef174fb8a7c322a3983c0fa720cf13497f860869f68b2e",
"expectation": "accept",
"compatibilityCase": "canonical"
},
@@ -183,6 +183,71 @@ fn runtime_context_session_identity(context: &Value) -> Option<Value> {
Some(identity)
}
// Mirrors only the canonical asset suffix from composeNativeSystemInstructions.
// No replacement is allowed inside the prompt or a user's custom entry.
fn registered_asset_suffix(context: &Value) -> Option<String> {
let instructions = context.get("instructions")?;
let root = instructions.pointer("/bundle/rootPath")?.as_str()?;
if root.is_empty() {
return None;
}
let mut blocks = Vec::new();
if let Some(copy) = instructions
.get("workingCopy")
.filter(|value| !value.is_null())
{
let path = copy.get("rootPath")?.as_str()?;
let entry = copy.get("entryPath")?.as_str()?;
if path.is_empty() || entry.is_empty() {
return None;
}
blocks.push(if copy.get("kind").and_then(Value::as_str) == Some("agent_files") {
format!("Your persistent agent directory (AGENT_HOME) is {path}. Your instruction entry is {entry}, relative to that directory. All supported files and subfolders there are restored across tasks and sessions, and collected after this provider stops. Write task deliverables in the task working directory. Only changed or deleted files synchronize; the last sync wins for the same file. Temporary copies are cleaned up without retaining file history. Check the save receipt before claiming persistence.")
} else {
format!("Your editable agent instruction file is {path}/{entry}. Edit this registered private copy normally. After this run stops, Paperclip saves changed content as a persistent revision if your responsible user still has permission and the baseline has not changed. Check the run's instruction-save receipt before claiming persistence. Conflicts are preserved for explicit resolution. Repository instruction files, skills, and this run's loaded prompt are separate and are not collected.")
});
}
blocks.push(format!("Read-only instruction sibling root: {root}"));
Some(blocks.join("\n\n"))
}
fn only_registered_asset_text_changed(
current: &AcpxProviderDescriptor,
prior: &AcpxProviderDescriptor,
) -> bool {
if current.instructions == prior.instructions
&& current
.runtime_context
.pointer("/instructions/workingCopy/rootPath")
== prior
.runtime_context
.pointer("/instructions/workingCopy/rootPath")
&& current
.runtime_context
.pointer("/instructions/bundle/rootPath")
== prior
.runtime_context
.pointer("/instructions/bundle/rootPath")
{
return true;
}
let Some(current_suffix) = registered_asset_suffix(&current.runtime_context) else {
return false;
};
let Some(prior_suffix) = registered_asset_suffix(&prior.runtime_context) else {
return false;
};
current
.instructions
.strip_suffix(&format!("\n\n{current_suffix}"))
.zip(
prior
.instructions
.strip_suffix(&format!("\n\n{prior_suffix}")),
)
.is_some_and(|(current_prefix, prior_prefix)| current_prefix == prior_prefix)
}
impl AcpxProviderDescriptor {
fn validate_session(
&self,
@@ -1055,7 +1120,8 @@ impl AcpxCommandExecutor {
object.remove("aggregateDigest");
}
}
if run_attachment_policy(&descriptor.agent) == RunAttachmentPolicy::AuthenticatedRunGrants {
let attachment_policy = run_attachment_policy(&descriptor.agent);
if attachment_policy != RunAttachmentPolicy::ImmutableInstructions {
// New runs rotate authenticated instruction text and registered
// file-copy grants. Preserve mainline MCP refresh while comparing
// every remaining context identity, including unknown policy fields.
@@ -1066,7 +1132,12 @@ impl AcpxCommandExecutor {
.is_some_and(|(current, prior)| current == prior);
let grants_changed = descriptor.instructions != state.descriptor.instructions
|| descriptor.runtime_context != state.descriptor.runtime_context;
if !compatible || (grants_changed && descriptor.run_id == state.descriptor.run_id) {
let text_compatible = attachment_policy == RunAttachmentPolicy::AuthenticatedRunGrants
|| only_registered_asset_text_changed(&descriptor, &state.descriptor);
if !compatible
|| !text_compatible
|| (grants_changed && descriptor.run_id == state.descriptor.run_id)
{
return Err(DurableRunnerError::invalid(
"ACPX run.attach changed runtime context outside a new authenticated run",
));
@@ -2953,6 +3024,30 @@ mod tests {
fs::remove_dir_all(directory).unwrap();
}
#[test]
fn attachment_asset_suffix_matches_typescript_composer() {
let fixtures: Value = serde_json::from_str(include_str!(
"../../../../test/fixtures/registered-asset-instructions.json"
))
.unwrap();
for fixture in fixtures.as_array().unwrap() {
let suffix = registered_asset_suffix(&fixture["context"]).unwrap();
assert_eq!(
fixture["instructions"]
.as_str()
.unwrap()
.strip_suffix(&format!("\n\n{suffix}")),
Some(
format!(
"Pinned prompt.\n\n{}\n\nPinned connection policy.",
fixture["entry"].as_str().unwrap()
)
.as_str()
)
);
}
}
#[cfg(unix)]
#[test]
fn restores_settled_session_for_explicit_run_attachment_only() {
@@ -3157,6 +3252,17 @@ mod tests {
#[cfg(unix)]
#[test]
fn cursor_attachment_rotates_only_authenticated_run_grants() {
authenticated_run_grant_attachment("cursor");
}
#[cfg(unix)]
#[test]
fn claude_attachment_rotates_only_authenticated_asset_paths() {
authenticated_run_grant_attachment("claude");
}
#[cfg(unix)]
fn authenticated_run_grant_attachment(agent: &str) {
let directory = temporary_directory("cursor-cross-run-attach");
let runtime = directory.join("runtime");
let workspace = directory.join("workspace");
@@ -3177,7 +3283,7 @@ mod tests {
args: Vec::new(),
artifacts: vec![artifact(&command)],
};
let mut descriptor_value = descriptor("cursor");
let mut descriptor_value = descriptor(agent);
descriptor_value["sidecarCommand"] = json!(command);
descriptor_value["runtimeContext"] = json!({ "instructions": { "digest": "stable" }, "mcp": { "digest": "before" }, "aggregateDigest": "before" });
descriptor_value["sidecarArgs"] = json!([]);
@@ -3203,6 +3309,12 @@ mod tests {
"mcp": {"assignmentSetId": "assignment-1", "digest": "e".repeat(64), "bindingId": "old-run-binding"},
"futurePolicy": {"companyId": "company-1"},
});
if agent == "claude" {
descriptor_value["instructions"] = json!(format!(
"Pinned prompt. Custom entry mentioning /old-bundle.\n\n{}",
registered_asset_suffix(&descriptor_value["runtimeContext"]).unwrap()
));
}
let original_descriptor: AcpxProviderDescriptor =
serde_json::from_value(descriptor_value.clone()).unwrap();
let identity = AcpxProviderSessionIdentity {
@@ -3330,6 +3442,52 @@ mod tests {
json!("/new-bundle");
descriptor_value["runtimeContext"]["skills"][0]["bundle"]["rootPath"] = json!("/new-skill");
descriptor_value["runtimeContext"]["mcp"]["bindingId"] = json!("new-run-binding");
if agent == "claude" {
descriptor_value["instructions"] = json!(format!(
"Pinned prompt. Custom entry mentioning /old-bundle.\n\n{}",
registered_asset_suffix(&descriptor_value["runtimeContext"]).unwrap()
));
let mut stale_text = descriptor_value.clone();
stale_text["instructions"] =
json!(original.state.as_ref().unwrap().descriptor.instructions);
assert!(attached
.attach_run(&json!({"provider": stale_text}))
.is_err());
let mut changed_text = descriptor_value.clone();
changed_text["instructions"] = json!(descriptor_value["instructions"]
.as_str()
.unwrap()
.replace("Custom entry", "Changed entry"));
assert!(attached
.attach_run(&json!({"provider": changed_text}))
.is_err());
// A path inside the custom entry is content, not a relocatable grant.
let mut changed_example = descriptor_value.clone();
changed_example["instructions"] = json!(descriptor_value["instructions"]
.as_str()
.unwrap()
.replace("mentioning /old-bundle", "mentioning /new-bundle"));
assert!(attached
.attach_run(&json!({"provider": changed_example}))
.is_err());
for pointer in [
"/prompt/digest",
"/instructions/bundle/digest",
"/skills/0/bundle/digest",
"/futurePolicy/companyId",
] {
let mut changed_identity = descriptor_value.clone();
*changed_identity["runtimeContext"]
.pointer_mut(pointer)
.unwrap() = json!("changed");
assert!(
attached
.attach_run(&json!({"provider": changed_identity}))
.is_err(),
"{pointer}"
);
}
}
attached
.attach_run(&json!({"provider": descriptor_value}))
.unwrap();
@@ -6,11 +6,13 @@
pub(crate) enum RunAttachmentPolicy {
ImmutableInstructions,
AuthenticatedRunGrants,
AuthenticatedAssetPaths,
}
pub(crate) fn run_attachment_policy(agent: &str) -> RunAttachmentPolicy {
match agent {
"cursor" => RunAttachmentPolicy::AuthenticatedRunGrants,
"claude" => RunAttachmentPolicy::AuthenticatedAssetPaths,
_ => RunAttachmentPolicy::ImmutableInstructions,
}
}
@@ -25,7 +27,11 @@ mod tests {
run_attachment_policy("cursor"),
RunAttachmentPolicy::AuthenticatedRunGrants
);
for agent in ["claude", "codex", "grok", "pi", "copilot", "unknown"] {
assert_eq!(
run_attachment_policy("claude"),
RunAttachmentPolicy::AuthenticatedAssetPaths
);
for agent in ["codex", "grok", "pi", "copilot", "unknown"] {
assert_eq!(
run_attachment_policy(agent),
RunAttachmentPolicy::ImmutableInstructions
@@ -0,0 +1,225 @@
//! Controller-selected task environment; mirrors configured-environment.ts.
use crate::local_runner::LocalRunnerError;
use std::process::Command;
const MARKER: &str = "PAPERCLIP_CONFIGURED_ENV_KEYS";
const RESERVED: &[&str] = &[
"AGENT_HOME",
"ANTHROPIC_API_KEY",
"ANTHROPIC_AUTH_TOKEN",
"AWS_BEARER_TOKEN_BEDROCK",
"BASH_ENV",
"CLAUDE_CODE_OAUTH_TOKEN",
"CODEX_API_KEY",
"CODEX_HOME",
"COPILOT_GITHUB_TOKEN",
"CURSOR_API_KEY",
"CURSOR_AUTH_TOKEN",
"ENV",
"GH_ENTERPRISE_TOKEN",
"GH_TOKEN",
"GITHUB_ENTERPRISE_TOKEN",
"GITHUB_TOKEN",
"HOME",
"NODE_OPTIONS",
"NODE_PATH",
"OPENAI_API_KEY",
"OPENROUTER_API_KEY",
"PAPERCLIP_ACPX_CREDENTIAL_BINDING",
"PAPERCLIP_AGENT_ID",
"PAPERCLIP_AGENT_KEY_ID",
"PAPERCLIP_AGENT_MESSAGE_KEY",
"PAPERCLIP_AGENT_PRIVATE_KEY",
"PAPERCLIP_AGENT_PUBLIC_KEY",
"PAPERCLIP_AI_PROVIDER_KEY",
"PAPERCLIP_API_BRIDGE_MODE",
"PAPERCLIP_API_KEY",
"PAPERCLIP_API_URL",
"PAPERCLIP_APPROVAL_ID",
"PAPERCLIP_APPROVAL_STATUS",
"PAPERCLIP_COMPANY_ID",
"PAPERCLIP_CONFIGURED_ENV_KEYS",
"PAPERCLIP_EXECUTION_MODE",
"PAPERCLIP_EXTERNAL_CHAT_EXECUTION_BOUND_KEY",
"PAPERCLIP_GITHUB_AUTH_MODE",
"PAPERCLIP_GITHUB_BRIDGE_TOKEN",
"PAPERCLIP_GITHUB_BROKER_TOKEN",
"PAPERCLIP_GITHUB_BROKER_URL",
"PAPERCLIP_GITHUB_HOST_HOME",
"PAPERCLIP_GITHUB_LAUNCHER_DIR",
"PAPERCLIP_GIT_METADATA_ROOTS",
"PAPERCLIP_GIT_TOKEN",
"PAPERCLIP_HARNESS_CHECKOUT_KEY",
"PAPERCLIP_INSTANCE_ID",
"PAPERCLIP_LINKED_ISSUE_IDS",
"PAPERCLIP_NATIVE_MCP_NAME",
"PAPERCLIP_NATIVE_MCP_TOKEN",
"PAPERCLIP_NATIVE_MCP_URL",
"PAPERCLIP_NORMALIZED_SESSION_ID",
"PAPERCLIP_PROVIDER_TRACE_MAX_BYTES",
"PAPERCLIP_PROVIDER_TRACE_PATH",
"PAPERCLIP_RUNNER_BOOTSTRAP_TICKET",
"PAPERCLIP_RUNNER_EXTERNAL_SANDBOX",
"PAPERCLIP_RUNNER_INSTANCE_ID",
"PAPERCLIP_RUNNER_NETWORK_ACCESS",
"PAPERCLIP_RUNNER_NETWORK_ROOTS",
"PAPERCLIP_RUNTIME_PRIMARY_URL",
"PAPERCLIP_RUNTIME_SERVICES_JSON",
"PAPERCLIP_RUNTIME_SERVICE_INTENTS_JSON",
"PAPERCLIP_RUN_ID",
"PAPERCLIP_TASK_ID",
"PAPERCLIP_WAKE_COMMENT_ID",
"PAPERCLIP_WAKE_PAYLOAD_JSON",
"PAPERCLIP_WAKE_REASON",
"PAPERCLIP_WORKSPACES_JSON",
"PAPERCLIP_WORKSPACE_AUTHORITATIVE_ROOT",
"PAPERCLIP_WORKSPACE_BRANCH",
"PAPERCLIP_WORKSPACE_CWD",
"PAPERCLIP_WORKSPACE_ID",
"PAPERCLIP_WORKSPACE_REALIZATION_MODE",
"PAPERCLIP_WORKSPACE_REPO_REF",
"PAPERCLIP_WORKSPACE_REPO_URL",
"PAPERCLIP_WORKSPACE_SOURCE",
"PAPERCLIP_WORKSPACE_WORKTREE_PATH",
"PATH",
"PERL5LIB",
"PERL5OPT",
"PYTHONHOME",
"PYTHONPATH",
"RUBYLIB",
"RUBYOPT",
"SHELL",
"USERPROFILE",
"XAI_API_KEY",
];
fn eligible_key(key: &str) -> bool {
let upper = key.to_ascii_uppercase();
!key.is_empty()
&& key.len() <= 128
&& key
.bytes()
.enumerate()
.all(|(i, b)| b == b'_' || b.is_ascii_alphabetic() || (i > 0 && b.is_ascii_digit()))
&& !RESERVED.contains(&upper.as_str())
&& ![
"LD_",
"DYLD_",
"GIT_CONFIG_",
"PAPERCLIP_RUNNER_",
"PAPERCLIP_NATIVE_",
"PAPERCLIP_GITHUB_",
"PAPERCLIP_ACPX_",
"PAPERCLIP_VERIFIED_",
]
.iter()
.any(|prefix| upper.starts_with(prefix))
}
pub(crate) fn apply_configured_environment(
command: &mut Command,
lookup: impl Fn(&str) -> Option<String>,
) -> Result<(), LocalRunnerError> {
let Some(raw) = lookup(MARKER) else {
return Ok(());
};
let invalid = || LocalRunnerError::invalid("invalid configured environment projection");
if raw.len() > 20_000 {
return Err(invalid());
}
let mut names: Vec<String> = serde_json::from_str(&raw).map_err(|_| invalid())?;
if names.len() > 128 || names.iter().any(|name| !eligible_key(name)) {
return Err(invalid());
}
names.sort();
if names.windows(2).any(|pair| pair[0] == pair[1]) {
return Err(invalid());
}
let mut bytes = 0;
// Validate all values before modifying the child launch specification.
let mut values = Vec::new();
for name in &names {
let value = lookup(name).ok_or_else(invalid)?;
let size = name.len() + value.len();
bytes += size;
if value.contains('\0') || size > 65_536 || bytes > 262_144 {
return Err(invalid());
}
values.push((name, value));
}
command.envs(values);
command.env(
MARKER,
serde_json::to_string(&names).map_err(|_| invalid())?,
);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeMap;
#[test]
fn rejects_reserved_malformed_missing_and_oversized_environment() {
for raw in [
"not-json",
"[1]",
"[\"NODE_OPTIONS\"]",
"[\"PAPERCLIP_API_KEY\"]",
"[\"PAPERCLIP_RUNNER_BOOTSTRAP_TICKET\"]",
"[\"OPENAI_API_KEY\"]",
"[\"LD_PRELOAD\"]",
"[\"bad=name\"]",
"[\"CUSTOM\",\"CUSTOM\"]",
"[\"MISSING\"]",
] {
let mut command = Command::new("unused");
assert!(
apply_configured_environment(&mut command, |key| if key == MARKER {
Some(raw.into())
} else {
None
})
.is_err()
);
assert_eq!(command.get_envs().count(), 0);
}
let mut command = Command::new("unused");
assert!(
apply_configured_environment(&mut command, |key| Some(if key == MARKER {
"[\"CUSTOM\"]".into()
} else {
"x".repeat(65_536)
}))
.is_err()
);
}
#[test]
#[cfg(unix)]
fn child_receives_only_selected_task_values_across_configuration_changes() {
for secret in [None, Some("first-secret"), Some("rotated-secret"), None] {
let mut source = BTreeMap::from([
("HOST_SECRET", "unbound-secret".to_string()),
("NODE_OPTIONS", "unbound-loader".to_string()),
]);
let names = if let Some(value) = secret {
source.insert("PAPERCLIP_PAGE_AWS_SECRET_ACCESS_KEY", value.into());
vec!["PAPERCLIP_PAGE_AWS_SECRET_ACCESS_KEY"]
} else {
vec![]
};
source.insert(MARKER, serde_json::to_string(&names).unwrap());
let mut command = Command::new("/bin/sh");
command.env_clear().args(["-c", "printf '%s|%s|%s' \"$PAPERCLIP_PAGE_AWS_SECRET_ACCESS_KEY\" \"$HOST_SECRET\" \"$NODE_OPTIONS\""]);
apply_configured_environment(&mut command, |key| source.get(key).cloned()).unwrap();
let output = command.output().unwrap();
assert!(output.status.success());
assert_eq!(
String::from_utf8(output.stdout).unwrap(),
format!("{}||", secret.unwrap_or_default())
);
}
}
}
@@ -12,6 +12,7 @@ pub mod aws_agentcore_provider;
pub mod claude_managed_provider;
pub mod codex_provider;
mod codex_startup_trust;
mod configured_environment;
pub mod dot_provider_backend;
pub mod durable;
pub mod fake_harness;
@@ -385,6 +385,14 @@ struct ManagedDurableState {
model_request_count: u64,
#[serde(default)]
provider_usage: Option<Value>,
// Persisted independently of turn/retry state so warm run attachment can
// subtract prior runs without resetting this run during crash recovery.
#[serde(default)]
accounting_baseline: Option<Value>,
#[serde(default)]
accounting_latest: Option<Value>,
#[serde(default)]
accounting_latest_complete: bool,
#[serde(default)]
claude_managed_skills: Option<Vec<ClaudeManagedSkillRef>>,
#[serde(default)]
@@ -510,6 +518,9 @@ impl ManagedDurableState {
durable_event_cursor: None,
model_request_count: 0,
provider_usage: None,
accounting_baseline: Some(zero_managed_usage()),
accounting_latest: Some(zero_managed_usage()),
accounting_latest_complete: true,
claude_managed_skills: None,
claude_managed_skill_cleanup: None,
active_turn_id: None,
@@ -1047,6 +1058,13 @@ impl ManagedProviderCommandExecutor {
// Checkpoint validation must use the attachment's run identity.
// The durable runner separately activates the external authority
// after this command succeeds, then rotates the full config.
if state.run_id != run_id {
state.accounting_baseline = if state.accounting_latest_complete {
state.accounting_latest.clone()
} else {
None
};
}
state.run_id = run_id.clone();
self.config.run_id = run_id;
}
@@ -1575,7 +1593,26 @@ impl ManagedProviderCommandExecutor {
}
}
if method == "thread/tokenUsage/updated" {
state.push(managed_usage_event(&state.descriptor, &params))?;
let current =
complete_managed_usage(&state.descriptor, &params).filter(|current| {
state.accounting_latest.as_ref().is_none_or(|previous| {
managed_usage_delta(previous, current).is_some()
})
});
let delta = current.as_ref().and_then(|current| {
state
.accounting_baseline
.as_ref()
.and_then(|baseline| managed_usage_delta(baseline, current))
});
let mut usage_event = managed_usage_event(&state.descriptor, &params);
usage_event.payload["runDeltaAvailable"] = json!(delta.is_some());
usage_event.payload["runDelta"] = delta.unwrap_or(Value::Null);
state.accounting_latest_complete = current.is_some();
if current.is_some() {
state.accounting_latest = current;
}
state.push(usage_event)?;
state.model_request_count =
usage_request_count(&params).unwrap_or(state.model_request_count);
return Ok(());
@@ -2003,6 +2040,111 @@ fn usage_request_count(params: &Value) -> Option<u64> {
.and_then(Value::as_u64)
}
fn zero_managed_usage() -> Value {
json!({"inputTokens": 0, "outputTokens": 0, "cacheReadTokens": 0,
"cacheWriteTokens": 0, "activeSeconds": 0.0, "requests": 0, "providerCostUsd": 0.0})
}
fn complete_managed_usage(descriptor: &ManagedProviderDescriptor, params: &Value) -> Option<Value> {
let usage = params.get("usage").unwrap_or(params);
if descriptor.kind() == ManagedProviderKind::AwsAgentcore {
// An interrupted invocation may return old numeric totals while its
// metadata is pending, or a ceiling estimate with lower-bound tokens.
// Neither is a complete receipt, even when every counter is present.
for report in [params, usage] {
if report
.get(AGENTCORE_USAGE_RECONCILIATION_FIELD)
.is_some_and(|state| {
state.as_str() != Some(AGENTCORE_USAGE_RECONCILIATION_OBSERVED)
})
|| report
.get("tokenCountsLowerBound")
.is_some_and(|value| value.as_bool() != Some(false))
|| report.get(AGENTCORE_PENDING_INVOCATION_FIELD).is_some()
|| report.get(AGENTCORE_PENDING_CEILING_FIELD).is_some()
{
return None;
}
}
}
let count = |camel: &str, snake: &str| usage.get(camel).or_else(|| usage.get(snake));
for (camel, snake) in [
("inputTokens", "input_tokens"),
("outputTokens", "output_tokens"),
("cacheReadInputTokens", "cache_read_input_tokens"),
] {
if count(camel, snake)?.as_u64()? > 9_007_199_254_740_991 {
return None;
}
}
if let Some(cache) = usage.get("cache_creation") {
cache.as_object()?;
for key in ["ephemeral_1h_input_tokens", "ephemeral_5m_input_tokens"] {
if let Some(value) = cache.get(key) {
if value.as_u64()? > 9_007_199_254_740_991 {
return None;
}
}
}
} else if let Some(value) = count("cacheWriteInputTokens", "cache_write_input_tokens") {
if value.as_u64()? > 9_007_199_254_740_991 {
return None;
}
} else if descriptor.kind() == ManagedProviderKind::AwsAgentcore {
return None;
}
if let Some(value) = count("activeSeconds", "active_seconds") {
let value = value.as_f64()?;
if !value.is_finite() || value < 0.0 {
return None;
}
}
let price = if descriptor.kind() == ManagedProviderKind::AwsAgentcore {
usage.get("estimatedCostUsd")?.as_f64()?
} else {
let cost = usage.get("list_cost").or_else(|| usage.get("listCost"))?;
if cost.get("currency")?.as_str()? != "USD" {
return None;
}
cost.get("amount")?.as_str()?.parse::<f64>().ok()? / 100.0
};
if !price.is_finite() || price < 0.0 {
return None;
}
let mut measurement = managed_usage_event(descriptor, params).payload["cumulative"].clone();
measurement["providerCostUsd"] = json!(price);
// Also reject integer overflow or values that cannot survive JSON/JS intact.
managed_usage_delta(&zero_managed_usage(), &measurement)?;
Some(measurement)
}
fn managed_usage_delta(baseline: &Value, current: &Value) -> Option<Value> {
let mut delta = serde_json::Map::new();
for key in [
"inputTokens",
"outputTokens",
"cacheReadTokens",
"cacheWriteTokens",
"requests",
] {
let prior = baseline.get(key)?.as_u64()?;
let next = current.get(key)?.as_u64()?;
if next > 9_007_199_254_740_991 || prior > 9_007_199_254_740_991 {
return None;
}
delta.insert(key.to_owned(), json!(next.checked_sub(prior)?));
}
for key in ["activeSeconds", "providerCostUsd"] {
let prior = baseline.get(key)?.as_f64()?;
let next = current.get(key)?.as_f64()?;
if !prior.is_finite() || !next.is_finite() || prior < 0.0 || next < prior {
return None;
}
delta.insert(key.to_owned(), json!(next - prior));
}
Some(Value::Object(delta))
}
fn managed_usage_event(
descriptor: &ManagedProviderDescriptor,
params: &Value,
@@ -2613,6 +2755,13 @@ mod tests {
executor.state.as_mut().unwrap().active_turn_id = Some("active".to_owned());
assert!(executor.attach(&payload).is_err());
executor.state.as_mut().unwrap().active_turn_id = None;
let usage = |input, cents| ProviderEvent::Notification {
method: "thread/tokenUsage/updated".to_owned(),
params: json!({"usage": {"input_tokens": input, "output_tokens": 8,
"cache_read_input_tokens": 13, "list_cost": {"currency": "USD", "amount": cents}}}),
};
executor.project_event(usage(21, "25")).unwrap();
executor.state.as_mut().unwrap().pending_events.clear();
payload["paperclipNextAuthority"] = json!({"identity": {
"normalizedSessionId": config.normalized_session_id, "runId": "next-run"
}});
@@ -2620,6 +2769,13 @@ mod tests {
let mut next_config = config.clone();
next_config.run_id = "next-run".to_owned();
executor.rotate_authority(&next_config);
executor.project_event(usage(40, "50")).unwrap();
let state = executor.state.as_mut().unwrap();
let receipt = &state.pending_events.back().unwrap().payload["runDelta"];
assert_eq!(receipt["inputTokens"], json!(19));
assert_eq!(receipt["providerCostUsd"], json!(0.25));
state.pending_events.clear();
executor.save_state().unwrap();
assert_eq!(
executor.state.as_ref().unwrap().provider_session_id,
session_id
@@ -2648,6 +2804,14 @@ mod tests {
);
payload["authorizedTools"] = claude_prepare_payload()["authorizedTools"].clone();
executor.attach(&payload).unwrap();
// Same-run reconnect is not a new billing baseline, even after recovery.
executor.project_event(usage(50, "75")).unwrap();
executor.project_event(usage(50, "75")).unwrap();
let state = executor.state.as_mut().unwrap();
let receipt = &state.pending_events.back().unwrap().payload["runDelta"];
assert_eq!(receipt["inputTokens"], json!(29));
assert_eq!(receipt["providerCostUsd"], json!(0.5));
state.pending_events.clear();
assert_eq!(
executor.provider.as_mut().unwrap().read().unwrap()["tools"],
json!([])
@@ -2658,6 +2822,34 @@ mod tests {
.unwrap()
.validate(&next_config)
.unwrap();
// An incomplete last report cannot become the next run's baseline.
executor
.project_event(ProviderEvent::Notification {
method: "thread/tokenUsage/updated".to_owned(),
params: json!({"usage": {"input_tokens": 60}}),
})
.unwrap();
executor.state.as_mut().unwrap().pending_events.clear();
payload["paperclipNextAuthority"]["identity"]["runId"] = json!("third-run");
executor.attach(&payload).unwrap();
assert!(executor
.state
.as_ref()
.unwrap()
.accounting_baseline
.is_none());
executor.project_event(usage(70, "100")).unwrap();
assert_eq!(
executor
.state
.as_ref()
.unwrap()
.pending_events
.back()
.unwrap()
.payload["runDelta"],
Value::Null
);
fs::remove_dir_all(directory).unwrap();
}
@@ -2731,6 +2923,230 @@ mod tests {
);
}
#[test]
fn managed_usage_publishes_a_fresh_run_receipt_for_each_provider() {
for payload in [claude_prepare_payload(), agentcore_prepare_payload()] {
let directory =
std::env::temp_dir().join(format!("managed-receipt-{}", Uuid::new_v4()));
let config = test_config(&directory);
let mut executor =
ManagedProviderCommandExecutor::with_runner_config(&directory, &config);
executor.prepare(&payload).unwrap();
executor
.project_event(ProviderEvent::Notification {
method: "thread/tokenUsage/updated".to_owned(),
params: json!({"usage": {
"inputTokens": 21, "outputTokens": 8, "cacheReadInputTokens": 13,
"cacheWriteInputTokens": 5, "estimatedCostUsd": 0.25,
"list_cost": {"currency": "USD", "amount": "25"}
}}),
})
.unwrap();
let event = executor
.state
.as_ref()
.unwrap()
.pending_events
.back()
.unwrap();
assert_eq!(event.payload["runDeltaAvailable"], json!(true));
assert_eq!(event.payload["runDelta"]["inputTokens"], json!(21));
assert_eq!(event.payload["runDelta"]["providerCostUsd"], json!(0.25));
fs::remove_dir_all(directory).unwrap();
}
}
#[test]
fn agentcore_pending_or_lower_bound_usage_never_certifies_a_run() {
for wrapped in [false, true] {
for price in [0.0, 0.25] {
for marker in [
json!({"usageReconciliation": AGENTCORE_USAGE_RECONCILIATION_PENDING}),
json!({"usageReconciliation": AGENTCORE_USAGE_RECONCILIATION_CONSERVATIVE}),
json!({"usageReconciliation": "unknown"}),
json!({"usageReconciliation": null}),
json!({"tokenCountsLowerBound": true}),
json!({"tokenCountsLowerBound": "false"}),
json!({"pendingInvocationId": "unresolved-invocation"}),
json!({"pendingEstimatedCeilingUsd": 1.0}),
] {
let directory =
std::env::temp_dir().join(format!("managed-pending-{}", Uuid::new_v4()));
let config = test_config(&directory);
let mut executor =
ManagedProviderCommandExecutor::with_runner_config(&directory, &config);
executor.prepare(&agentcore_prepare_payload()).unwrap();
let usage = json!({"inputTokens": 10, "outputTokens": 2,
"cacheReadInputTokens": 0, "cacheWriteInputTokens": 0,
"estimatedCostUsd": price});
let mut params = if wrapped {
json!({"usage": usage})
} else {
usage.clone()
};
params
.as_object_mut()
.unwrap()
.extend(marker.as_object().unwrap().clone());
executor
.project_event(ProviderEvent::Notification {
method: "thread/tokenUsage/updated".to_owned(),
params,
})
.unwrap();
let state = executor.state.as_ref().unwrap();
let receipt = &state.pending_events.back().unwrap().payload;
assert_eq!(receipt["runDeltaAvailable"], json!(false), "{marker}");
assert_eq!(receipt["runDelta"], Value::Null, "{marker}");
assert!(!state.accounting_latest_complete);
executor.save_state().unwrap();
executor =
ManagedProviderCommandExecutor::with_runner_config(&directory, &config);
executor.restore().unwrap();
executor
.project_event(ProviderEvent::Notification {
method: "turn/completed".to_owned(),
params: json!({"turn": {"id": "interrupted", "status": "interrupted"}}),
})
.unwrap();
assert!(!executor.state.as_ref().unwrap().accounting_latest_complete);
let mut observed = usage;
observed["usageReconciliation"] =
json!(AGENTCORE_USAGE_RECONCILIATION_OBSERVED);
observed["tokenCountsLowerBound"] = json!(false);
executor
.project_event(ProviderEvent::Notification {
method: "thread/tokenUsage/updated".to_owned(),
params: observed,
})
.unwrap();
assert_eq!(
executor
.state
.as_ref()
.unwrap()
.pending_events
.back()
.unwrap()
.payload["runDeltaAvailable"],
json!(true)
);
fs::remove_dir_all(directory).unwrap();
}
}
}
}
#[test]
fn managed_accounting_rejects_partial_invalid_or_regressing_totals() {
let descriptor =
ManagedProviderDescriptor::parse(claude_prepare_payload()["provider"].clone()).unwrap();
let good = json!({"usage": {"input_tokens": 21, "output_tokens": 8,
"cache_read_input_tokens": 13, "list_cost": {"currency": "USD", "amount": "25"}}});
for (field, value) in [
("input_tokens", Value::Null),
("output_tokens", json!(-1)),
("cache_read_input_tokens", json!(9007199254740992_u64)),
("cache_creation", json!({"ephemeral_1h_input_tokens": -1})),
("list_cost", json!({"currency": "EUR", "amount": "25"})),
("list_cost", json!({"currency": "USD", "amount": "NaN"})),
("list_cost", Value::Null),
] {
let mut bad = good.clone();
bad["usage"][field] = value;
assert!(
complete_managed_usage(&descriptor, &bad).is_none(),
"{field}"
);
}
let current = complete_managed_usage(&descriptor, &good).unwrap();
for field in [
"inputTokens",
"outputTokens",
"cacheReadTokens",
"cacheWriteTokens",
"requests",
"activeSeconds",
"providerCostUsd",
] {
let mut baseline = current.clone();
baseline[field] = current[field]
.as_u64()
.map(|value| json!(value + 1))
.unwrap_or_else(|| json!(current[field].as_f64().unwrap() + 1.0));
assert!(
managed_usage_delta(&baseline, &current).is_none(),
"{field}"
);
}
}
#[test]
fn managed_usage_keeps_high_water_across_partial_reports_and_recovery() {
let directory = std::env::temp_dir().join(format!("managed-high-water-{}", Uuid::new_v4()));
let config = test_config(&directory);
let mut executor = ManagedProviderCommandExecutor::with_runner_config(&directory, &config);
executor.prepare(&claude_prepare_payload()).unwrap();
for (input, output, price, complete) in [
(100, Some(8), "100", true),
(50, Some(8), "50", false),
(150, None, "150", false),
(75, Some(8), "75", false),
(200, Some(8), "200", true),
] {
executor.project_event(ProviderEvent::Notification {
method: "thread/tokenUsage/updated".to_owned(),
params: json!({"usage": {"input_tokens": input, "output_tokens": output,
"cache_read_input_tokens": 0, "list_cost": {"currency": "USD", "amount": price}}}),
}).unwrap();
let state = executor.state.as_mut().unwrap();
assert_eq!(
state.pending_events.back().unwrap().payload["runDeltaAvailable"],
json!(complete)
);
assert_eq!(state.accounting_latest_complete, complete);
assert_eq!(
state.accounting_latest.as_ref().unwrap()["inputTokens"],
json!(if input == 200 { 200 } else { 100 })
);
state.pending_events.clear();
executor.save_state().unwrap();
executor = ManagedProviderCommandExecutor::with_runner_config(&directory, &config);
executor.restore().unwrap();
}
fs::remove_dir_all(directory).unwrap();
}
#[test]
fn restored_legacy_managed_state_never_certifies_a_session_total_as_this_run() {
let directory =
std::env::temp_dir().join(format!("managed-legacy-receipt-{}", Uuid::new_v4()));
let config = test_config(&directory);
let mut executor = ManagedProviderCommandExecutor::with_runner_config(&directory, &config);
executor.prepare(&claude_prepare_payload()).unwrap();
let mut old = serde_json::to_value(executor.state.as_ref().unwrap()).unwrap();
old.as_object_mut().unwrap().remove("accountingBaseline");
old.as_object_mut().unwrap().remove("accountingLatest");
executor.state = Some(serde_json::from_value(old).unwrap());
executor
.project_event(ProviderEvent::Notification {
method: "thread/tokenUsage/updated".to_owned(),
params: json!({"usage": {"input_tokens": 21, "output_tokens": 8,
"cache_read_input_tokens": 13, "list_cost": {"currency": "USD", "amount": "25"}}}),
})
.unwrap();
let event = executor
.state
.as_ref()
.unwrap()
.pending_events
.back()
.unwrap();
assert_eq!(event.payload["runDeltaAvailable"], json!(false));
assert_eq!(event.payload["runDelta"], Value::Null);
fs::remove_dir_all(directory).unwrap();
}
#[test]
fn claude_usage_maps_nested_cache_creation_token_buckets() {
let descriptor = ManagedProviderDescriptor::ClaudeManaged(ClaudeManagedProviderConfig {
@@ -780,6 +780,9 @@ impl SupervisedProcess {
command.env(key, value);
}
}
crate::configured_environment::apply_configured_environment(&mut command, |key| {
std::env::var(key).ok()
})?;
if let Some(executable) = verified_runtime_executable {
// Node reports a sealed memfd launch as `/memfd:... (deleted)` via
// process.execPath. Give descriptor-loaded runtimes the inherited,
@@ -538,11 +538,13 @@ fn admit_terminal_tool_authority(
"paperclip_finish" => {
matches!(disposition.as_str(), "done" | "needs_review")
|| (disposition == "yielded"
&& input
.get("continuation")
.and_then(|continuation| continuation.get("kind"))
.and_then(Value::as_str)
== Some("response_wake"))
&& matches!(
input
.get("continuation")
.and_then(|continuation| continuation.get("kind"))
.and_then(Value::as_str),
Some("response_wake" | "monitor")
))
}
"paperclip_block" => disposition == "blocked",
_ => false,
@@ -4900,8 +4902,9 @@ mod tests {
assert_eq!(finish_result.result["error"]["code"], "invalid_tool_call");
assert_eq!(finish_result.result["error"]["retryable"], false);
let finish_message = finish_result.result["error"]["message"].as_str().unwrap();
assert!(finish_message
.contains("continuation must include kind=response_wake, summary, and idempotencyKey"));
assert!(finish_message.contains(
"continuation must include kind=response_wake or monitor, summary, and idempotencyKey"
));
assert!(finish_message.contains("/required (missing \"requiredField\")"));
assert!(finish_message.contains("/additionalProperties"));
assert!(!finish_message.contains("secretSubmittedValue"));
@@ -4916,6 +4919,18 @@ mod tests {
assert!(block_message.len() <= 512);
assert!(!block_message.chars().any(char::is_control));
let question_result = schema_rejection(
"request_human_input",
json!({"PRIVATE_FIELD": "PRIVATE_VALUE"}),
);
let question_message = question_result.result["error"]["message"].as_str().unwrap();
assert!(question_message.contains("payload.questionSet"));
assert!(question_message.contains("/required (missing \"requiredField\")"));
assert!(!question_message.contains("PRIVATE_FIELD"));
assert!(!question_result.result.to_string().contains("PRIVATE_VALUE"));
assert!(question_message.len() <= 512);
assert_eq!(question_result.result["error"]["retryable"], false);
let ordinary_result = schema_rejection("get_task_context", json!({}));
assert_eq!(
ordinary_result.result["error"]["message"],
@@ -5156,6 +5171,25 @@ mod tests {
assert!(state.validate().is_ok());
}
#[test]
fn accepted_terminal_tool_preserves_an_explicit_monitor_wait() {
let mut state = opencode_result_state();
let mut result = valid_opencode_result();
result["reportedWorkDisposition"] = json!("yielded");
result["continuation"] = json!({
"kind": "monitor",
"summary": "Wait for the scheduled check.",
"idempotencyKey": "monitor-1"
});
admit_terminal_tool_authority(&mut state, "paperclip_finish", &result, false).unwrap();
let terminal = terminal_events(&state, "turn.completed", None);
assert_eq!(terminal.len(), 1);
assert_eq!(terminal[0].payload["reportedWorkDisposition"], "yielded");
assert!(state.validate().is_ok());
}
#[test]
fn terminal_tool_authority_rejects_an_unbound_yield() {
let mut state = opencode_result_state();
@@ -39,7 +39,8 @@ const MAX_SETTLED_CALL_IDS: usize = 65_536;
const REPLAY_FILTER_WORDS: usize = 32_768;
const ACTIVE_TURN_RECEIPT_LIMIT_MESSAGE: &str =
"durable provider tool receipt limit reached for the active turn";
const COMPLETION_INPUT_SCHEMA_HINT: &str = "Invalid paperclip_finish arguments. Required fields: reportedWorkDisposition, summary, completionClaim, evidence, and verification. When reportedWorkDisposition is yielded, continuation must include kind=response_wake, summary, and idempotencyKey.";
const COMPLETION_INPUT_SCHEMA_HINT: &str = "Invalid paperclip_finish arguments. Required fields: reportedWorkDisposition, summary, completionClaim, evidence, and verification. When reportedWorkDisposition is yielded, continuation must include kind=response_wake or monitor, summary, and idempotencyKey.";
const QUESTION_INPUT_SCHEMA_HINT: &str = "Invalid request_human_input arguments. Required fields: idempotencyKey, interactionKind, title, prompt, and continuationPolicy. For questions, use payload.version=1 and a complete payload.questionSet matching the authorized schema. Preserve the supplied question and option IDs.";
const BLOCK_INPUT_SCHEMA_HINT: &str = "Invalid paperclip_block arguments. Required fields: reportedWorkDisposition=blocked, summary, completionClaim, evidence, verification, and blocker. blocker must include reasonCode, owner, unblockAction, and scope.";
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
@@ -242,6 +243,7 @@ impl ProviderBridgeError {
let safe_provider_message = match operation_id {
"paperclip_finish" => Some(COMPLETION_INPUT_SCHEMA_HINT),
"paperclip_block" => Some(BLOCK_INPUT_SCHEMA_HINT),
"request_human_input" => Some(QUESTION_INPUT_SCHEMA_HINT),
_ => None,
};
Self {
@@ -251,7 +253,7 @@ impl ProviderBridgeError {
}
fn with_schema_locations(mut self, validator: &jsonschema::Validator, input: &Value) -> Self {
// Only completion tools have a provider-safe hint. Report bounded paths
// Only the reserved tools above have a provider-safe hint. Report bounded paths
// into the authorized schema, never the submitted values or unknown keys.
if let Some(message) = self.safe_provider_message.as_mut() {
let locations = validator
@@ -875,7 +875,7 @@ pub fn normalize_codex_notification(method: &str, params: &Value) -> Vec<Normali
"text": bounded_text(string(params.get("delta")), MAX_TEXT_CHARS),
}),
),
"item/started" | "item/completed" => {
"item/started" | "item/updated" | "item/completed" => {
let provider_item = item(params);
let item_id = stable_id(string(provider_item.get("id")), "codex-item");
let item_type = string(provider_item.get("type"));
@@ -883,12 +883,12 @@ pub fn normalize_codex_notification(method: &str, params: &Value) -> Vec<Normali
let completed = method == "item/completed";
if matches!(
item_type,
"commandExecution" | "mcpToolCall" | "dynamicToolCall"
"commandExecution" | "mcpToolCall" | "dynamicToolCall" | "builtinToolCall"
) {
let mut payload = json!({
"schema": "paperclip.tool.execution.v1",
"executionId": item_id,
"transport": match item_type { "mcpToolCall" => "mcp", "dynamicToolCall" => "dynamic", _ => "process" },
"transport": match item_type { "mcpToolCall" => "mcp", "dynamicToolCall" => "dynamic", "builtinToolCall" => "builtin", _ => "process" },
"operation": if item_type == "commandExecution" { "execute" } else { "unknown" },
"name": provider_item.get("tool").or_else(|| provider_item.get("command")).and_then(Value::as_str).map(|value| bounded_text(value, 240)),
"target": Value::Null,
@@ -913,6 +913,8 @@ pub fn normalize_codex_notification(method: &str, params: &Value) -> Vec<Normali
&mut events,
if completed {
"tool.execution.completed"
} else if method == "item/updated" {
"tool.execution.progressed"
} else {
"tool.execution.started"
},
@@ -1752,6 +1754,36 @@ mod tests {
assert!(!events[0].payload.to_string().contains("top-secret"));
}
#[test]
fn preserves_opencode_builtin_tool_activity_and_bounded_errors() {
for (method, event_type, status) in [
("item/started", "tool.execution.started", "running"),
("item/updated", "tool.execution.progressed", "running"),
("item/completed", "tool.execution.completed", "failed"),
] {
let events = normalize_codex_notification(
method,
&json!({
"threadId": "session-1", "turnId": "turn-1", "item": {
"id": "part-invalid", "type": "builtinToolCall", "tool": "invalid",
"status": status, "output": "Tool not found: fixture_missing; token=top-secret",
}
}),
);
assert_eq!(events[0].event_type, event_type);
assert_eq!(events[0].payload["executionId"], "part-invalid");
assert_eq!(events[0].payload["transport"], "builtin");
assert_eq!(events[0].payload["name"], "invalid");
assert_eq!(events[0].payload["status"], status);
assert!(events[0].payload["output"]
.as_str()
.unwrap()
.contains("fixture_missing"));
assert!(!events[0].payload.to_string().contains("top-secret"));
assert!(events[0].payload.get("callId").is_none());
}
}
#[test]
fn maps_codex_final_answer_as_final_agent_message() {
let final_answer = normalize_codex_notification(
@@ -7,7 +7,7 @@
"src/scenarios/scenario-plan.ts"
],
"counts": {
"actions": 56,
"actions": 57,
"legacyRequirements": 106
},
"actions": [
@@ -30,6 +30,25 @@
"src/scenarios/scenario-explorer.test.ts::renders every scenario with exposure, control plane, authorization, diff, and parity"
]
},
{
"id": "set_task_monitor",
"ownership": "optional_agent_tool",
"surfaces": [
"live"
],
"legacyAliases": [],
"contractCase": "protocol-action:set_task_monitor",
"contractOwner": "src/catalog/protocol-action-contracts.test.ts::set_task_monitor has a schema-valid canonical example and every declared projection",
"legacyBehavioralCases": [],
"deterministicCases": [
"protocol-action:set_task_monitor"
],
"legacyRequirementCases": [],
"deterministicOwners": [
"src/catalog/protocol-action-contracts.test.ts::set_task_monitor has a schema-valid canonical example and every declared projection",
"src/scenarios/scenario-explorer.test.ts::renders every scenario with exposure, control plane, authorization, diff, and parity"
]
},
{
"id": "submit_complaint",
"ownership": "optional_agent_tool",
@@ -83,7 +83,7 @@
{
"id": "wake_scheduling",
"description": "Schedule a bounded continuation wake when the current task owns the future check.",
"operationIds": ["schedule_wake"]
"operationIds": ["schedule_wake", "set_task_monitor"]
},
{
"id": "routines",
@@ -305,12 +305,12 @@
"legacyGroup": 16,
"name": "Wake situations",
"owner": "control plane + always context/history tools",
"operationIds": ["get_task_context", "get_task_history", "schedule_wake"],
"operationIds": ["get_task_context", "get_task_history", "schedule_wake", "set_task_monitor"],
"controlPlaneOperationIds": ["select_work", "route_wake"],
"realSurface": "wakeup requests, heartbeat context, comment/interaction/approval/blocker wake routing, and scheduled wake services",
"mockStateDomains": ["wake", "task", "comments", "interactions", "approvals", "blockers", "run"],
"prpEvidence": "attention request routing/resolution plus resumed session/run causality",
"gap": "Production scheduling binding is unbound; control-plane routing remains non-callable."
"gap": "set_task_monitor binds the one-shot monitor scheduler in production; schedule_wake remains mock-backed. Control-plane routing remains non-callable."
}
],
"rules": {
@@ -6,7 +6,7 @@ Status: canonical explanatory contract for the Paperclip runner V1 surface.
This document keeps three independent meanings of **group** separate. PRP families describe wire evidence and controller commands; capability placement decides who owns an operation; behavioral eval groups organize the 106 scenario corpus. None of the three axes can be used as a substitute for another.
The generated totals are **105 PRP events in 31 event families**, **18 controller commands in 7 command families**, **10 control-plane operations**, **56 reconciled semantic operations** (18 always, 38 optional), and **106 scenarios in 16 behavior groups**.
The generated totals are **105 PRP events in 31 event families**, **18 controller commands in 7 command families**, **10 control-plane operations**, **57 reconciled semantic operations** (18 always, 39 optional), and **106 scenarios in 16 behavior groups**.
## Axis 1: PRP v1 event and command families
@@ -87,7 +87,7 @@ Placement has exactly three outcomes:
`answer_status_question`, `block_task`, `finish_task`, `get_agent_instruction_history`, `get_task_context`, `get_task_history`, `inspect_operation_result`, `list_document_revisions`, `list_documents`, `read_agent_instructions`, `read_document`, `register_deliverable`, `report_progress`, `request_human_input`, `request_review`, `restore_agent_instructions`, `update_agent_instructions`, `write_document`.
### Optional operations (38) and grant groups (14)
### Optional operations (39) and grant groups (14)
Grant groups are documentation/exposure bundles, not additional authority. The operation descriptor's exact `requiredClaims` remains decisive.
@@ -100,7 +100,7 @@ Grant groups are documentation/exposure bundles, not additional authority. The o
| `governance` | `list_approvals`<br>`get_approval`<br>`get_approval_context`<br>`request_approval`<br>`decide_approval`<br>`comment_on_approval` | `governance:approvals:comment`<br>`governance:approvals:decide`<br>`governance:approvals:read`<br>`governance:approvals:request` | Read, request, comment on, and decide approvals under governed-action checks. |
| `cases` | `list_cases`<br>`upsert_case` | `cases:read`<br>`cases:write` | Read and update case summaries without reusing issue-document authority. |
| `workspace_runtime` | `get_workspace_runtime`<br>`control_workspace_service` | `workspace:control`<br>`workspace:read` | Inspect and control the active issue workspace runtime. |
| `wake_scheduling` | `schedule_wake` | `control_plane:wakes` | Schedule a bounded continuation wake when the current task owns the future check. |
| `wake_scheduling` | `schedule_wake`<br>`set_task_monitor` | `control_plane:wakes` | Schedule a bounded continuation wake when the current task owns the future check. |
| `routines` | `list_routines`<br>`manage_routine` | `routines:read`<br>`routines:write` | Inspect or manage company routines. |
| `company_skills` | `create_skill`<br>`update_skill`<br>`list_company_skills`<br>`sync_company_skills` | `company_skills:read`<br>`company_skills:write` | Create, update, or inspect company skills and synchronize the current agent's skills. |
| `secrets` | `list_secret_metadata`<br>`read_secret_value` | `secrets:metadata:read`<br>`secrets:values:read` | Inspect secret metadata or use a brokered secret value without exposing plaintext evidence. |
@@ -162,6 +162,7 @@ Grant groups are documentation/exposure bundles, not additional authority. The o
| `search_api` | `optional_agent_tool` | `api:discover` | `standard`<br>`ask`<br>`planning`<br>`skill_test` | `read` | `none` | no | inline/no mapping | `live`<br>`live_codex` | `PaperclipRunnerToolAuthority`<br>Authenticated PRP tool input/result and existing HTTP route authorization/activity records.<br>catalog PRP status: `bound` |
| `search_tasks` | `optional_agent_tool` | `discovery:tasks:read` | `standard`<br>`ask`<br>`planning`<br>`skill_test` | `read` | `none` | no | `snapshot_read:company_tasks` | `scenario` + `live`<br>`live_codex` | `unbound`<br>read projection surfaced via a tool-result item event; no control-plane state diff<br>catalog PRP status: `audit_pending` |
| `set_dependencies` | `optional_agent_tool` | `dependencies:write` | `standard`<br>`skill_test` | `company_write` | `required` | no | `semantic_command:set_dependencies` | `scenario` + `live`<br>`live_codex` | `issues.update.blockedByIssueIds`<br>semantic-operation item event plus company-entity state diff and audit record<br>catalog PRP status: `bound` |
| `set_task_monitor` | `optional_agent_tool` | none | `standard` | `task_write` | `required` | no | inline/no mapping | `live`<br>`live_codex` | `prepareIssueMonitorUpdate`<br>Transactional persisted issue monitor, audit activity and idempotent run receipt.<br>catalog PRP status: `bound` |
| `set_task_title` | `optional_agent_tool` | none | `standard`<br>`ask`<br>`planning`<br>`skill_test` | `task_write` | `required` | no | inline/no mapping | `live`<br>`live_codex` | `setIssueTitle`<br>Run-bound title update and transactional activity record.<br>catalog PRP status: `bound` |
| `submit_complaint` | `optional_agent_tool` | none | `standard`<br>`ask`<br>`planning` | `task_write` | `required` | no | inline/no mapping | `live`<br>`live_codex` | `submitAgentCommentary`<br>Run-bound local commentary and transactional activity record.<br>catalog PRP status: `bound` |
| `submit_suggestion` | `optional_agent_tool` | none | `standard`<br>`ask`<br>`planning` | `task_write` | `required` | no | inline/no mapping | `live`<br>`live_codex` | `submitAgentCommentary`<br>Run-bound local commentary and transactional activity record.<br>catalog PRP status: `bound` |
@@ -194,7 +195,7 @@ Behavior groups describe expected outcomes and trajectories. They do not grant t
| [`rf` — Reference files](#behavior-group-rf-reference-files) | always instruction tools + optional domain tools + test-only escape hatch | `list_cases`<br>`upsert_case`<br>`list_routines`<br>`manage_routine`<br>`create_skill`<br>`update_skill`<br>`list_company_skills`<br>`sync_company_skills`<br>`list_secret_metadata`<br>`read_secret_value`<br>`export_company`<br>`administer_company`<br>`generic_api_request`<br>`search_api`<br>`call_api`<br>`create_project`<br>`read_agent_instructions`<br>`update_agent_instructions`<br>`get_agent_instruction_history`<br>`restore_agent_instructions`<br>`submit_complaint`<br>`submit_suggestion` | `append_audit_record` | agent instruction revisions, project, case, routine, company-skill, secret, portability, and administration services, and run-attributed agent commentary | `company`<br>`cases`<br>`routines`<br>`skills`<br>`secrets`<br>`audit`<br>`fault` | 22 | bounded domain projections, redacted broker receipts, company diffs, and audit references | Instruction read/update/history/restore require the live canonical revision service, current responsible-user permissions, and pinned base revisions for writes. Their service, authority, and product E2E tests are separate from the legacy mock scenarios. Project and skill creation and API tools require the live server authority; generic_api_request is test-only and the remaining domain operations are scenario-only. Broad administer_company is deferred and cannot claim product coverage. Production API and paired dedicated-tool regressions are recorded separately in paperclip-evals/evals/runner-api-tools; the legacy scenario count is not evidence of that coverage. Feedback actions are live-only: shared persistence, authority, API, and actual Codex smoke tests cover them; the legacy scenario count does not claim feedback coverage. |
| [`mh` — Multi-hop](#behavior-group-mh-multi-hop) | composed semantic operations + control-plane continuation | `create_task`<br>`set_dependencies`<br>`request_human_input`<br>`request_approval`<br>`register_deliverable` | `route_wake`<br>`reconcile_run` | delegation, dependency, interaction, approval, artifact, and terminal orchestration services | `task`<br>`blockers`<br>`interactions`<br>`approvals`<br>`artifacts`<br>`wake`<br>`run`<br>`audit` | 4 | correlated operation receipts, state diffs, attention hops, work assessment, status decision, and terminal outcome | No generic transaction tool is allowed; shared mock/real conformance must prove each composed effect. |
| [`rs` — Restraint and no-call](#behavior-group-rs-restraint-and-no-call) | policy/exposure layer | `answer_status_question`<br>`read_secret_value`<br>`generic_api_request` | `enforce_budget` | task-mode, secret-broker, test-scope, pause, and budget policy checks | `actor`<br>`task`<br>`budget`<br>`secrets`<br>`audit`<br>`fault` | 3 | absence of forbidden effects plus typed policy denial/redaction receipts when a call is attempted | Typed redaction/authorization receipts need additive v1 evidence; generic_api_request is never a product fallback. |
| [`wk` — Wake situations](#behavior-group-wk-wake-situations) | control plane + always context/history tools | `get_task_context`<br>`get_task_history`<br>`schedule_wake` | `select_work`<br>`route_wake` | wakeup requests, heartbeat context, comment/interaction/approval/blocker wake routing, and scheduled wake services | `wake`<br>`task`<br>`comments`<br>`interactions`<br>`approvals`<br>`blockers`<br>`run` | 8 | attention request routing/resolution plus resumed session/run causality | Production scheduling binding is unbound; control-plane routing remains non-callable. |
| [`wk` — Wake situations](#behavior-group-wk-wake-situations) | control plane + always context/history tools | `get_task_context`<br>`get_task_history`<br>`schedule_wake`<br>`set_task_monitor` | `select_work`<br>`route_wake` | wakeup requests, heartbeat context, comment/interaction/approval/blocker wake routing, and scheduled wake services | `wake`<br>`task`<br>`comments`<br>`interactions`<br>`approvals`<br>`blockers`<br>`run` | 8 | attention request routing/resolution plus resumed session/run causality | set_task_monitor binds the one-shot monitor scheduler in production; schedule_wake remains mock-backed. Control-plane routing remains non-callable. |
### Scenario links
@@ -468,10 +469,10 @@ Current responsibility-based paths are normative. Numbered `phase-*` or mileston
### Catalog split and deliberate replacement
- Scenario/eval catalog: **40** operations.
- Live dispatcher catalog: **44** operations.
- Shared: **28**; union/canonical authority: **56**.
- Live dispatcher catalog: **45** operations.
- Shared: **28**; union/canonical authority: **57**.
- Scenario-only: `administer_company`, `export_company`, `inspect_operation_result`, `list_cases`, `list_company_skills`, `list_goals`, `list_routines`, `list_secret_metadata`, `manage_routine`, `read_secret_value`, `sync_company_skills`, `upsert_case`.
- Live-only: `call_api`, `create_project`, `get_agent`, `get_agent_instruction_history`, `get_approval`, `get_approval_context`, `hire_agent`, `list_project_repositories`, `read_agent_instructions`, `restore_agent_instructions`, `schedule_wake`, `search_api`, `set_task_title`, `submit_complaint`, `submit_suggestion`, `update_agent_instructions`.
- Live-only: `call_api`, `create_project`, `get_agent`, `get_agent_instruction_history`, `get_approval`, `get_approval_context`, `hire_agent`, `list_project_repositories`, `read_agent_instructions`, `restore_agent_instructions`, `schedule_wake`, `search_api`, `set_task_monitor`, `set_task_title`, `submit_complaint`, `submit_suggestion`, `update_agent_instructions`.
- The generated provider contract contains exactly the live catalog; the canonical union remains the migration authority until all scenario-only operations are either implemented, deferred, or removed by an explicit reconciliation decision.
- `generic_api_request` stays exported only for controlled tests and cannot be cited as real-surface, mock-parity, or PRP product coverage.
@@ -974,6 +974,28 @@ describe("HarnessDriverBackend", () => {
await expect(iterator.next()).rejects.toThrow("provider transport lost after resolution");
});
it.each([false, true])("retains final usage without accepting late output (journal boundary only: %s)", async journalOnly => {
class AccountingAfterCancelSession extends FakeHarnessSession {
async interrupt() {}
override async *events() {
yield prpEvent(1, "item.completed", { kind: "agentMessage", text: "late answer" });
yield prpEvent(2, "item.completed", { kind: "usage", usage: { runDelta: { inputTokens: 7, outputTokens: 3 }, runDeltaComplete: true } });
yield prpEvent(3, "turn.completed", { status: "completed" });
}
}
const backend = new HarnessDriverBackend({ ...driver, async openSession() { return new AccountingAfterCancelSession(); } });
const session = await backend.openSession({
identity: { runId: "run-1", sessionId: "session-1", companyId: "company-1", issueId: "issue-1", agentId: "agent-1" }, workingDirectory: "/workspace",
});
if (journalOnly) session.revokeTurnPublication();
else await session.cancel({ reason: "durable governed wait", signal: new AbortController().signal }).cleanup;
const events = [];
for await (const event of session.events()) events.push(event);
expect(events.filter(event => event.payload.kind === "agentMessage")).toHaveLength(0);
expect(events.find(event => event.payload.kind === "usage")?.payload.usage).toMatchObject({ runDelta: { inputTokens: 7, outputTokens: 3 } });
expect(events.some(event => event.eventType === "turn.cancelled")).toBe(true);
});
it("does not start provider work when Stop arrives before the first turn", async () => {
const provider = new FakeHarnessSession();
const start = vi.spyOn(provider, "startTurn");
@@ -549,7 +549,10 @@ class HarnessNativeSession implements NativeSession {
(event.eventType === "run.terminal" && ["failed", "cancelled"].includes(String(event.payload.runTerminalState))) ||
(event.eventType === "item.completed" &&
event.payload.kind === "interrupt_acknowledgement");
if (this.#explicitlyCancelled && !isCancellationEvent) continue;
// Accounting for work already performed survives cancellation. It grants
// no tool, message, semantic-result, or continuation authority.
const isUsageReceipt = event.eventType === "item.completed" && event.payload.kind === "usage";
if (this.#explicitlyCancelled && !isCancellationEvent && !isUsageReceipt) continue;
sourceInstanceId = event.sourceInstanceId;
lastSourceSequence = Math.max(lastSourceSequence, event.sourceSeq);
if (event.eventType === "runtime_request.created") {
@@ -735,6 +738,10 @@ class HarnessNativeSession implements NativeSession {
return this.#session.interrupt(input);
}
revokeTurnPublication() {
this.#explicitlyCancelled = true;
}
cancel(input: { reason: string; signal: AbortSignal }) {
if (input.signal.aborted) {
throw (
@@ -744,7 +751,7 @@ class HarnessNativeSession implements NativeSession {
// This flag is the adapter's synchronous publication boundary. Provider
// interruption happens afterward as passive cleanup, so a slow or broken
// transport cannot synthesize or publish new accepted output for the turn.
this.#explicitlyCancelled = true;
this.revokeTurnPublication();
const interrupt = this.#session.interrupt;
return {
cleanup:
@@ -77,7 +77,8 @@ describe("native runtime context files", () => {
}
expect(PRP_COMPLETION_TOOL_DESCRIPTION).toContain("do not claim completion while gated");
expect(PRP_COMPLETION_TOOL_DESCRIPTION).toContain("supplied link and action");
expect(PRP_COMPLETION_TOOL_DESCRIPTION).toContain("explicit wait for the next response");
expect(PRP_COMPLETION_TOOL_DESCRIPTION).toContain("yielded with response_wake for a response");
expect(PRP_COMPLETION_TOOL_DESCRIPTION).toContain("monitor after set_task_monitor confirms a schedule on this task");
expect(PRP_BLOCK_TOOL_DESCRIPTION).toContain("its owner, and the action needed to unblock it");
expect(constraints).not.toContain(
"final response exactly once before invoking",
@@ -20,9 +20,9 @@ describe("canonical semantic-catalog reconciliation authority", () => {
it("pins the reconciled op-set relationship between the two catalogs", () => {
const summary = capabilityCatalogReconciliation();
expect(summary.scenarioCount).toBe(40);
expect(summary.liveCount).toBe(44);
expect(summary.liveCount).toBe(45);
expect(summary.sharedCount).toBe(28);
expect(summary.unionCount).toBe(56);
expect(summary.unionCount).toBe(57);
// Any operation added to or removed from either catalog without a
// reconciliation decision changes these exact sets and fails the gate.
expect(summary.liveOnly).toEqual([
@@ -38,6 +38,7 @@ describe("canonical semantic-catalog reconciliation authority", () => {
"restore_agent_instructions",
"schedule_wake",
"search_api",
"set_task_monitor",
"set_task_title",
"submit_complaint",
"submit_suggestion",
@@ -60,7 +61,7 @@ describe("canonical semantic-catalog reconciliation authority", () => {
});
it("is the single source both catalogs derive their operation set from", () => {
expect(CAPABILITY_CANONICAL_OPERATIONS).toHaveLength(56);
expect(CAPABILITY_CANONICAL_OPERATIONS).toHaveLength(57);
const canonicalIds = new Set(CAPABILITY_CANONICAL_OPERATIONS.map((operation) => operation.operationId));
// Neither catalog may contain an operation absent from the canonical source.
for (const tool of SCENARIO_CATALOG) expect(canonicalIds.has(tool.operationId)).toBe(true);
@@ -94,7 +95,7 @@ describe("canonical semantic-catalog reconciliation authority", () => {
});
it("names placement, claims, task modes, side-effect class, idempotency, redaction, mock mapping, real binding status, and PRP evidence for every operation", () => {
expect(CAPABILITY_CANONICAL_CATALOG).toHaveLength(56);
expect(CAPABILITY_CANONICAL_CATALOG).toHaveLength(57);
for (const operation of CAPABILITY_CANONICAL_CATALOG) {
expect(operation.placement).toMatch(/^(always|optional)_agent_tool$/);
expect(Array.isArray(operation.requiredClaims)).toBe(true);
@@ -120,7 +121,7 @@ describe("canonical semantic-catalog reconciliation authority", () => {
it("classifies real binding status so generic_api_request is never product coverage", () => {
const summary = capabilityCatalogReconciliation();
expect(summary.byRealBindingStatus).toEqual({
live_codex: 43,
live_codex: 44,
scenario_mock: 12,
test_only: 1,
});
@@ -69,7 +69,8 @@ describe("semantic action catalog", () => {
(action) => action.operationId,
);
expect(operationIds).toHaveLength(36);
expect(operationIds).toHaveLength(37);
expect(operationIds).toContain("set_task_monitor");
expect(new Set(operationIds).size).toBe(operationIds.length);
expect(operationIds).not.toContain("generic_api_request");
expect(Object.isFrozen(PAPERCLIP_SEMANTIC_ACTION_CATALOG)).toBe(true);
@@ -1,3 +1,4 @@
import { setTaskMonitorInputSchema } from "../protocol-actions/set-task-monitor.js";
import { listProjectsDescription, listProjectsInputSchema } from "../protocol-actions/list-projects.js";
import { setTaskTitleAction } from "../protocol-actions/set-task-title.js";
import { reassignTaskAction } from "../protocol-actions/reassign-task.js";
@@ -583,6 +584,12 @@ const descriptors: readonly PaperclipSemanticActionDescriptor[] = [
),
outputSchema: operationReceipt,
}),
descriptor({
operationId: "set_task_monitor", title: "Set task monitor",
description: "Schedule, replace or clear a persisted one-shot monitor on an owned task.",
placement: "optional", effect: "write", requiredClaims: [], allowedModes: ["standard"],
inputSchema: setTaskMonitorInputSchema, outputSchema: openObject,
}),
descriptor({
operationId: "schedule_wake",
title: "Schedule bounded wake",
@@ -2,6 +2,7 @@ export type PaperclipSemanticActionId =
| "search_api"
| "call_api"
| "set_task_title"
| "set_task_monitor"
| "get_task_context"
| "get_task_history"
| "list_documents"
@@ -13,6 +13,7 @@ import { parseNativeRuntimeContext } from "../contracts/runtime-context.js";
import { openCodeProxyTaskEnvelope } from "./opencode-proxy-task-envelope.js";
import {
openCodeProxyItemNotification,
openCodeProxyToolNotification,
openCodeProxyTerminalNotification,
shouldAnnounceOpenCodeProxyTurn,
shouldForwardOpenCodeProxyItem,
@@ -192,7 +193,11 @@ function announceTurnStarted(opened: HarnessSession, turnId: string): void {
async function pumpEvents(opened: HarnessSession): Promise<void> {
for await (const event of opened.events()) {
const payload = record(event.payload);
if (event.eventType === "turn.started") {
const toolActivity = openCodeProxyToolNotification({ eventType: event.eventType,
threadId: opened.ids().driverSessionId, turnId: event.turnId, payload });
if (toolActivity) {
send(toolActivity);
} else if (event.eventType === "turn.started") {
if (typeof event.turnId === "string")
announceTurnStarted(opened, event.turnId);
} else if (
@@ -1,3 +1,24 @@
/** Forward bounded tool activity without inventing a host-call identity or arguments. */
export function openCodeProxyToolNotification(input: {
eventType: string; threadId: string; turnId?: string; payload: Record<string, unknown>;
}): { method: string; params: Record<string, unknown> } | null {
const phase = ({ "tool.execution.started": "started", "tool.execution.progressed": "updated", "tool.execution.completed": "completed" } as Record<string, string>)[input.eventType];
if (!phase) return null;
const p = input.payload;
if (!input.turnId || typeof p.executionId !== "string" || !p.executionId || p.schema !== "paperclip.tool.execution.v1")
throw new Error("OpenCode tool activity omitted its bound identity");
const type = ({ builtin: "builtinToolCall", mcp: "mcpToolCall", dynamic: "dynamicToolCall", process: "commandExecution" } as Record<string, string>)[String(p.transport)];
if (!type) throw new Error("OpenCode tool activity has an unsupported transport");
return { method: `item/${phase}`, params: {
threadId: input.threadId, turnId: input.turnId, itemId: p.executionId,
item: { id: p.executionId, type, tool: p.name, server: p.namespace,
readOnlyHint: p.readOnly, status: p.status, durationMs: p.durationMs, exitCode: p.exitCode,
// This is the already bounded provider-visible result, not raw input or a private trace.
output: typeof p.output === "string" ? p.output.slice(0, 16_384) : "",
},
} };
}
export function shouldForwardOpenCodeProxyItem(input: {
turnId?: string;
kind?: unknown;
@@ -106,11 +106,17 @@ it.each([true, false])("settles a real proxy interrupt with controller feedback
await response(1);
send({ id: 2, method: "thread/start", params: { cwd: root, model: "openrouter/deepseek/deepseek-v4-flash-0731", completionContract: { revision: "proxy-v1", criterionIds: ["objective"] }, conversationMode: "prepared" } });
await response(2);
send({ id: 3, method: "turn/start", params: { input: [{ type: "text", text: JSON.stringify({ schema: "paperclip.native-model-envelope.v3", task: { prompt: "completion-feedback" }, completionContract: { revision: "proxy-v1", criteria: [{ id: "objective", requirement: "Complete the fixture." }] } }) }] } });
send({ id: 3, method: "turn/start", params: { input: [{ type: "text", text: JSON.stringify({ schema: "paperclip.native-model-envelope.v3", task: { prompt: "invalid-tool-feedback completion-feedback" }, completionContract: { revision: "proxy-v1", criteria: [{ id: "objective", requirement: "Complete the fixture." }] } }) }] } });
await response(3);
await expect.poll(() => messages.find(message => message.method === "item/tool/call"), { timeout: 5_000 }).toBeDefined();
const call = messages.find(message => message.method === "item/tool/call")!;
expect(call.params!.tool).toBe("paperclip_finish");
const toolFrames = messages.filter(message => (message.params?.item as { id?: string } | undefined)?.id === "part-invalid");
expect(toolFrames.map(message => message.method)).toEqual(["item/started", "item/updated", "item/completed"]);
expect(toolFrames.at(-1)?.params).toMatchObject({
threadId: call.params!.threadId, turnId: call.params!.turnId,
item: { id: "part-invalid", type: "builtinToolCall", tool: "invalid", status: "failed", output: "Tool not found: fixture_missing" },
});
const feedback = "Accepted. Include [Saved document](/PAP/issues/PAP-1#document-plan).";
// The interrupt is ahead of the response on stdin. Commands stay serial,
// but their awaited response must not queue behind the command itself.
@@ -0,0 +1,99 @@
import { CONFIGURED_ENVIRONMENT_KEYS, configuredEnvironment, configuredEnvironmentProjection } from './configured-environment.js';
import { describe, expect, it, vi } from 'vitest';
import { createSanitizedCodexEnvironment } from './drivers/codex/app-server-transport.js';
import { createIsolatedCodexAppServerArgs } from './drivers/codex/codex-security-config.js';
import { createCapabilityRunnerdProviderEnvironment } from './live/runnerd-codex-transport.js';
import { spawnRunner } from './control-plane/durable-prp-control-plane.js';
const pages = {
PAPERCLIP_PAGE_AWS_ACCESS_KEY_ID: 'fake-page-access-key',
PAPERCLIP_PAGE_AWS_SECRET_ACCESS_KEY: 'fake-page-secret',
PAPERCLIP_PAGE_BUCKET: 'pages.example.test',
};
const source = { ...configuredEnvironmentProjection(pages), PATH: '/bin', ...pages, DATABASE_URL: 'host-secret-must-not-cross', NODE_OPTIONS: '--require=/untrusted/bootstrap.js', PAPERCLIP_API_KEY: 'controller-api-key-must-not-cross' };
const identity = { runnerInstanceId: 'runner-proof', environmentLeaseId: 'lease-proof', runId: 'run-proof', normalizedSessionId: 'session-proof', turnId: 'turn-proof', itemId: 'item-proof' };
function shellKeys() {
const args = createIsolatedCodexAppServerArgs(source);
const setting = args.find(value => value.startsWith('shell_environment_policy.include_only='))!;
return { keys: JSON.parse(setting.slice(setting.indexOf('=') + 1)) as string[], serialized: args.join('\n') };
}
describe('controller-configured environment across native runner boundaries', () => {
it('retains configured Pages values at the native Codex provider sanitizer', () => {
expect(createSanitizedCodexEnvironment(source)).toMatchObject(pages);
});
it('retains configured Pages values when preparing a fresh runnerd process', () => {
expect(createCapabilityRunnerdProviderEnvironment({ provider: 'codex', options: { environment: source }, identity, codexHome: '/isolated/codex-home', runtimeContextPath: '/isolated/runtime-context.json', hasRuntimeContext: false })).toMatchObject(pages);
});
it('passes configured Pages values through the runner spawn boundary', () => {
let launched: NodeJS.ProcessEnv | undefined;
spawnRunner({ connection: { mode: 'connect', connectUrl: 'ws://127.0.0.1:43127' }, stateDirectory: '/tmp/runner-env-proof', identity, ticket: 'fake-ticket', maxOutboxBytes: 262144, p0ReserveBytes: 65536, runnerVersion: '0.3.0', runnerDigest: `sha256:${'a'.repeat(64)}`, environment: source,
processLauncher: spec => { launched = spec.environment; return { child: { pid: 42, exitCode: null, signalCode: null, kill: () => true }, completion: Promise.resolve({ code: 0, signal: null, stdout: '', stderr: '' }) }; },
});
expect(launched).toMatchObject(pages);
expect(launched!.DATABASE_URL).toBeUndefined();
expect(launched!.NODE_OPTIONS).toBeUndefined();
expect(launched!.PAPERCLIP_API_KEY).toBeUndefined();
});
it('allows configured Pages values into model-issued shell commands without serializing secrets into argv', () => {
const { keys, serialized } = shellKeys();
expect(keys).toEqual(expect.arrayContaining(Object.keys(pages)));
expect(keys).not.toContain('DATABASE_URL');
expect(keys).not.toContain('NODE_OPTIONS');
expect(keys).not.toContain('PAPERCLIP_API_KEY');
for (const value of Object.values(pages)) expect(serialized).not.toContain(value);
});
it('continues to exclude unbound ambient Pages credentials and host secrets', () => {
vi.stubEnv('PAPERCLIP_PAGE_AWS_SECRET_ACCESS_KEY', 'ambient-page-secret');
try {
expect(createSanitizedCodexEnvironment().PAPERCLIP_PAGE_AWS_SECRET_ACCESS_KEY).toBeUndefined();
const sanitized = createSanitizedCodexEnvironment(source);
expect(sanitized.DATABASE_URL).toBeUndefined();
expect(sanitized.NODE_OPTIONS).toBeUndefined();
expect(sanitized.PAPERCLIP_API_KEY).toBeUndefined();
} finally { vi.unstubAllEnvs(); }
});
});
describe("configured environment authority", () => {
it("keeps selected bootstrap values in the environment rather than argv", () => {
const environment = { PATH: "/bin", ...configuredEnvironmentProjection({ LANG: "configured-language-value" }) };
const args = createIsolatedCodexAppServerArgs(environment);
expect(args.join("\n")).not.toContain("configured-language-value");
expect(args.find(arg => arg.startsWith("shell_environment_policy.include_only="))).toContain("LANG");
});
it("projects ordinary task credentials only when explicitly selected", () => {
const projected = configuredEnvironmentProjection({ DATABASE_URL: "task-db", CUSTOM_FLAG: "on", NODE_OPTIONS: "unsafe", PAPERCLIP_API_KEY: "controller" });
expect(createSanitizedCodexEnvironment(projected)).toMatchObject({ DATABASE_URL: "task-db", CUSTOM_FLAG: "on" });
expect(projected.NODE_OPTIONS).toBeUndefined();
expect(projected.PAPERCLIP_API_KEY).toBeUndefined();
});
it("does not accept an ambient projection in the provider or shell policy", () => {
vi.stubEnv(CONFIGURED_ENVIRONMENT_KEYS, JSON.stringify(["PAPERCLIP_PAGE_AWS_SECRET_ACCESS_KEY"]));
vi.stubEnv("PAPERCLIP_PAGE_AWS_SECRET_ACCESS_KEY", "ambient-secret");
try {
expect(createSanitizedCodexEnvironment().PAPERCLIP_PAGE_AWS_SECRET_ACCESS_KEY).toBeUndefined();
expect(createIsolatedCodexAppServerArgs().join("\n")).not.toContain("PAPERCLIP_PAGE_AWS_SECRET_ACCESS_KEY");
} finally { vi.unstubAllEnvs(); }
});
it.each(["not-json", "[1]", '["NODE_OPTIONS"]', '["PAPERCLIP_API_KEY"]', '["PAPERCLIP_RUNNER_BOOTSTRAP_TICKET"]', '["OPENAI_API_KEY"]', '["LD_PRELOAD"]', '["bad=name"]', '["CUSTOM","CUSTOM"]', '["MISSING"]'])
("rejects malformed or reserved projection %s", raw => {
expect(() => configuredEnvironment({ [CONFIGURED_ENVIRONMENT_KEYS]: raw })).toThrow("Invalid configured environment");
});
it("rejects oversized and null-containing values without reporting their contents", () => {
for (const value of ["x".repeat(65_536), "secret\0suffix"]) {
expect(() => configuredEnvironmentProjection({ CUSTOM: value })).toThrow(/configured environment|Configured environment/);
}
});
it("preserves configured values through ACPX and OpenCode preparation", async () => {
const { createSanitizedAcpxSpawnInput } = await import("./drivers/acpx/environment.js");
expect(createSanitizedAcpxSpawnInput(source, "codex").env).toMatchObject(pages);
expect(createCapabilityRunnerdProviderEnvironment({ provider: "opencode", options: { environment: source }, identity, codexHome: "/isolated/home", runtimeContextPath: "/isolated/context", hasRuntimeContext: false })).toMatchObject(pages);
});
});
Loaded 100 of 520 files, more files were not shown because too many files have changed in this diff. Show more