mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-24 18:19:05 +02:00
Update private task storage with master migrations
Preserve the reviewed privacy rules and existing PR history. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
commit
6d6c7331cb
461 files changed
+37259
-57220
No files matched your search
@@ -0,0 +1,38 @@
|
||||
import { mkdir, mkdtemp, realpath, rm, symlink, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { afterEach, expect, it } from "vitest";
|
||||
import { resolveCursorProvisioner } from "../commands/runtime.js";
|
||||
|
||||
const roots: string[] = [];
|
||||
afterEach(async () => { await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))); });
|
||||
async function fixture() {
|
||||
const root = await realpath(await mkdtemp(join(tmpdir(), "cursor-public-setup-")));
|
||||
roots.push(root);
|
||||
await mkdir(join(root, "dist/vendor/paperclip-runner/cli"), { recursive: true });
|
||||
await writeFile(join(root, "package.json"), JSON.stringify({ name: "@paperclipai/server" }));
|
||||
await writeFile(join(root, "dist/index.js"), "throw new Error('must not import server');");
|
||||
const provisioner = join(root, "dist/vendor/paperclip-runner/cli/provision-cursor.cjs");
|
||||
await writeFile(provisioner, "fixture");
|
||||
return { root, provisioner, url: pathToFileURL(join(root, "dist/index.js")).href };
|
||||
}
|
||||
|
||||
it("resolves the public provisioner without starting the server", async () => {
|
||||
const f = await fixture();
|
||||
expect(await resolveCursorProvisioner(f.url)).toBe(f.provisioner);
|
||||
});
|
||||
|
||||
it("rejects repository layouts and wrong package identities", async () => {
|
||||
const f = await fixture();
|
||||
await mkdir(join(f.root, "src")); await writeFile(join(f.root, "src/index.ts"), "fixture");
|
||||
await expect(resolveCursorProvisioner(pathToFileURL(join(f.root, "src/index.ts")).href)).rejects.toThrow("published server layout");
|
||||
await writeFile(join(f.root, "package.json"), JSON.stringify({ name: "untrusted" }));
|
||||
await expect(resolveCursorProvisioner(f.url)).rejects.toThrow("identity is invalid");
|
||||
});
|
||||
|
||||
it("rejects a provisioner linked outside the installed package", async () => {
|
||||
const f = await fixture(); const other = await fixture();
|
||||
await rm(f.provisioner); await symlink(other.provisioner, f.provisioner);
|
||||
await expect(resolveCursorProvisioner(f.url)).rejects.toThrow("escapes");
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { lstat, readFile, realpath } from "node:fs/promises";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { Command } from "commander";
|
||||
|
||||
/** Resolve the installed public dependency without importing or starting it. */
|
||||
export async function resolveCursorProvisioner(serverUrl: string): Promise<string> {
|
||||
const url = new URL(serverUrl);
|
||||
if (url.protocol !== "file:" || url.search || url.hash) throw new Error("Cursor setup requires an installed Paperclip server");
|
||||
const entry = await realpath(fileURLToPath(url));
|
||||
if (!entry.endsWith("/dist/index.js")) throw new Error("Cursor setup requires the published server layout");
|
||||
const root = resolve(dirname(entry), "..");
|
||||
const manifest = join(root, "package.json");
|
||||
const info = await lstat(manifest);
|
||||
if (!info.isFile() || info.isSymbolicLink() || info.size > 65536 || JSON.parse(await readFile(manifest, "utf8")).name !== "@paperclipai/server") throw new Error("Cursor setup server package identity is invalid");
|
||||
const provisioner = join(root, "dist/vendor/paperclip-runner/cli/provision-cursor.cjs");
|
||||
if (await realpath(provisioner) !== provisioner || !(await lstat(provisioner)).isFile()) throw new Error("Cursor setup entrypoint escapes its server package");
|
||||
return provisioner;
|
||||
}
|
||||
|
||||
export async function setupCursorRuntime(): Promise<void> {
|
||||
const provisioner = await resolveCursorProvisioner(import.meta.resolve("@paperclipai/server"));
|
||||
const child = spawn(process.execPath, [provisioner], {
|
||||
stdio: "inherit", env: { PATH: process.env.PATH ?? "/usr/bin:/bin", LANG: "C.UTF-8" },
|
||||
});
|
||||
const cancel = () => { if (child.exitCode === null && child.signalCode === null) child.kill("SIGTERM"); };
|
||||
process.on("SIGINT", cancel); process.on("SIGTERM", cancel);
|
||||
try {
|
||||
await new Promise<void>((accept, reject) => {
|
||||
let spawnError: Error | undefined;
|
||||
child.on("error", error => { spawnError = error; });
|
||||
child.once("close", (code, signal) => {
|
||||
if (spawnError) reject(spawnError);
|
||||
else if (code !== 0 || signal) reject(new Error("Cursor setup did not finish. Review its error; an invalid installation is never replaced automatically."));
|
||||
else accept();
|
||||
});
|
||||
});
|
||||
} finally { process.off("SIGINT", cancel); process.off("SIGTERM", cancel); }
|
||||
}
|
||||
|
||||
export function registerRuntimeCommands(program: Command): void {
|
||||
program.command("runtime").description("Manage explicitly installed agent runtimes")
|
||||
.command("setup <provider>")
|
||||
.description("Install and verify the pinned Cursor runtime for this host (public downloads; no model calls)")
|
||||
.action(async (provider: string) => {
|
||||
if (provider !== "cursor") throw new Error("Supported runtime setup: paperclipai runtime setup cursor");
|
||||
await setupCursorRuntime();
|
||||
});
|
||||
}
|
||||
@@ -32,6 +32,7 @@ import { applyDataDirOverride, type DataDirOptionLike } from "./config/data-dir.
|
||||
import { loadPaperclipEnvFile } from "./config/env.js";
|
||||
import { initTelemetryFromConfigFile, flushTelemetry } from "./telemetry.js";
|
||||
import { registerWorktreeCommands } from "./commands/worktree.js";
|
||||
import { registerRuntimeCommands } from "./commands/runtime.js";
|
||||
import { registerPluginCommands } from "./commands/client/plugin.js";
|
||||
import { registerClientAuthCommands } from "./commands/client/auth.js";
|
||||
import { registerConnectCommand } from "./commands/client/connect.js";
|
||||
@@ -261,6 +262,7 @@ registerSecretCommands(program);
|
||||
registerSkillsCommands(program);
|
||||
registerTeamCommands(program);
|
||||
registerWorktreeCommands(program);
|
||||
registerRuntimeCommands(program);
|
||||
registerEnvLabCommands(program);
|
||||
registerPluginCommands(program);
|
||||
|
||||
|
||||
@@ -98,3 +98,7 @@ have distinct identities.
|
||||
Native failure records and reports redact the assigned key before truncating diagnostics. Streaming output buffers settle at item or turn completion: short structural prefixes (such as a trailing dash) are preserved, longer interrupted key fragments become redaction markers, and pending buffers are cleared. Terminal events carry these settled `outputTails`; transcript projection displays them as deltas without changing the source event receipt.
|
||||
|
||||
Codex shell delivery preserves its default `KEY`/`SECRET`/`TOKEN` name exclusions for other configured credentials. The exceptions are the three identity variables and `PAPERCLIP_API_KEY`: the latter is the short-lived, scoped run/bridge credential required by the Paperclip agent skill’s Bash/curl API calls. Disabling Codex’s automatic exclusions is paired with this explicit filtered allowlist; it does not admit arbitrary host environment variables. Provider authentication secrets can still reach the provider process without being newly exposed to shell commands by this feature.
|
||||
|
||||
Cloud customer-success inspection can consume this existing identity together
|
||||
with strict active-run authority. See [inspection support](CUSTOMER-SUCCESS-INSPECTION.md).
|
||||
No additional agent keypair or private-key distribution is introduced.
|
||||
@@ -0,0 +1,85 @@
|
||||
# Cloud customer-success inspection support
|
||||
|
||||
Paperclip exposes `/api/customer-success/v1/run-authority` and `/read` for the
|
||||
coordinated Cloud inspection broker. This foundation supports one registered
|
||||
Paperclip agent; bot creation, scheduling, scoring and reporting are separate.
|
||||
|
||||
The authority endpoint is disabled unless
|
||||
`PAPERCLIP_CUSTOMER_SUCCESS_AUTHORITY_ENABLED=true`. It accepts only a strict,
|
||||
current instance/company-derived managed-run JWT. It requires the authenticated
|
||||
agent's existing provisioned Ed25519 identity and an active running heartbeat;
|
||||
paused, cancelled/stopped, finished and unsupported-runtime agents are rejected.
|
||||
No identity GET provisions keys. Normal API JWT compatibility remains unchanged;
|
||||
legacy signatures are rejected specifically at this authority boundary.
|
||||
|
||||
Tenant reads are disabled unless
|
||||
`PAPERCLIP_CUSTOMER_SUCCESS_INSPECTION_ENABLED=true`. Configure the public-only
|
||||
`PAPERCLIP_CUSTOMER_SUCCESS_INSPECTION_JWKS` and the stable HTTPS
|
||||
`PAPERCLIP_CUSTOMER_SUCCESS_CLOUD_ORIGIN`. Cloud provision/roll delivers the trust
|
||||
configuration. The persisted runtime stack identity is authoritative; an explicit
|
||||
`PAPERCLIP_CLOUD_STACK_ID` is usable on operator-configured qualification stacks.
|
||||
Cloud's dedicated inspection key is distinct from the inspecting agent's key.
|
||||
|
||||
The tenant verifies a maximum-sixty-second, audience/type-bound Ed25519 permit
|
||||
with exact stack, query, grant, registered key/agent/run, request and binding
|
||||
version. It calls Cloud's permit-consumption endpoint before reading, so replay
|
||||
fencing and every inspection record live in Cloud. Cloud remains responsible for
|
||||
stack eligibility, seven days from creation/pooled claim, human older-stack
|
||||
approval, active source-run checks, limits, revocation and audits. The source run
|
||||
bearer is never forwarded to tenants.
|
||||
|
||||
The namespace terminates before actor middleware. It never creates users,
|
||||
memberships, sessions, run-identity snapshots, activity records or read receipts.
|
||||
All database readers run inside repeatable-read, read-only transactions. An
|
||||
explicit catalog avoids existing GET side effects such as skill reconciliation,
|
||||
instruction recovery/adoption and provider-trace cleanup. There is no arbitrary
|
||||
GET proxy, write operation or credential-resolution operation.
|
||||
|
||||
The catalog in `services/customer-success-inspection.ts` covers companies,
|
||||
directory/membership/permission metadata, agents/config/instruction revisions and
|
||||
public identity, tasks/comments/documents/interactions, approvals/decisions,
|
||||
routines/schedules, projects/workspaces/stored repository assignments, skill
|
||||
sources/versions, execution events/trace metadata, activity/costs, work products/
|
||||
assets/attachments, and existing readable connections/installs/grants/catalogs.
|
||||
`packages/shared/src/customer-success.ts` is the versioned wire contract and is
|
||||
mirrored byte-for-byte in the private Cloud broker; update both together.
|
||||
|
||||
`list`/`get` readers enforce company scope and bounded pagination. Special
|
||||
operations read instruction files without repair, installed skill snapshots,
|
||||
existing run logs, workspace files through the current file-resource service,
|
||||
and company-scoped storage assets. Workspace context uses an owning task and
|
||||
existing project/workspace checks. Binary envelopes and inclusive byte ranges
|
||||
pass through Cloud; ranges cap at 1 MiB, JSON at 2 MiB, and lists at 100 rows.
|
||||
Remote or unsnapshotted resources report unavailable; large content requires
|
||||
pagination or download ranges. No storage credentials or bypass URLs are issued.
|
||||
|
||||
Existing config/event/run redactions and path/symlink/secret-file/size restrictions
|
||||
remain in use. Authentication account/session, secret, private-key/encrypted-key
|
||||
and raw provider-trace tables/readers are excluded. Identity-enabled trace
|
||||
suppression stays intact. No new prose/file scanner is added; arbitrary pasted
|
||||
secrets in otherwise readable content may be present.
|
||||
|
||||
Deploy this support first, then Cloud's migration/broker/admin UI. Keep both
|
||||
inspection and Cloud policy disabled until staging and internal canary checks
|
||||
pass. Wake only idle sleeps through Cloud's existing controller; normal startup
|
||||
and background writes after wake are separate from the inspection read. Existing
|
||||
human login/Slack alerts are unchanged.
|
||||
|
||||
Focused verification:
|
||||
|
||||
```sh
|
||||
pnpm exec vitest run server/src/__tests__/customer-success.test.ts \
|
||||
server/src/__tests__/agent-auth-jwt.test.ts server/src/__tests__/agent-identity.test.ts
|
||||
# Coordinated qualification, with the sibling Cloud build:
|
||||
PAPERCLIP_INSPECTION_CLOUD_DIST=/absolute/path/paperclip-cloud/dist \
|
||||
pnpm exec vitest run server/src/__tests__/customer-success-cloud.test.ts
|
||||
```
|
||||
|
||||
The coordinated test uses two disposable local PostgreSQL databases, a real
|
||||
managed process with the existing identity/env injection, a PostgreSQL-backed
|
||||
Cloud broker, HTTP permit consumption, the existing wake controller with a local
|
||||
provider, and bounded binary file reads. It tests durable replay fencing and
|
||||
never touches a customer. Cloud's operations document covers enrollment,
|
||||
capability grants, exclusions, approval/expiry/revocation, audit retention and
|
||||
rollback. During rollback, disable Cloud policy first, disable tenant inspection,
|
||||
and preserve identity material and Cloud audit history.
|
||||
+1
-1
@@ -226,7 +226,7 @@ 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 `0312` and `0313` are explicitly allowlisted in the
|
||||
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
|
||||
|
||||
@@ -1541,6 +1541,18 @@ the saved question. Questions do not contribute to composer pending counts.
|
||||
Dismissal persists locally for the person and task across reloads; reopening
|
||||
restores the original form and draft. Approval and permission gates are unchanged.
|
||||
|
||||
A saved ordinary question becomes historical when a newer human task message
|
||||
moves it out of the current composer. Agent context, completion feedback, and
|
||||
native finalization apply the same rule. The historical question remains pending
|
||||
and answerable, including after completion; a later human answer records history
|
||||
without reopening the task or waking its agent. Its presence alone does not
|
||||
request another answer or prevent completion. Cancellation still expires pending
|
||||
questions. Agents continue work that does not need the missing input and withdraw
|
||||
obsolete questions when later evidence satisfies them. A real current input
|
||||
blocker must identify what is still needed. Approval, permission, connection,
|
||||
and configured review gates remain active. No server-side UI dismissal record
|
||||
is required.
|
||||
|
||||
## 15. Operational Requirements
|
||||
|
||||
## 15.1 Environment
|
||||
|
||||
@@ -0,0 +1,575 @@
|
||||
# Cursor ACP capability inventory
|
||||
|
||||
Current source (2026-10-06): **Cursor profile v14 combines the model-selection correction with mainline ACP resource-not-found handling**.
|
||||
The bundled ACPX runtime forwards unlisted model IDs unchanged, verifies model
|
||||
selection responses, and reapplies a selected model when a loaded session reports
|
||||
another model. Its 14 package tests exercise startup and reconnect through a real
|
||||
ACPX manager with offline ACP fixtures. Cursor activity projection now belongs to
|
||||
its adapter. The release attestation binds the updated ACPX patch; older profile
|
||||
identities cannot be reused for new launches. Committed plan waits remain readable.
|
||||
The live evidence below belongs to profile v11; it does not certify v14 artifacts.
|
||||
|
||||
Historical release candidate (2026-10-04): **Cursor profile v11 passed local and Daytona qualification**.
|
||||
It ports snapshot `22c78242a4e0c2369fecf0c2dc4e7600fbad6706` onto mainline
|
||||
`dd868ed125cd709506dd9b29fca640a44d580501`, preserving newer recovery and owned
|
||||
warm agent-file handoff. The [readiness checklist](../plans/2026-10-03-cursor-production-readiness.md)
|
||||
records all ten local and all ten remote gates, including cleanup. The final
|
||||
public installed-package smoke is recorded separately there. The historical
|
||||
results below retain their own identities and limits.
|
||||
|
||||
The public installation path is `paperclipai runtime setup cursor`. It explicitly
|
||||
downloads the pinned CLI `2026.09.26-dd393fe`, applies the source-owned patch, and
|
||||
verifies the complete runtime closure. npm installation does not download Cursor.
|
||||
Run setup as the OS user that runs Paperclip. Local assets live in that account's
|
||||
`~/.paperclip/runtimes/cursor/<platform>-<arch>/<closure-sha256>`, independently of
|
||||
npm directory ownership and isolated provider HOME/XDG settings. Existing image
|
||||
assets remain authoritative; an invalid packaged runtime cannot fall back to the
|
||||
user cache. Release assembly and runtime loading reject stale Cursor profile or
|
||||
closure identities even when the supplied manifest has been rehashed.
|
||||
Ordinary provider packs include those assets, and Daytona image preparation uses
|
||||
the same distribution. Supported targets are macOS ARM64/x64 and Linux x64.
|
||||
|
||||
Configure a company secret and bind it in the agent environment as
|
||||
`CURSOR_API_KEY` or `CURSOR_AUTH_TOKEN`. Select the model explicitly; native ACP
|
||||
must acknowledge that exact model before execution. Missing assets, credentials,
|
||||
model access, and entitlement failures are surfaced without selecting a substitute.
|
||||
`acpxSessionMode` supports Agent (default), Plan, and Ask and is part of recovery
|
||||
identity. Acceptance of a native plan succeeds the planning run while leaving the
|
||||
task open awaiting explicit direction; acceptance alone never starts implementation.
|
||||
|
||||
Paperclip semantic questions are the supported question path. The native
|
||||
AskQuestion handler is defensive compatibility code and is not certified for this
|
||||
release. Per-run dollar usage is unavailable. Partial counters remain diagnostic
|
||||
observations with unknown semantics; they do not establish measured spend or an
|
||||
enforceable per-run dollar bound. Qualification uses the separately authorized
|
||||
account cap. Image-input delivery, detailed native diffs, and deeper child
|
||||
transcripts remain follow-ups.
|
||||
|
||||
Historical source candidate (2026-10-01): **Cursor profile v10 is unqualified**.
|
||||
Native tool IDs containing C0, C1 or DEL control characters now use the same
|
||||
bounded hash in permission details, passive evidence and the sidecar tool event.
|
||||
Blank permission IDs are rejected because ACPX drops blank tool-event identity.
|
||||
Other admitted IDs stay unchanged at this boundary; distinct IDs stay distinct.
|
||||
Canonical tool execution then applies the existing Rust opaque-ID conversion.
|
||||
For example, `tool/1` remains the native permission/evidence key while its
|
||||
execution ID is the deterministic opaque hash. Lifecycle readers explicitly
|
||||
convert between these keys; they must not compare them directly. Both-order
|
||||
bridge tests cover this distinction, including 161-character IDs. The original ACP request and option identity
|
||||
remain intact for response delivery. The declaration binds the identity helper,
|
||||
permission adapter and evidence projector. Retained v9 sessions are incompatible.
|
||||
The native distribution and its usage limits are unchanged. Deterministic tests
|
||||
cover both permission/tool arrival orders and an unresolved real ACPX callback;
|
||||
fresh local and Daytona qualification is still required.
|
||||
|
||||
Historical source candidate (2026-09-30): **Cursor profile v9 is unqualified**.
|
||||
Its declaration binds `paperclip-cursor-usage-v4`, all three newly verified native
|
||||
closures and the shared ACPX patch that persists bounded diagnostic observations.
|
||||
Every native counter receipt remains partial with unverified semantics; it cannot
|
||||
satisfy token or dollar accounting. The exact model remains unpriced until a
|
||||
verified rate is available. Prior v7 source, builds and paid observations below
|
||||
are historical and do not qualify v9. Old v7/v8 sessions must be reopened.
|
||||
The v9 collector marks missing, invalid or reused child run ordinals as
|
||||
`child_run_attribution_unverified`. The offline proof executes the pinned
|
||||
vendor child creation and reuse methods on all three platforms; child counter
|
||||
aggregation semantics remain unverified.
|
||||
|
||||
Historical qualification checkpoint (2026-09-30): **Cursor profile v9 remains unqualified.** Draft PR #14724 at `3adee6f3fc652d5f5ee40b2061a16cea0c7341f5` passes Apex 5/5 and all 51 check runs plus one context; one unchanged-head failed-job retry is recorded. It fixes future eval-notice preservation but does not regrade the retained strict protocol-accounting failure or establish native USD. The verified fixed $25 account-cycle cap resets October 28 and applies to account on-demand fees, not per-cell costs. The separate 15-test helper proposal is unintegrated and grants no launch authority. Profile v9 binds usage-v4 and three native closures; counters remain partial with unverified semantics. Local and Daytona qualification remain pending.
|
||||
|
||||
A separate native-question attempt ended without a captured
|
||||
`cursor/ask_question` callback, and the model reported that AskQuestion was
|
||||
unavailable. [Public upstream review](https://forum.cursor.com/t/agent-acp-never-offers-the-askquestion-tool-so-cursor-ask-question-is-never-sent/172976)
|
||||
attributed the reported symptom to server-side ACP session identification,
|
||||
without identifying a missing initialize capability or a verified fix release.
|
||||
Native AskQuestion therefore remains unverified for the pinned model/mode; this
|
||||
does not establish universal absence or support in every mode. Semantic question
|
||||
continuation is separate and does not qualify the native callback. Evidence is
|
||||
retained at `runtime-9-8-10-preparation/cursor-question-public-review/root-findings.json`
|
||||
(SHA-256 `2fc34c1942e85ab58577b7cfa8ccdeafe520cc7c539aa67a7eef5a474848ad73`).
|
||||
|
||||
Historical v7 checkpoint (2026-09-30): **Cursor profile v7 remains unqualified**. The corrected local native-plan case on controller/Product source `3d21d375de2b6249d9f5322bf001ce0ce0e052aa` and native runtime `5b8e4454ef0bf12d0bb068c2e41d8c9df9356a1c` passed all 26 checks. Reject, feedback, revision-bound acceptance, browser reconnect, exact delivery and unchanged workspace bytes passed. The single planning run succeeded while the task remained In Progress with an explicit next-message wait; no implementation or task completion is claimed. Cleanup and full end integrity passed. Result SHA-256: `685609f01291db84f40d890ef562473b95065e86907bd964b656ad732f6dd97a`. The earlier controller40064 attempt remains failed. Controller fix `c58a8881f` uses the production schema/policy/contract hash envelope; native runtime/profile bytes are unchanged. That exact Cursor PR head has green CI and Greptile 5/5. All three platform builds exist, but the remaining current-profile local/Daytona cases are still required. Native AskQuestion availability remains unverified. Cursor on-demand usage remains $0 under the approved fixed $25 account cap; native per-run USD is unknown. See the [comparative capability report](runner-rich-acp-capabilities.md) for exact source roles, evidence and remaining gates.
|
||||
|
||||
Evidence updated: 2026-09-29. Candidate: `2026.09.26-dd393fe`. This profile is **not live-qualified**. One authorized task-context prompt on the initial free account returned an upgrade requirement, invoked no semantic tools, and supplied no usage receipt. Its cost is unknown; the account dashboard remained unchanged at coarse precision. The user then selected another account, whose exact-model local Runner Eval passed task-context and history tools with clean terminal settlement. Its dashboard attributed 56K rounded tokens to included usage and no incremental charge; ACP still supplied no token or dollar receipt. The local Product hello subsequently passed through the real browser, server, database, native runner, and authenticated completion tool. A second local Product case passed file creation, editing, command validation, an independent exact-byte matcher, and visible workspace artifact presentation. The merged-source local Product question and revision-bound Plan approval also passed browser response and semantic-tool continuation. A serialized follow-up passed pending semantic-question recovery across a server restart. A verified native ACP probe also denied a shell write before any observed side effect. Native Cursor questions/plans and their recovery, durable permission presentation, interruption, and Daytona execution remain required qualification gates.
|
||||
|
||||
The earlier 2026-09-29 source used **profile v5**, with native session-mode admission and the instruction correction described below. All prior paid results (including v4) and packaged builds remain historical and cannot qualify v5. The earlier mode-selection limitation below describes the pre-v5 implementation.
|
||||
|
||||
The reference is the runner's Codex app-server integration and its closed thread-item inventory in `src/provider-events.ts`. Cursor ACP and its private extension methods are the transport; the legacy Cursor adapter is unchanged.
|
||||
|
||||
## Evidence and packaging
|
||||
|
||||
- [Cursor's ACP contract](https://cursor.com/docs/cli/acp) describes stdio JSON-RPC, sessions, permissions and five extensions. The pinned binary reveals additional capabilities and one material documentation discrepancy below.
|
||||
- `packages/paperclip-runner/cursor-distributions.json` pins vendor archive and complete extracted execution-closure SHA256 digests for macOS ARM64/x64 and Linux x64. All three archives were downloaded and independently inventoried (446 files on macOS, 454 on Linux). A real macOS ARM64 installation passed the materializer's full closure check.
|
||||
- The archive is a complete runtime: bundled `node`, `index.js`, dynamically loaded numbered JavaScript chunks, native addons, workers, and assets. Pinning only the launcher or one executable does not pin its execution dependencies. The materializer rejects links/special files and overwrites, and verifies every file against the pinned closure digest.
|
||||
- `test/fixtures/cursor-acp/credential-free-probe.json` records an actual macOS ARM64 initialization and nonbillable method probes. `initialize` reports ACP v1, session load/list, HTTP/SSE MCP, image prompts, and opt-in subagent events. Audio and embedded context are false. `session/fork` and `session/resume` return method-not-found. Creating/listing a session requires authentication.
|
||||
- Static evidence comes from `5672.index.js` (ACP implementation), `8096.index.js` (ACP SDK protocol schemas), `190.index.js` (hook paths) and `index.js` (CLI/configuration), all bound by the archive and closure pins. SDK schema support alone is not counted as implemented harness capability.
|
||||
|
||||
## Capability matrix against Codex app-server
|
||||
|
||||
This historical field inventory distinguishes implemented transport behavior from
|
||||
its original qualification gaps. The current release proof and exclusions are in
|
||||
the readiness report linked above; native AskQuestion remains uncertified.
|
||||
|
||||
| Capability | Cursor harness/ACP evidence | Paperclip treatment | Remaining gap or qualification |
|
||||
|---|---|---|---|
|
||||
| Streaming assistant/reasoning | `agent_message_chunk`, `agent_thought_chunk` in live and replay presenters | Existing ACP streaming path | Authenticated event capture required |
|
||||
| Tool lifecycle/input/output | `tool_call` and `tool_call_update`, including raw input/output, locations and content | Canonical lifecycle, bounded output, input-changed indicator and one relative target; Cursor extensions add semantic activity | Raw input body, structured content/diffs/media and additional locations are not projected. See the exact field audit below |
|
||||
| Permissions | Native `session/request_permission` offers `allow_once`, `allow_always`, and `reject_once` | Shared durable approval mapping preserves supported decisions; indefinite grant is not exposed without proven scope | A live raw ACP write denial preserved request ID `0` and original `reject-once`, with 98 absent-marker samples through cleanup. This does not qualify durable approval presentation or reconnect. The original grader failure and separate offline correlation assessment are retained |
|
||||
| Human questions | `cursor/ask_question`, native single/multiple selections | `normalizeCursorQuestionRequest`: all questions required; opaque UI IDs round-trip native IDs; forged/duplicate/missing selections rejected | Active-turn adapter maps canonical answers, decline to skipped, and cancellation; durable delivery still requires end-to-end evidence |
|
||||
| Proposed plans and decisions | `cursor/create_plan` with full Markdown, overview, todos, named phases and project flag | `normalizeCursorPlanRequest`: complete presentation; accept/reject/cancel; revision-derived question ID; rejection feedback | Foundation question descriptions allow 100,000 chars; this boundary also enforces the 196 KiB UTF-8 durable payload cap. Oversize rejects; it is never truncated. No generated `planUri`, since no uploaded plan artifact is fabricated |
|
||||
| Execution checklist | Standard ACP `plan` and `cursor/update_todos` (`merge`) | Per-turn reducer merges by native ID and emits complete snapshots with increasing revisions | Native cancelled status is visible as “Cancelled” with canonical blocked status, since canonical plan schema has no cancelled enum |
|
||||
| Child activity | Optional `subagent_spawned`, `subagent_state_update`; metadata has tool-call/agent/model identity | Stateful child normalizer emits canonical delegation and retains role/task across state deltas; active-turn adapter validates parent, known child spawn, and immutable tool/agent origin | Requires client `_meta.subagents: true`. ACPX SDK 1.4.0 lacks these two update variants. A closed opt-in pre-parser intercept binds the parent and declared descendants; child text/reasoning/plan/tool summaries are attributed to their child, never flattened into the parent. Nested media and raw tool details remain partial |
|
||||
| Completed subagent tasks | `cursor/task`: description, prompt, subtype, model, ID, duration | Canonical delegation with role, model, task and duration in activity summary | Dedicated numeric duration field absent from canonical delegation schema |
|
||||
| Subagent questions | Pinned ACP subagent handler explicitly rejects interactive questions | No unsupported affordance advertised | Confirmed harness omission; requires upstream support |
|
||||
| Generated images | `cursor/generate_image`: description, path and reference-image paths | Canonical generated/viewed artifact events; description notice; realpath/regular-file containment checks; `registered:false` | Paths are references, not automatic uploads. Revalidate before artifact ingestion. No image bytes invented |
|
||||
| Files and diffs | Tool `content` includes `diff` blocks with `path`, `oldText`, `newText`; native edit output and locations | Tool lifecycle plus the runner’s separate workspace-change evidence | Provider-reported before/after blocks are preserved by ACPX but not consumed by the canonical mapper. Additional and absolute locations are dropped; structured provider-diff projection needs implementation and live proof |
|
||||
| True active-turn steering | No `session/steer`; `handlePrompt` cancels its existing pending prompt before starting another | Advertise steering unsupported | Concurrent prompt is interruption/replacement, not Codex-style in-place steering. Do not mislabel it |
|
||||
| Queued follow-ups | No harness queue method discovered | Control-plane scheduling can send another prompt after settlement | No native queue-management events; do not send concurrently to emulate queueing |
|
||||
| Cancellation | `session/cancel` calls the active prompt cancellation callback | Existing interruption path | Process/child cleanup and exact terminal settlement require live qualification |
|
||||
| Turn settlement | Prompt implementation drains background subagent completion and awaits child publishers | Existing prompt result terminal boundary; explicit owned process cleanup | Native write denial settled without side effects. Background shell/child error settlement remains unverified. Credential-free stdin EOF did not exit within five seconds; do not advertise clean EOF |
|
||||
| Session continuity | `session/new`, `session/load`; load replays historical messages, reasoning, tools, images and children | Existing session load/recovery boundary with identity fencing | Duplicate replay suppression and provider-death recovery need authenticated proof |
|
||||
| Session discovery | `session/list` implemented, cwd filter absolute; pagination cursor rejected | Discovered and reported | Currently unused: runner recovers only its exact recorded session; arbitrary history browsing needs company-scoped discovery API |
|
||||
| Fork/resume methods | `session/fork` and `session/resume` absent in agent implementation and return method-not-found | Unsupported | Session load is available; do not infer fork from SDK schema |
|
||||
| Modes | `agent`, `plan`, `ask`; `session/set_config_option` returns exact mode; native mode updates are observable | v5 admits an explicit selected mode, binds recovery identity, and gates every prompt against native acknowledgement | Default Agent; mode is independent of permission policy. Plan acceptance does not switch to Agent. Native callback availability and Plan-mode completion remain unqualified |
|
||||
| Model selection | `session/set_model`, config option `model`, model-parameter options, `config_option_update`; `cursor/list_available_models` extension | Exact requested model must be selected/verified by shared admission | Authenticated listing and exact Luna variant echo passed. Native parameterized picker metadata and separate parameter changes also passed; this richer configuration path is not exposed by the runner. See the authenticated discovery evidence below |
|
||||
| Available commands | `available_commands_update`, including skills/slash commands | ACPX persists command metadata; runner canonical mapper deliberately emits no display event | Command picker and bounded command-discovery surface are not implemented |
|
||||
| Session metadata | `session_info_update` title after automatic naming | Capability documented | Runner owns normalized session identity; provider title is currently not surfaced |
|
||||
| Prompt media | Image=true, audio=false, embeddedContext=false in observed initialize | The shared runner turn contract accepts text only; it does not forward image attachment blocks to ACP | P1: implement bounded, validated attachment-to-ACP conversion with capability checks, then qualify the exact model. Audio/embedded context are explicitly unsupported by this advertisement |
|
||||
| MCP and semantic tools | Session MCP injection supports stdio/HTTP/SSE; user/project config also loaded | Runner-owned authenticated MCP bridge only; the owned distribution patch never initializes ambient MCP | Exact tool discovery and company/token boundary remain live tests |
|
||||
| Usage/cost | Vendor ACP emits only prompt `stopReason`; native `TurnEndedUpdate` separately exposes optional input/output/cache/reasoning counters | The observation-only patch below preserves bounded native counters as partial diagnostic metadata; it emits no standard usage or dollar receipt | Native counter aggregation semantics and exact-model pricing remain unverified; accounting stays unavailable |
|
||||
| Reviews, compaction, hooks, memory | Native mode changes, hooks present internally; no dedicated ACP counterparts to Codex context/hook/memory lifecycle found | No fabricated event families | Distinguish confirmed absence of ACP event mapping from unverified native internals |
|
||||
| Goal controls / lineage | No ACP goal or fork lineage protocol found | Unsupported | No equivalent of Codex native goals/thread lineage |
|
||||
|
||||
## Wire and isolation findings
|
||||
|
||||
The observation-only usage candidate adds `_meta.paperclipCursorUsage` to a
|
||||
prompt response. Its `paperclip.cursor.native-usage.v1` envelope contains a fresh
|
||||
opaque prompt ID and separate native invocation/run observations. It allows at
|
||||
most 64 observations, 64 invocation identities and 16 KiB of JSON. Only observed,
|
||||
safe nonnegative numeric input/output/cache-read/cache-write/reasoning counters
|
||||
are retained; absent or invalid fields are omitted. Every envelope remains
|
||||
`completeness: "partial"` with `native_counter_semantics_unverified`, even when
|
||||
all fields are present. There are no standard ACP usage fields, counter sums,
|
||||
estimated dollars or billed dollars. A reused child ID has no native callback
|
||||
generation, so its counters are omitted with `child_run_attribution_unverified`.
|
||||
The `invocationId` is a generated per-prompt ordinal. `nativeRun` is likewise a
|
||||
generated invocation ordinal for parents, and the vendor's child run ordinal
|
||||
for children; neither is advertised as a native request ID. Finalization closes
|
||||
all tracked invocations, marks any missing terminal observation and enforces the
|
||||
15,744-byte emission limit after all reasons are present, trimming observations
|
||||
explicitly. The declared 16 KiB limit includes a 640-byte reserve for the bounded
|
||||
ACPX request/message identity wrapper persisted alongside the envelope.
|
||||
History and callbacks arriving after their invocation/prompt closes cannot add
|
||||
observations to another prompt. Cancelled prompts return their own partial
|
||||
envelope after the existing cancellation handling; they do not claim complete
|
||||
cancelled-work accounting. A thrown JSON-RPC error returns no prompt envelope,
|
||||
so these observations are not retained on that error path. This is a new source candidate, not a change to
|
||||
the frozen v7 runtime or a regrading of historical evidence.
|
||||
|
||||
The `paperclip-cursor-usage-v4` candidate retains the vendor version and archive
|
||||
pins. Private full-tree materialization verifies 446 files per macOS target and
|
||||
454 files on Linux; only the ACP implementation chunk differs from each vendor
|
||||
tree. Its patched closures are:
|
||||
|
||||
| Platform | Patched closure SHA-256 |
|
||||
| --- | --- |
|
||||
| macOS ARM64 | `257424bd48e35412091c6adfc61e4648e836757ec1d240d890bba81a24918c30` |
|
||||
| macOS x64 | `6f28c799c5afdc64fbdff8a2157f565f17ae7615efe014ac389d63bf70cf2be2` |
|
||||
| Linux x64 | `eadb8bb8ffb0450455b15b88c9b230307a9e149958a0c452d16dd157b4633d74` |
|
||||
|
||||
`usage-v4-runtime-patch-offline-proof.json` retains fresh isolation/instruction
|
||||
checks, and `native-usage-v4-offline-proof.json` records the usage checks. Earlier
|
||||
proof files remain historical. These are offline source proofs; native emission
|
||||
semantics, accounting completeness and live qualification remain unproven.
|
||||
|
||||
The pinned-source offline harness executes the patched prompt method, native
|
||||
invocation expression, child construction/reuse and child-update methods on all three platform chunks with
|
||||
transport/session dependency doubles. It proves routing and bounds, not actual
|
||||
backend emissions, retry/subagent inclusion, cache/reasoning overlap or billing.
|
||||
Shared metadata persistence and new distribution/profile identities are separate
|
||||
integration requirements. Until semantics and the exact selected model's pricing
|
||||
are verified, these observations grant no accounting coverage. The vendor package
|
||||
declares no license grant; its bundled license notices describe third-party
|
||||
components. [Cursor's terms](https://cursor.com/en-US/terms-of-service) and the
|
||||
applicable agreement remain release-review evidence for modified distributions;
|
||||
this source work does not establish redistribution permission.
|
||||
|
||||
The documentation calls todo/task/image methods notifications. The pinned presenter actually calls `connection.extMethod(...).catch(...)`: these messages can carry JSON-RPC IDs. They need immediate acknowledgement plus activity normalization. They must never become human input requests or block awaiting an unnecessary decision.
|
||||
|
||||
Question and plan requests lack a session ID. The shared hook binds them to the retained connection, active execution/session/turn and originating request ID; it rejects stale/duplicate answers, cancels on turn termination, persists requests before exposing them, and awaits exact JSON-RPC pipe-write delivery before recording a resolved interaction. This proves transport handoff, not an additional provider application-level acknowledgement. The provider module does not substitute a best-guess session.
|
||||
|
||||
Fixed launch arguments are `--disable-project-configs --disable-auto-update acp`, using the verified bundled Node and absolute verified `index.js`. Private HOME/XDG roots are required together with CURSOR_CONFIG_DIR, CURSOR_DATA_DIR, disabled compilation caching, `AGENT_CLI_CREDENTIAL_STORE=memory`, and `NO_OPEN_BROWSER=1`. Only explicitly bound CURSOR_API_KEY or CURSOR_AUTH_TOKEN may enter. The controller creates a provider/session-scoped credential-name binding from the explicit task environment, ignoring inherited or caller-supplied markers. Rust forwards it through the closed sidecar environment boundary. Before host admission, the sidecar rejects missing, stale, wrong-provider or unbound credentials and removes the marker before provider launch. Tests cover this full boundary and preserve legacy credential behavior. Do not use `--force`, `--trust`, or `--approve-mcps` to paper over governance.
|
||||
|
||||
`--disable-project-configs` only suppresses `.cursor/cli.json`. It does not suppress project MCP, Cursor/Claude hooks, or installed plugins. `assertCursorWorkspacePolicy` refuses ambient project execution config from the workspace through its nearest Git root, including symlinks and unreadable configuration. Ordinary Claude settings with neither hooks nor plugins remain admissible. Enterprise system hooks are checked too. The host repeats admission checks before native launch. The vendor admission check alone cannot prevent a concurrent file mutation. The current `paperclip-cursor-usage-v4` distribution patch removes local hook loading, asynchronous team hook synchronization, ambient MCP loader initialization, and ambient-client merging from the ACP source. The retained admission check remains defense in depth. This closes those discovery paths independently of filesystem polling; authenticated validation on the patched final runtime remains required.
|
||||
|
||||
Artifact paths are never automatically read or uploaded. References require a present regular file under the physical workspace, reject traversal and symbolic links, and retain `registered:false`. Private/outside paths are omitted and produce a visible warning. This is metadata validation, not durable file ownership or a replacement for artifact registration checks.
|
||||
|
||||
## Verification and open work
|
||||
|
||||
Passed in the provider worktree:
|
||||
|
||||
- 17 Cursor Vitest cases: questions, multi-selection, native ID round-trip, invalid answers, exact plan revisions, outcomes, todo delta merging, delegation metadata, path containment/symlink refusal, child identity retention, private launch defaults and project config gates; active-turn canonical response mapping and immediate acknowledgements; stale/foreign/unknown child identity rejection.
|
||||
- 5 materializer Node tests: platform pins, entire-tree tampering, linked paths, archive tampering before extraction, and overwrite refusal.
|
||||
- Targeted strict TypeScript compilation for both provider modules and their imported contracts.
|
||||
- Credential-free wire initialization and auth/method probes, plus real macOS ARM64 materialization and archive/closure inventories for the other two platforms.
|
||||
|
||||
The generic native-distribution lease now snapshots the pinned closure, launches Cursor through its verified bundled Node with a closed-module guard, and uses the existing lifetime guardian. A real nonbillable Cursor initialization passed through that lease. Its six focused tests passed, including argument fencing, external module rejection, and native child ownership/exit proof. The existing snapshot suite also passed. The foundation’s later master integration refreshes the common Claude/Codex pins; the candidate pack below was built against that synchronized graph.
|
||||
|
||||
Priority follow-ups before advertising support: (1) per-attempt cost attribution despite missing native usage receipts; (2) durable restrictive-approval and interruption proof; (3) remaining local and Daytona files/artifacts, questions/plans, recovery and settlement proof; (4) parent tool content/diff projection and richer nested child presentation; (5) authenticated trace audit for additional unconsumed native fields. Shared receipt/recovery coverage now proves pipe delivery, provider-loss expiry, exact lifecycle IDs and no approval replay.
|
||||
|
||||
Remaining rich capabilities intentionally unused: session discovery, generic mode/config-option UI, provider session titles, command/skill picker metadata, full nested child transcripts beyond bounded delegation summaries, native numeric task duration, and optional planUri. They are listed above with reasons; neither “unsupported” nor “complete” should be inferred for unverified live behaviors. The final combined report must update this inventory with integration evidence and include any further events observed during authenticated qualification.
|
||||
|
||||
Shared display-channel verification additionally covers exact rich-event schema validation, terminal/semantic/source injection rejection, active-turn fences, complete Unicode plan preservation through durable outbox persistence, secret redaction disclosure, and oversized UTF-8 rejection. The 4 KiB diagnostic preview cap remains active for diagnostics, never full approval documents.
|
||||
|
||||
## Child transcript boundary and remaining fields
|
||||
|
||||
Pinned source creates a separate session-update presenter for every child and can use another child as the immediate parent. Enabling `_meta.subagents` therefore requires handling both lifecycle and ordinary child session updates. The Cursor-specific ACPX compatibility path tracks declared descendants per stream and active prompt, forwards them only under the retained root parent authority, and suppresses unknown child sessions. It leaves the SDK's standard parent update schema closed. Known child updates never become parent assistant messages. Old-stream, foreign-parent, pre-turn, settled, aborted, oversized, unknown-child and duplicate-spawn cases are covered.
|
||||
|
||||
Child assistant/reasoning text, plan steps and tool lifecycle are displayed in each child's activity summary. The 4,000-character activity surface retains a clearly marked tail when it fills; this is a summary, not a complete nested transcript. Child tool raw input/output, locations, diffs, media content and other child update fields are not rendered by this summary surface and produce explicit partial-detail notices. Adding canonical child transcript/tool surfaces is the next priority for these meaningful exposed fields. Native `capabilities:{}` currently carries no populated child capability fields; `_meta.cursor.agentId` and `toolCallId` are retained internally for immutable origin checks. Nested parent identity is represented in activity metadata because canonical delegation has no parent-child relation field. Native `disconnected` now yields a failed child with a visible explanation, never a successful completion.
|
||||
|
||||
Three real ACP stream tests prove child lifecycle/transcript preservation before SDK parsing, nested parent attribution, standard parent parsing, and old-stream/turn/connection fences. Shared extension, reply-delivery and Pi receipt package cases pass against the resulting patched package; the current totals are recorded below. Three native factory cases verify all platform pins, exact profiles, and rejection of unsupported distributions; native assets resolve only from the runner-owned package authority.
|
||||
|
||||
## Integrated pack and offline launch evidence
|
||||
|
||||
The full `build-provider-pack.mjs --candidate-providers=cursor` path passed on source `25fb1b5b317e52a8ad50208d7681a1ee34bd939c` (foundation includes upstream master `18e8c121d`). It used standalone Node, pnpm 9.15.4, a fresh production deployment and the pinned vendor archive. The build checked portable Node relocation, fresh ACPX import and complete Cursor materialization. The retained manifest is `packages/paperclip-runner/test/fixtures/cursor-acp/provider-pack-darwin-arm64.json`; it contains only relative paths and digests.
|
||||
|
||||
- Pack digest: `sha256:38bc6dd39c13b0f5478a1018e26deb61b0333ab0242f3d857e3c79d3a34b7682`.
|
||||
- Cursor declaration: `sha256:c91aa592ec867071ec4457b9b7230399ce42ad918787ceed99b4b651ea5607e2`.
|
||||
- macOS ARM64 closure: `sha256:77394184a89b0e7384971181da19c3c82d83a399b04e7944b3f94fe3d7e62b25`.
|
||||
- Resolved dependency lock: `sha256:9eea60187c6c808efb4d936a234a8d8000beac45253091abd4c5348132bd1408`, also the reviewed Daytona build digest for this branch. This digest was resolved from the clean tracked lock with the exact Docker resolution-only command. A second resolution from the clean tracked lock produced the identical digest; the frozen install applied the Cursor/Grok combined patch successfully. The image consumes the reviewed resolved-lock artifact without registry resolution. Image creation rejects a different digest before execution.
|
||||
|
||||
`provider-pack-offline-proof.json` records a real launch through the generic profile installation registry from the pack’s verified native closure and retained snapshot, using private directories and no credentials. ACP v1 initialize succeeds. The correct `cursor/list_available_models`, `session/list` and `session/new` methods return authentication-required; `session/fork` and `session/resume` return method-not-found. The process produced no stderr, was explicitly terminated after these probes, and its lease closed. The earlier discovery fixture’s `_cursor/list_available_models` probe used an incorrect method prefix; this later probe corrects that uncertainty without changing the original evidence.
|
||||
|
||||
A separate credential-free macOS probe on the same final pack initialized with JSON-RPC request ID `0`, then closed stdin at 20:56:57.776 UTC. The native process did not exit within five seconds and produced no stderr. The probe then sent the owned process group SIGTERM and closed its native lease. `provider-pack-offline-proof.json` retains `eofSettled:false`. This is a native lifecycle limitation: no clean-EOF guarantee is claimed. Production cleanup uses explicit process ownership and termination; Linux image qualification must separately record EOF behavior and verify bounded explicit cleanup. This probe sent no credentials or model prompts.
|
||||
|
||||
At the prior foundation checkpoint `7721662f2`, the TypeScript/verified-sidecar build and all 177 targeted cases passed: Cursor normalization/policy/artifacts, credential bindings, the durable launcher's actual process specification, candidate backend admission, and sidecar behavior. The actual process-launch regressions verify explicit candidate credentials and their marker survive the final closed allowlist; ambient credentials and caller-supplied markers cannot select themselves. The prior source `f09a9a3f8` passed all 47 CI jobs after one retry for runner shutdown and a wall-clock rate-limit case, plus Greptile 5/5; it also passed 29 installed ACPX/materializer Node cases and 6 Daytona image-content cases. These prior results remain historical, distinct from the final source proof.
|
||||
|
||||
The current pack/probe binds foundation `f063fbf2b`, including candidate admission, biller attribution, explicit unavailable usage, actual-spawn credential binding, and preservation of usage receipts on terminal provider failure. The verified TypeScript build, 56 Cursor/sidecar/eval-provider tests, 67 shared live/eval tests, and 25 mixed-usage contract tests passed during these qualification fixes. Earlier pack evidence remains in Git history. Its explicit offline factory model sentinel is not an authenticated model selection; no ACP prompt was sent. Daytona content identity includes the candidate selection, Cursor materializer and distribution manifest. This is local macOS ARM64 packaging/initialization evidence only. It does not qualify Linux execution, a Daytona image build/run, authenticated model work, permissions, or dollar accounting. No screenshot is claimed for these headless tests. That offline probe submitted no prompts and incurred no model usage. Subsequent authenticated attempts are recorded separately below.
|
||||
|
||||
The final pack passed 31 installed-package/materializer contract tests, 82 Cursor/integrity profile tests (six platform-specific skips), and 12 candidate/image-content tests. TypeScript and verified-sidecar compilation passed. Its generic installation registry probe initialized the pinned native executable without credentials or prompts, then shut down cleanly. A fresh release daemon was built from source `78360fa55adff11da9f200b228467eca782b81e1`, Rust tree `17d327094be5759d80ef5bd02e15c59e78548ea2` (identical to foundation `f063fbf2b`), SHA256 `e6a9fb5170b76a49b8411834b3706e8edf8f1a1ae85ad13b55368158aa7f67a0`. This source includes bounded snapshot copying and public Grok packaging. Earlier paid attempts retain their original sources, package identities, and daemon digests; refreshing packaging does not rerun or extend their qualification.
|
||||
|
||||
The locally built Linux x64 image also passed credential-free native initialization through the generic installation registry and immutable lease, with Docker networking disabled. Image ID is `sha256:5fa7951d1d6dd99305555fe00a5baf2bf5b834d16f021053737301975b93986f`, built from provider source `25fb1b5b317e52a8ad50208d7681a1ee34bd939c`. The Linux pack digest is `sha256:5fab4bc1f0633561b7d7e858a535ebd23faeccedb5f86044df071a3206d949ba`; the runner daemon SHA256 is `5eb3031aed118112caf0b80ea67a6cd01283098e2b4e526ef02d1416d125a1fb`. The pinned Cursor closure is `sha256:bf04ef8ea6a63191067a6b3e15139aa2c793d8419daab246aa3f0a012e1bbcd9`. ACP initialize request ID `0` returned protocol version 1. Linux also reported `eofSettled:false`; bounded explicit process-group termination and lease cleanup passed. `linux-image-offline-proof.json` retains the identities and limits. This local container proof sent no credentials or model prompts and is not a paid Daytona run or authenticated Linux qualification.
|
||||
|
||||
## Authenticated discovery evidence
|
||||
|
||||
`test/fixtures/cursor-acp/authenticated-capability-probe.json` records the user-authorized follow-up on 2026-09-28. The pinned verified distribution returned 41 model entries, created a session, accepted `gpt-5.6-luna[context=272k,reasoning=medium,fast=false]` through both `session/set_model` and `session/set_config_option`, and returned that exact value. No model prompt was sent. These facts establish authentication and model selection, not inference entitlement or full runner qualification.
|
||||
|
||||
The native picker has two materially different interfaces. Ordinary ACP advertises fixed variant IDs. It rejected `grok-4.7[context=256k,reasoning_effort=low,fast=false]`, although each individual parameter value is advertised by the separate catalog. Advertising client `_meta.parameterizedModelPicker:true` enables base model `grok-4.7` and separate `session/set_config_option` calls; context `256k`, `reasoning_effort:low`, and `fast:false` all succeeded and were echoed. The runner does not yet negotiate or project this parameterized configuration path. Follow-up P2: bind the model plus complete verified parameter map to profile/recovery identity and expose supported options without silently choosing a default. In particular, this account's Grok and Composer catalog defaults enable the more expensive Fast option.
|
||||
|
||||
Credential setup uses [Cursor's documented browser login](https://cursor.com/docs/cli/reference/authentication), with an isolated task-owned home and the pinned file credential store. Only the access token was explicitly saved in the operator's private secret file; credentials and refresh tokens are absent from retained public fixtures. The [official usage guide](https://cursor.com/help/account-and-billing/overages) distinguishes included usage from on-demand charges and warns that spend-limit enforcement is delayed. The first prompt therefore requires a separately reserved budget and attribution from the account usage records; missing ACP usage remains unknown, never zero-cost inference.
|
||||
|
||||
## Authenticated qualification attempts
|
||||
|
||||
`test/fixtures/cursor-acp/authenticated-qualification-attempts.json` retains sanitized per-attempt source, package, daemon, model, timing, cleanup, and cost evidence. The first canonical `get-task-context` attempt used pack source `e5159fb67ad28bf6a691bfa64ffddb332d96f8be`, exact Luna selection, one turn, a 60-second limit, a $0.50 declared envelope inside a $2 reservation, and zero retries. This is a Runner Eval against the mock control plane, not Product E2E or restrictive-permission qualification.
|
||||
|
||||
The provider returned only an upgrade requirement and then normal completion. Source inspection of pinned `5672.index.js` confirms that actionable authentication/entitlement errors are rendered as ordinary assistant text and lose their typed failure before ACP settlement. No text-matching terminal heuristic is used. This was a native-wrapper gap in that unmodified candidate. The owned distribution patch described below preserves those typed errors as failed ACP RPC responses; the historical attempt is unchanged. The semantic oracle must still fail a response that did not invoke the required tool.
|
||||
|
||||
The replacement-account attempt passed all four canonical `get-task-context` checks at source `01959b8a602683f13706807983f02c3cba9d36a0`, using newly rebuilt daemon source `f77ae83aeb6d802c73f1c955760017ae272eba29`. Both `get_task_context` and `get_task_history` returned successful authenticated semantic results. It took 29.291 seconds and exited cleanly. The Usage page showed a new `gpt-5.6-luna-medium` row at 19:28:55 UTC with 56K tokens, marked Included. Incremental cash was zero for that row; metered dollars remain unavailable. A conservative $0.07 list-price bound assumes at most 57K rounded tokens at the maximum listed $1.20/M rate and is not an invoice amount. There was no retry.
|
||||
|
||||
The local Product hello first stopped at agent creation with HTTP 422 before any provider prompt. Foundation `26dde3cfe` fixed exact host-owned candidate/model admission at the public agent API. The separately authorized replacement passed at source `edf538e61e712dddb6b4d59045c3dcfd445686c7`, from 19:41:56.376 to 19:42:38.761 UTC. All six independent matchers passed: one exact completion marker, task Done, run succeeded, native mode, and local execution. The retained run confirms the 120-second configured deadline, no timeout, committed finalization, and clean cleanup. The final screenshot was inspected and shows the Cursor assignee, tool activity, one completion marker, and Done status. Native usage is null. The account Usage page attributes a new 19:42:25 UTC Luna row with 41.7K tokens, Included in Pro Plus, and zero incremental cash. Metered dollars remain unavailable; a conservative $0.06 list-price bound uses at most 42K rounded tokens at $1.20/M and is not a billing receipt. This proves the local completion-tool product path, not native question/plan, permission, file, recovery, or Daytona qualification. Both attempts remain in the sanitized evidence fixture.
|
||||
|
||||
The local file-edit Product case passed at source `fe132224c2b30a8d9ce7b46cea38b8760af233fc`, from 19:44:43.582 to 19:45:24.406 UTC. All seven matchers passed, including an independent exact-byte read of the 24-byte final file. A native shell command created then rewrote the file and asserted the expected bytes; tool lifecycle and output were retained. The inspected browser screenshot shows the file, a downloadable workspace artifact card, edited-file activity, the completion marker, and Done. Cleanup passed. This is workspace artifact discovery and presentation evidence, not provider-reported diff projection. The account Usage page attributes a 19:44:59 UTC Luna row with 84.1K tokens, Included in Pro Plus, and zero incremental cash. Metered dollars remain unknown; the conservative list-price bound is $0.102, not an invoice. The trace also confirms a meaningful remaining field gap: native command `rawOutput.exitCode:0` survives in serialized output, while canonical `exitCode` remains null; extracting only validated command results is a P2 follow-up.
|
||||
|
||||
The merged-source local question Product case passed at `a7e01a0cec397dd5048f5d5b5825658dc6e91450`, from 20:04:44.147 to 20:05:52.691 UTC. Its two expected provider runs submitted at 20:05:00.738 and 20:05:30.525 UTC, with no retry. The real `request_human_input` semantic tool produced a pending single-choice question, the browser selected Cobalt through the public response endpoint, the answered interaction woke the assignee, and the task completed with one final marker. Both pending and final screenshots were inspected; all six terminal matchers, lifecycle invariants, and cleanup passed. Bounds were 120 seconds per turn, 300 seconds overall, and a $2 reservation. Native usage is unavailable. The dashboard attributes Included Luna rows at 20:05:01 UTC (35.5K tokens) and 20:05:35 UTC (42K tokens), with zero incremental cash; metered dollars remain unavailable. A conservative $0.10 list-price allowance is not an invoice. This is semantic interaction and continuation evidence, explicitly not qualification of native `cursor/ask_question`, server restart recovery, or plan decisions.
|
||||
|
||||
The local Product Plan case also passed on the same frozen source `a7e01a0cec`: 20:09:56.429–20:10:49.114 UTC, two expected turns, six passing terminal matchers and no invariant failures. The inspected UI displayed the complete two-step Plan at revision 1. The pending confirmation's `target.revisionId` exactly equaled the displayed document's `latestRevisionId`; it was accepted before continuation and successful completion. Both account Usage rows (48.5K and 43.7K) were Included with zero incremental cash; native metered dollars remain unavailable. This covers semantic `write_document` plus `request_human_input` confirmation, not native `cursor/create_plan`.
|
||||
|
||||
The first subsequent server-restart attempt **failed**; the later authorized retry is recorded below. Its first launcher attempt failed before any provider call because the task's temporary Unix socket path exceeded the macOS limit; the zero-prompt failure remains recorded. An explicitly authorized infrastructure replacement used a short canonical private path and produced the required pending question. During the requested restart, embedded PostgreSQL failed `semget` because the host semaphore limit was exhausted. No answer or continuation occurred. The original result remains failed (`transient_infrastructure`), including failed API cleanup verification; a separate process inspection found no surviving task-owned server, PostgreSQL, runner, or provider process. The dashboard attributes one 20:13:38 UTC Luna row with 23.7K tokens, Included in Pro Plus, zero incremental cash, and no continuation row; native metered dollars remain unavailable. Exact submission time was not recoverable from the post-failure API snapshot, so attribution uses the retained case window. No automatic retry was made.
|
||||
|
||||
The explicitly authorized serialized restart retry passed on source `d35b83074a018537f5475568d7410e3b1d676789`, using the final pack and matching release daemon, from 20:42:13.120 to 20:43:10.985 UTC. All six matchers passed, no invariant failed, and cleanup passed; a separate process inspection confirmed no remaining task-owned processes. The real pending semantic question survived the server restart, was answered Cobalt through the browser, and produced one successful continuation and Done. Both screenshots were inspected. This establishes semantic interaction persistence and wake-assignee recovery, not restoration of a blocked native `cursor/ask_question` request. The dashboard attributes two Luna rows at 20:42:26 UTC (23.8K tokens) and 20:42:57 UTC (41.7K), both Included with zero incremental cash; the displayed current-account total is 458.1K across eleven requests. Native metered dollars remain unknown. Original launcher and PostgreSQL failures remain retained.
|
||||
|
||||
A separate native AskQuestion sidecar probe on final pack source `25fb1b5b317e52a8ad50208d7681a1ee34bd939c` sent one prompt at 20:39:47.990 UTC, with no Paperclip semantic tools. It requested single and multiple selection through Cursor's native question tool. The provider instead replied “The native AskQuestion tool is unavailable in this session,” emitted zero native input requests, and ended normally. The qualification oracle correctly failed; session close passed. The dashboard attributes a 20:39:48 UTC row with 17.4K tokens as Included, with zero incremental cash and unknown native metered dollars. This is observed default-mode tool unavailability, not proof of absence in every Cursor mode. The pinned source implements `session/set_mode` for agent/plan/ask and native question/plan handlers; the runner currently has no explicit mode-selection command. Retained native initialization exposes modes and model settings but no native tool inventory or question/plan availability bit. The bundled `ask_question_all_modes:true` default does not prove the server-selected tool catalog. No native Plan prompt was attempted, and no native interaction pass is claimed.
|
||||
|
||||
The restrictive native denial probe used the final verified pack and exact Luna model, with one prompt submitted at 20:52:07.462 UTC. Cursor emitted numeric request ID `0` for the shell write. Its offered IDs were `allow-once`, `allow-always`, and `reject-once`; the client selected the unchanged `reject-once` and acknowledged the pipe write at 20:52:10.919 UTC. The provider ended at 20:52:11.980 UTC. The forbidden marker was absent in all 98 independent samples, including before launch, at the terminal, five seconds later, and after process-group and lease cleanup at 20:52:16.996 UTC. This establishes the native harness denial behavior, not sidecar normalization, Rust durability, or Product approval UI.
|
||||
|
||||
The original probe grader failed because it expected `rawInput` on the permission frame. That frame omits the arguments, while the preceding same-session `tool_call` carries the exact command under the identical native `toolCallId`. The original failed result remains immutable. A separate offline assessment correlates that exact command, session, tool identity, actual response and delivery acknowledgment with the original marker samples; it passes without another model call. Tests reject missing binding, a different tool ID, a different command, wrong kind, or a title alone. `native-denial-proof.json` retains this distinction and sanitized wire frames. The account Usage page attributes 24.4K tokens at 20:52:07 UTC, Included in Pro Plus, bringing the displayed total to 482.5K across twelve requests with zero on-demand charge. Native metered usage remains unavailable. The conservative $0.03 list-price allowance is not an invoice.
|
||||
|
||||
The first attempt also exposed the shared live-eval layer's rejection of missing usage. The foundation now retains explicit unavailable-usage records for diagnostic candidates, leaves the numeric receipt ledger empty, preserves the response for grading, and keeps unknown aggregate costs unknown across mixed turns. This fix does not retroactively turn the retained failed attempt into a pass.
|
||||
|
||||
[Cursor's Grok Bot billing documentation](https://cursor.com/help/grok-bot/plans) says linking SuperGrok grants Grok Bot usage while leaving the Cursor plan unchanged. [Cursor's pricing page](https://cursor.com/pricing) lists Composer for free Hobby and frontier models for paid plans. These documents and model-list presence do not establish entitlement for a particular CLI prompt. The operator selected a replacement Pro+ account; its exact Luna configuration was echoed successfully, with no automatic model substitution.
|
||||
|
||||
## Exact remaining field audit
|
||||
|
||||
This source audit follows pinned native update → ACPX runtime → `canonicalProviderEventsFromAcpxRuntimeEvent` → Paperclip activity. It supplements the higher-level capability matrix and does not claim an authenticated trace was captured.
|
||||
|
||||
| Native field/interface | Preserved boundary | Current projection / reason for partial use | Follow-up |
|
||||
|---|---|---|---|
|
||||
| Prompt image blocks | Pinned native initialize advertises image input | Shared `AcpxRuntimeTurnInput` has only `text`; `codex-runtime-adapter.ts` passes that text to ACPX, so no image blocks reach the wrapper from a runner turn | P1: add validated, bounded attachment inputs through the runner/ACPX contract, then prove exact-model behavior; native advertisement alone is not implementation evidence |
|
||||
| Parent tool `rawInput` | ACPX forwards the body | Canonical tool schema exposes only `inputUpdated`; full native arguments are not displayed | P1: bounded/redacted tool-argument presentation with company and path checks |
|
||||
| Parent tool `content[]` diff `path`, `oldText`, `newText` | ACPX forwards structured content; native permission requests also use it for proposed changes | Canonical tool mapper uses `rawOutput`, so completed provider-reported diff blocks have no dedicated output surface; separate workspace evidence is not equivalent | P1: validate physical workspace locations and expose attributed diff artifacts |
|
||||
| Parent tool `content[]` images/resource blocks | ACPX forwards blocks and produces a summary | Structured media is unused by canonical tool output; safe registration/location semantics must precede ingestion | P1: validated artifact reference projection with explicit provenance |
|
||||
| Parent tool `locations[]` | ACPX forwards bounded locations | Only first relative location becomes `target`; absolute paths and additional locations are discarded | P1: workspace-aware multi-file attribution; never strip arbitrary absolute prefixes |
|
||||
| Parent tool `rawOutput` | ACPX forwards raw value | Canonical output is bounded serialized text, not lossless structured data; truncation is marked by output metadata | P2: typed tool-result detail viewer when useful |
|
||||
| Plan step priority/native extras | ACPX plan entries | Display uses body/status and fixed ordinal step IDs; priority and unknown metadata have no canonical fields | P2: add explicit bounded fields if native traces show product value |
|
||||
| Commands, config options, session title | ACPX persists/normalizes `available_commands_update`, `config_option_update`, `session_info_update` | Canonical display mapper explicitly skips these tags; exact model admission remains separate | P2: company-scoped command/config/title surfaces |
|
||||
| Child tool raw arguments/results, locations, diffs and media | Cursor child intercept retains validated update for its adapter | Child activity keeps bounded attributed summaries, with visible partial-detail notices; there is no nested transcript/tool detail surface | P1: canonical child transcript and tool/artifact relation model |
|
||||
| Child `_meta.cursor.agentId` / tool origin / parent ID | Retained for immutable-origin and descendant checks | Internal identity guards; parent relationship is textual metadata because canonical delegation lacks an edge field | P2: typed delegation lineage |
|
||||
| Task numeric `durationMs`, optional accepted `planUri` | Native task/plan extensions | Duration is visible in summary; no numeric delegation field. No plan URI is returned because no plan artifact is registered | P2: typed duration; only return an actually registered plan URI |
|
||||
| Session list, model parameters, generic mode control | Confirmed native ACP methods | No operator UI or cross-session discovery API in this integration; recovery loads only the runner-owned exact session | P2: explicit company-scoped controls; authenticated model catalog first |
|
||||
|
||||
True steering, native queueing, fork/resume, native usage receipts and interactive child questions are absent from this pinned ACP implementation as described above. Their absence is distinct from the implemented-but-unused fields in this table. Remote team hooks, provider-native artifact traces, interruption settlement and authenticated Linux/Daytona behavior remain unverified, not confirmed absent.
|
||||
|
||||
## Initial isolation patch, profile v3 (2026-09-29)
|
||||
|
||||
The initial profile v3 materialized `paperclip-cursor-isolation-v1` on top of the
|
||||
unchanged vendor `2026.09.26-dd393fe` archives. The materializer first verifies
|
||||
the archive and **vendor** execution closure, requires exact single-occurrence
|
||||
source anchors in an input-digest-pinned ACP chunk, applies the owned patch,
|
||||
then verifies a separately pinned **patched** execution closure. Both identities
|
||||
remain in the distribution manifest. Unknown, changed, already-patched, or
|
||||
ambiguously matched source fails closed. The patch does not change the legacy
|
||||
Cursor adapter or the vendor interactive CLI.
|
||||
|
||||
The ACP shared-services initializer no longer initializes or loads its ambient
|
||||
MCP loader. Each ACP session starts with an empty client lease and adds only the
|
||||
explicit session MCP definitions, preserving native last-definition-wins behavior.
|
||||
It never borrows an earlier or ambient lease, including when the session MCP list
|
||||
is empty or invalid. Local hook config is an empty snapshot, and the remote team
|
||||
hook fetch/install/update path cannot start. Newly created project config during
|
||||
a session therefore cannot enter either removed discovery path. This is source
|
||||
isolation, not an operating-system sandbox: explicitly allowed native shell
|
||||
commands still have their granted filesystem/network powers.
|
||||
|
||||
Typed `ActionRequiredError` instances now produce a failed JSON-RPC response
|
||||
with data schema `paperclip.cursor.provider-error.v1`, kind `action_required`,
|
||||
and the closed action enum `login`, `upgrade`, `payment`, `config`, or `unknown`.
|
||||
Login and native `ConnectError` with the Unauthenticated code use ACP error
|
||||
`-32000`; other action requirements use `-32603`. Provider-private error detail
|
||||
is not copied into these responses. Ordinary assistant text is never examined to
|
||||
infer an entitlement failure. Generic provider exceptions outside these typed
|
||||
branches retain the vendor behavior and remain an upstream audit item.
|
||||
|
||||
`qualify-cursor-runtime-patch.mjs` executes expressions extracted from each actual
|
||||
input-digest-verified platform chunk. Its poisoned ambient loaders prove no
|
||||
ambient access while owned MCP, empty sessions, and duplicate definitions work;
|
||||
it also executes all typed error branches and ignores entitlement-shaped ordinary
|
||||
errors. The entire patched chunks compile for macOS ARM64/x64 and Linux x64.
|
||||
`runtime-patch-offline-proof.json` records these checks with zero provider calls.
|
||||
All three authentic archives separately passed materialization and post-patch
|
||||
closure verification. Seven materializer tests cover drift, ambiguous anchors,
|
||||
closure tampering, links, archive tampering, identity consistency, and overwrites.
|
||||
Both patched macOS binaries also returned ACP v1 from credential-free real-process
|
||||
initialization and exited after explicit SIGTERM; x64 ran through the ARM host
|
||||
compatibility layer. `runtime-patch-initialization.json` retains this distinction.
|
||||
These are offline checks, not authenticated macOS x64 or Linux/Daytona qualification.
|
||||
|
||||
Prior Product/Runner results above measured the earlier unpatched runtime. The
|
||||
profile must retain pending qualification until the rebuilt final foundation
|
||||
runtime passes the remaining native interaction, durable approval, cancellation,
|
||||
recovery, accounting, and authenticated target-platform gates.
|
||||
|
||||
### Frozen profile v3 build
|
||||
|
||||
[The retained build record](../../packages/paperclip-runner/test/fixtures/cursor-acp/production-v3-offline-build.json)
|
||||
identifies exact runtime source `526081b9d6c905de5e2da680e3a37c620899b380`,
|
||||
profile v3, the ARM64 portable package and matching release daemon, both lock
|
||||
hashes, the committed ACPX patch hash, and credential-free launch results.
|
||||
Dependencies were installed into a fresh private store with copied files to
|
||||
avoid borrowing mutable package bytes from another worktree. The initial frozen
|
||||
install failed because the committed lock's patch configuration differed; that
|
||||
failure is retained. A private derived staging lock then passed frozen install.
|
||||
The repository lock was not changed.
|
||||
|
||||
The package's real immutable lease passed ACP initialization, required
|
||||
authentication for model/session discovery and creation, rejected unsupported
|
||||
fork/resume methods, and closed with no stderr. The TypeScript and release daemon
|
||||
builds passed. Twenty-six installed-package contracts passed. Another 184 control
|
||||
tests passed, including two Cursor-specific tests using the real installed ACP
|
||||
SDK stream: sessionless native single/multi-selection and complete revision-bound
|
||||
plan replies preserve request ID zero, await response pipe delivery, reject stale
|
||||
plan revisions, and suppress late answers after cancellation. The surrounding
|
||||
suite covers mocked admission, permission identity, provider-loss expiry,
|
||||
recovery fences, and cleanup ownership. These are deterministic tests, not live
|
||||
native interaction, durable browser approval, or provider-death qualification.
|
||||
|
||||
The new `cursor-runtime-patch.mjs` is present in the Docker build context. It is
|
||||
also a required Daytona content-identity input: changing patch source must change
|
||||
the image identity even when another declared input is unchanged. Publishing or
|
||||
qualification must use an image whose content hash includes that script.
|
||||
|
||||
|
||||
## Profile v4: instruction delivery and admission (2026-09-29)
|
||||
|
||||
The pinned native ACP implementation ignores `_meta.systemPrompt` on session
|
||||
creation, and ACPX does not send it on load. Native workspace rules do not read
|
||||
Paperclip's registered `AGENT_HOME`. The hidden `--system-prompt` option is an
|
||||
internal-team-gated interactive CLI feature and is not used by this integration.
|
||||
Those facts invalidate any assumption that prior transport success alone proved
|
||||
canonical governance or custom entry instructions reached Cursor.
|
||||
|
||||
The owned `paperclip-cursor-instructions-v2` patch preserves the v3 isolation and
|
||||
typed-error changes and adds the composed instructions through Cursor's native
|
||||
`LocalResourceProvider.additionalRules` path. The sandbox supplies an ephemeral,
|
||||
controller-owned `PAPERCLIP_CURSOR_INSTRUCTIONS` payload. Ambient environment
|
||||
cannot select or replace it, and it is not persisted in ACPX's launch environment
|
||||
record. The payload binds exact UTF-8 content (maximum 32 KiB, no NUL) to SHA-256;
|
||||
it includes the already-composed governance, custom entry content and registered
|
||||
agent-home context, without discovering another workspace instruction file.
|
||||
Explicit empty content has an empty rules list and the SHA-256 of empty bytes.
|
||||
|
||||
Native session creation and load both use this process-owned snapshot and return
|
||||
`_meta.paperclipCursorInstructions` with schema, exact digest, and byte length.
|
||||
The additional rule uses the native global-rule representation and retains
|
||||
native ordering: additional rules precede the ordinarily discovered project
|
||||
rules in `RequestContext.rules`. It is not prepended to the user's message and
|
||||
does not replace Cursor's built-in system prompt. The synthetic rule identifier
|
||||
is `paperclip://runtime/instructions`; no generated instruction file is exposed
|
||||
to workspace discovery.
|
||||
|
||||
A synchronous ACPX `protocolGuardFactory` checks each connection independently.
|
||||
It correlates the acknowledgement with the exact new/load request and session,
|
||||
rejects missing or mismatched acknowledgements before SDK dispatch, and blocks
|
||||
outbound prompts until that session is admitted. Provider death and automatic
|
||||
reload reset authority. The adapter also asserts admission immediately after
|
||||
`ensureSession`. A best-effort observer cannot authorize a prompt. The shared
|
||||
patch retains upstream terminal-failure diagnostics, rich interaction callbacks,
|
||||
response-delivery receipts, and Cursor child-event handling.
|
||||
|
||||
[The v4 declaration](../../packages/paperclip-runner/test/fixtures/cursor-acp/profile-v4-identity.json)
|
||||
binds profile digest `sha256:b1440d559ebc4eef5c7a582f1c81fc153270cfbafa1731a8ee76d83713bdf61b`
|
||||
to all platform closures and ACPX patch SHA-256
|
||||
`64180c194ab841d6f4bba7e6eca5ead621c6222c9a8057e4bb2bb6f007d8bb3c`.
|
||||
Vendor archive and vendor closure hashes are unchanged. All three pinned archives
|
||||
passed fresh materialization and the new patched closure check:
|
||||
|
||||
| Platform | Patched closure SHA-256 |
|
||||
|---|---|
|
||||
| macOS ARM64 | `912a37edb67fd7737809c24049dce2db20b1330f98b15a6e43809f0e0943cb6c` |
|
||||
| macOS x64 | `322f54c7bd533b202a311ce8e2c8916bfe0202790ef8a40fbb36bb89cbd506cb` |
|
||||
| Linux x64 | `a375fc771dfe86b9b24532f95956ec4b02c5f4f24d80f5db06565160eb3b672c` |
|
||||
|
||||
`qualify-cursor-instructions.mjs`, called by the existing patch qualifier, executes
|
||||
all three digest-verified native resource constructors and bounded payload logic.
|
||||
On ARM64 it additionally executes the exact patched new/load methods and native
|
||||
cache/RequestContext construction with explicit dependency doubles, proving the
|
||||
composed bytes and native ordering at that boundary. This is an offline source
|
||||
execution proof, not a claim that an authenticated model obeyed the instructions.
|
||||
[The retained proof](../../packages/paperclip-runner/test/fixtures/cursor-acp/instructions-v4-offline-proof.json)
|
||||
and `runtime-patch-offline-proof.json` record the limits.
|
||||
|
||||
Nine new deterministic tests cover exact UTF-8 and empty instructions, ambient
|
||||
injection exclusion, recovery refresh, request/session/connection authority,
|
||||
actual SDK new/load admission, and actual runtime automatic reload after provider
|
||||
death. Valid acknowledgements permit the prompt; missing or wrong digests fail
|
||||
before prompt bytes. Another 113 adapter/sandbox/identity tests, 25 fresh isolated
|
||||
ACPX contracts, seven materializer cases, and the runner TypeScript compile pass.
|
||||
No paid calls or billing mutations were made. A new final-source provider pack
|
||||
and daemon, authenticated instruction semantics, and the remaining live gates
|
||||
are still required before qualification.
|
||||
|
||||
## Cursor profile v5: admitted native session modes (2026-09-29)
|
||||
|
||||
The explicit `acpxSessionMode` configuration selects `agent` (the default),
|
||||
`plan`, or `ask` for Cursor. Historical v5 qualification snapshots used
|
||||
`cursorMode`; those saved results retain their original identities. The current
|
||||
production port uses the generic provider/sidecar `mode` identifier, separate from ACPX's persistent/oneshot session lifecycle. Shared
|
||||
TypeScript/Rust transport and recovery code treat it as an opaque bounded string.
|
||||
Each provider adapter owns supported values, defaults, native translation and
|
||||
acknowledgement; currently only the Cursor adapter qualifies configurable modes.
|
||||
Mode is included in the immutable session key and recovery identity;
|
||||
a missing or changed mode cannot reopen an existing v5 session.
|
||||
|
||||
Admission checks the pinned native `session/new` or `session/load` response's
|
||||
mode configuration and mode state. If necessary, it sets the selected mode using
|
||||
`session/set_config_option` and requires its exact echoed configuration before
|
||||
admitting a prompt. A bare `session/set_mode` response or asynchronous
|
||||
`current_mode_update` alone is not acknowledgement. Every new native connection
|
||||
gets a fresh guard: a mismatched automatic reload blocks prompt delivery, and an
|
||||
unsolicited active mode change fails closed. Explicit startup/reopen admission
|
||||
can reassert the selected mode within the existing admission deadline and renews
|
||||
the consumed launch lease after each temporary control connection.
|
||||
|
||||
The pinned CLI maps Agent to native `default`, Plan to `plan`, and Ask to
|
||||
`search`, and carries that metadata into the native UserMessage mode. The
|
||||
inspectable `mode-v5-offline-proof.json` executes the pinned native methods with
|
||||
storage, transport, and enum-converter doubles. Real ACPX fixture tests separately
|
||||
prove cold-manager load/control ordering and automatic reconnect rejection before
|
||||
prompt bytes. Neither is paid model/tool qualification.
|
||||
|
||||
Native mode does not change Paperclip permission policy, company governance, or
|
||||
filesystem isolation. The native descriptions say Plan is read-only planning and
|
||||
Ask has no edits or command execution; these descriptions are not an OS sandbox
|
||||
claim. Native ACP CreatePlan acceptance does not itself mutate session mode in
|
||||
the pinned handler. An approved plan therefore stays in the selected Plan mode;
|
||||
there is no implicit promotion to Agent mode. Actual native question/plan callback
|
||||
availability, Plan-mode governed completion, and accepted/revised artifact flows
|
||||
remain pending Product qualification. Existing v4 paid results and macOS x64
|
||||
Rosetta build evidence remain historical and do not qualify v5.
|
||||
|
||||
[The v5 declaration](../../packages/paperclip-runner/test/fixtures/cursor-acp/profile-v5-identity.json)
|
||||
binds digest `sha256:aa8c0b2b84786982bcd06b7634bf95be6f2bc42bb8def48a8751dc50cf739b6b`.
|
||||
Profile v5 retains the native distribution closures and instruction patch. Its
|
||||
canonical declaration adds the admitted mode policy and binds ACPX patch
|
||||
`79aad2d688b03362e8cfcbf7a08f78a8383869f6882a9c9ed66f1d18efb94f2b`, which preserves
|
||||
identified empty rich-input chunks at the native parser boundary.
|
||||
|
||||
## Accepted native plans wait for explicit continuation (2026-09-30)
|
||||
|
||||
With Cursor CLI `2026.09.26-dd393fe`, accepting native CreatePlan can finish the
|
||||
planning turn normally without a Paperclip semantic completion result. The
|
||||
controller recognizes this boundary only from the admitted Plan-mode run,
|
||||
company/task scope, exact native plan request and accepted revision, acknowledged
|
||||
human response delivery, and normal terminal event. An explicit semantic result
|
||||
still takes precedence.
|
||||
|
||||
The planning run succeeds while the task stays `in_progress`. Its durable comment
|
||||
says: “Plan accepted. This task is waiting for your next message. This run used
|
||||
Plan mode; no implementation or task completion is claimed.” Acceptance neither
|
||||
changes the mode nor schedules implementation or an automatic follow-up. A user
|
||||
can send a new task message to continue; that message follows ordinary admission
|
||||
with the settings selected for the new run.
|
||||
|
||||
The committed wait survives controller restart, agent pause, changes to settings
|
||||
for future runs, and later profile-catalog revisions. Those changes are not
|
||||
permission to start task work. New waits must match the current qualified
|
||||
profile; recovery retains an older committed wait only while its original
|
||||
admission, contract, native request/answer/terminal proof, accepted-result
|
||||
identity, assignment, and applied task decision remain unchanged. A new user
|
||||
message or superseding task decision/run ends that wait's authority. Altering the
|
||||
original proof does not receive the historical-profile exception.
|
||||
|
||||
Deterministic tests cover real result persistence and transactional finalization,
|
||||
stale delivery rejection, restart/recovery without automatic wakes, catalog and
|
||||
future-setting changes, ordinary user continuation, and semantic-finish priority.
|
||||
The Product fixture preserves its native decision and workspace no-effect checks
|
||||
and now expects a successful planning run with an unfinished task and visible
|
||||
next-message guidance. Paid requalification of this controller behavior is still
|
||||
pending; the retained earlier Product failure is not reclassified as a pass.
|
||||
Runner, sidecar, provider distribution, profile, and image bytes are unchanged by
|
||||
this controller settlement change.
|
||||
|
||||
|
||||
### Cursor7 native plan lifecycle binding
|
||||
|
||||
The retained ca702 Cursor6 local plan attempt delivered both native decisions,
|
||||
including the revised plan's acceptance, but failed controller settlement. The
|
||||
provider emitted the accepted `CreatePlan` tool's activity after the callback
|
||||
request had been created. The earlier proof treated that activity as unrelated
|
||||
work. Workspace snapshots remained unchanged; that failed attempt is preserved.
|
||||
|
||||
Cursor7 binds the native callback's parent tool identity to the canonical
|
||||
`runtime_request` item identity through both the sidecar and direct driver. The
|
||||
controller requires exactly one successful lifecycle for that same tool, in the
|
||||
same session and turn, completing after the accepted answer and before the normal
|
||||
turn terminal. It rejects unrelated activity during this interval, failed or
|
||||
missing lifecycle records, and altered durable proof. Tool names and plan titles
|
||||
are not identity evidence. The committed receipt includes the correlated rows'
|
||||
digest. Existing exact Cursor6 committed waits retain their historical contract;
|
||||
Cursor6 cannot create a new wait or reopen under Cursor7 admission.
|
||||
|
||||
Focused deterministic checks cover the actual observed event order, identity
|
||||
normalization boundaries, cancellation/expiry reconstruction, unrelated tools,
|
||||
and persisted historical waits and proof tampering. Current runtime/sidecar/pack builds are complete; Cursor7 paid qualification
|
||||
remains blocked by the failed settlement case in the checkpoint above. This changes the
|
||||
runtime projection and profile contract, while the pinned Cursor native
|
||||
executable and distribution patch stay unchanged.
|
||||
|
||||
### Runner Eval diagnostic retention
|
||||
|
||||
The live-session recorder admits the bounded `cursor_native_usage_observed`
|
||||
notice only for the current Cursor session and turn. It validates the fixed
|
||||
provenance/reasons and generated observation identities, rejects unknown or
|
||||
oversized fields, and preserves parent/child observations through durable eval
|
||||
checkpoint reload. These unsummed, partial counters never enter the usage ledger
|
||||
or establish native USD. Deterministic transport/store tests cover this capture
|
||||
path; paid qualification remains pending. Earlier artifacts produced by the old
|
||||
recorder cannot establish whether these native counters were emitted and must
|
||||
retain their original accounting failure.
|
||||
@@ -41,6 +41,12 @@ standard/Ask tasks early, while preserving user-supplied titles. Its oracle
|
||||
correlates browser creation, native tool receipts, durable titles, audit ownership,
|
||||
and the reloaded task UI; fixture prompts contain no naming instructions.
|
||||
|
||||
The explicit-only [native connection guidance suite](../tests/runner-e2e/README.md#native-connection-guidance-explicit-only)
|
||||
adds neutral decline prompts, same-task run-attributed explanations, and measured
|
||||
no-use controls across three native local profiles. Its fifteen configured cells
|
||||
are preparation for future matched instruction comparisons, not a live result.
|
||||
Historical Everyday cases and production prompts are preserved.
|
||||
|
||||
## Selecting a family
|
||||
|
||||
Use **Runner Evals** for a runner protocol, adapter, transport, native session,
|
||||
|
||||
@@ -78,6 +78,24 @@ Entering `blocked` requires a routable waiting path. An issue may transition int
|
||||
|
||||
When a structured unblock descriptor is the waiting path, Paperclip immediately notifies the named owner: an agent owner gets a wake, a user or board owner gets an inbox notification. Prose-only blocked — free-text that names an owner or action in a comment without any of the paths above — routes to nobody. It is rejected at the API or auto-classified as `needs_attention` with a board notification, never silently accepted as a healthy waiting state.
|
||||
|
||||
Ordinary task questions are current input waits only while they still belong to
|
||||
the current human direction. A newer non-deleted human message on the same task
|
||||
makes an earlier ordinary question historical, without answering, cancelling,
|
||||
or accepting it. Agent-authored, run-attributed, derived-agent, and untrusted
|
||||
comments do not establish that direction. The server uses the same question
|
||||
classification in task context, completion feedback, governed waits, and native
|
||||
status finalization. Browser dismissal remains a local presentation preference.
|
||||
|
||||
A historical question stays in the feed and remains answerable after completion.
|
||||
A later authorized human answer updates history without reopening or resuming
|
||||
work. Cancellation still expires the question. Its pending
|
||||
state alone does not block completion or require another reminder. Agents must
|
||||
continue authorized work that does not need the missing information, withdraw
|
||||
obsolete questions when evidence satisfies them, and name any input that still
|
||||
prevents the current work. New current questions still define a waiting path.
|
||||
Approvals, governed tool/credential/connection requests, and configured review
|
||||
stages retain their gates; a later message does not grant approval.
|
||||
|
||||
A permission denial is not, by itself, a blocker. If an instructed step is denied at an authorization boundary but the issue's own deliverable is complete, the right disposition is `done`, not `blocked` (see the review-delegation rules in §6).
|
||||
|
||||
This requirement is prospective-only on rollout: it applies to transitions into `blocked` made after the feature ships, gated on the blocked-transition timestamp against the rollout marker, not on issue `createdAt`. Issues already blocked at upgrade time are untouched — no backfilled notifications, no retroactive validation, no `needs_attention` storm on deploy. Triage of pre-existing prose-blocked issues is a one-time opt-in digest, not a default.
|
||||
|
||||
@@ -0,0 +1,972 @@
|
||||
# Cursor production readiness — 2026-10-03
|
||||
|
||||
The release scope is Cursor CLI `2026.09.26-dd393fe` on macOS ARM64, macOS x64,
|
||||
and Linux x64 (including Daytona). Qualification uses the explicitly selected
|
||||
`gpt-5.6-luna[context=272k,reasoning=medium,fast=false]`. Native AskQuestion and
|
||||
authoritative per-run USD accounting are excluded from certification. Semantic
|
||||
Paperclip questions remain the supported question path. Unknown usage is unknown.
|
||||
|
||||
The proposed release supports authenticated Paperclip tools, semantic questions,
|
||||
native plan decisions, permission control, rich activity, file delivery, durable
|
||||
responses, cancellation and warm continuation. The matrix below records the
|
||||
completed local and fresh Daytona proof for those capabilities.
|
||||
Accepted planning succeeds while the task waits for explicit user direction.
|
||||
Acceptance does not start implementation.
|
||||
|
||||
Install with `paperclipai runtime setup cursor`. Configure company secret bindings
|
||||
for `CURSOR_API_KEY` or `CURSOR_AUTH_TOKEN` and select the model explicitly.
|
||||
Agent is the default mode; Plan and Ask are explicit alternatives. Missing assets,
|
||||
credentials, model availability and entitlement are diagnosed without substituting
|
||||
a model. A new managed-login experience is outside this release.
|
||||
|
||||
Image-input delivery, detailed native diffs and deeper child transcripts remain
|
||||
follow-ups. Partial counters are diagnostics; they do not establish measured
|
||||
spend or enforceable per-run dollar accounting. Native AskQuestion is implemented
|
||||
defensively but is not advertised or certified. See the
|
||||
[Cursor capability contract](../architecture/runner-cursor-capabilities.md).
|
||||
|
||||
## Bounded declaration cleanup — 2026-10-06
|
||||
|
||||
The PR audit found duplicated release tuples in Rust descriptor validation,
|
||||
duplicated model rules in Rust session validation, and independent pins in the
|
||||
TypeScript profile registry, provider-pack builder, server pack verifier, and
|
||||
Cursor installation code. `packages/paperclip-runner/acpx-profiles.json` now owns
|
||||
the release/profile declarations; generated TypeScript and named Rust structs
|
||||
share them. Following review, all ACPX harnesses now accept any explicit model ID;
|
||||
the native provider must acknowledge that exact selection before prompting.
|
||||
The release manifest and generated runtime declarations contain no model names
|
||||
or model allowlists. Qualification choices live only in evaluation fixtures.
|
||||
Server admission calls the same resolver. Existing Claude/Grok product defaults
|
||||
are named separately; Cursor and Codex ACP still require explicit selection.
|
||||
Cursor closure pins are generated from the existing distribution manifest.
|
||||
Build and typecheck reject declaration drift, including mismatches with the
|
||||
Cursor release attestation and installed dependencies.
|
||||
|
||||
The former Codex ACP and Pi single-model restrictions are removed, including
|
||||
the redundant restriction in the developer test-drive UI and server. Provider
|
||||
rejections and effective-model mismatches still fail without a fallback. Pi and
|
||||
Copilot remain pending providers; their operator-scoped qualification gates are
|
||||
unchanged. This change does not claim live qualification of additional models.
|
||||
|
||||
All provider versions, digests, and recovery identity fields retain their previous
|
||||
meaning. A regression check pins the pre-cleanup
|
||||
Cursor recovery digest. Historical rejection fixtures and evidence keep their
|
||||
original identities. Cursor launch flags, credential names, and native extension
|
||||
translation remain provider-adapter responsibilities; generic mode, lifecycle,
|
||||
permission, and transport contracts retain the earlier cleanup.
|
||||
|
||||
This is a source cleanup after the live qualification recorded below. Its checks
|
||||
do not relabel the earlier package/image as a build of this source, and no further
|
||||
paid qualification or release is implied.
|
||||
|
||||
## Final installed-release qualification — 2026-10-06
|
||||
|
||||
This qualification follows the generic mode and provider lifecycle cleanup. Its
|
||||
frozen application source is `d7b696f9b8f79095233e9e3d56d23e6a6018dd48`.
|
||||
The public controller packages use source `3d0c9b7761c61eb56628ea1778b1f3ca50fe817f`;
|
||||
that source adds only a fixture correction. Fixture commits through `646795e8f` change qualification
|
||||
fixtures and documentation, with no changes to `cli/src`, `server/src`, `ui/src`
|
||||
or `packages` relative to the frozen application. Historical results below retain
|
||||
their original source and artifact identities.
|
||||
|
||||
The tested public version is `0.0.0-cursor-verify.3d0c9b7761c6`. Cursor CLI
|
||||
`2026.09.26-dd393fe`, profile v11, patch `paperclip-cursor-usage-v4`, and explicit
|
||||
model `gpt-5.6-luna[context=272k,reasoning=medium,fast=false]` stayed fixed.
|
||||
The native distribution/profile digest is
|
||||
`sha256:2feb50c7b0a317dff454c00115a5bbe4d5c757189691586577be9c80234d477e`.
|
||||
The resolved build-lock SHA-256 is
|
||||
`89e5164aa79945655d253f30540c86a620e9df434299af4f1325727dbf5edf47`.
|
||||
The tracked lockfile was not changed.
|
||||
|
||||
| Packaged artifact | SHA-256 |
|
||||
| --- | --- |
|
||||
| macOS ARM64 daemon | `816c6603b4ef05ab29cc22a8b6cae9989469a8233bdedc3c97aae22a0c1c6abc` |
|
||||
| macOS x64 daemon | `372524b322f1200f1708288d0497195477ecf984e21dd32f3ddc2f2038465199` |
|
||||
| Linux x64 daemon | `d5a22554c922087f122ceeb0e7527529221c41af171b37315469c838dede0288` |
|
||||
| Public `paperclipai` tarball | `14a96c7de0c20cde231782f54ce0df76c1d6a81512f0c5b38a08ec8e8786631a` |
|
||||
| Public server tarball | `6e66d37c3ee4d61b66bbc5f2515550a246d8afc8a10b5425c064f5ec3f661d66` |
|
||||
| Public Daytona plugin tarball | `a6cf4e9070503757be0505d6a1a8df219dca493f801831b3cc1ca83a185d07db` |
|
||||
|
||||
The immutable qualification image is
|
||||
`ghcr.io/paperclipai/paperclip-daytona-runner@sha256:d6259b6bba094702c13fc2283bd85550849c1c53145b656fb2746778f9fa1747`.
|
||||
Its Linux provider-pack digest is
|
||||
`sha256:1916578945ba709e00904688792e50bedf505d45d69cc2023a4c95235c86cb18`.
|
||||
The complete extracted pack passed verification. Anonymous registry inspection
|
||||
confirmed the published OCI index and amd64 image. This publication does not
|
||||
publish an npm release or deploy production.
|
||||
|
||||
Clean public-package installation passed on macOS ARM64, macOS x64 through
|
||||
Rosetta, and Linux x64. Normal npm lifecycle scripts ran; Cursor remained absent
|
||||
until explicit `paperclipai runtime setup cursor`. Setup verified the pinned
|
||||
closure, and normal resolution launched each platform's packaged daemon without
|
||||
candidate or asset overrides. The x64 Mac proof covers installation and daemon
|
||||
startup under Rosetta, not a separate native Intel live campaign. The public
|
||||
Daytona plugin was built, packed and installed with its own dependencies. Its
|
||||
version matches the controller packages. Live cells use that installed CLI and
|
||||
plugin, company secret bindings, ordinary production admission, and the immutable
|
||||
image. Repository loaders and qualification overrides are absent.
|
||||
|
||||
### Post-qualification mainline reconciliation
|
||||
|
||||
Mainline `16b7db35ffa0f9a95913c8cbdeea3d595435691f` was merged after the
|
||||
frozen campaign. The only conflicts were the fixture catalog count (now 504)
|
||||
and flow dispatch in `tests/runner-e2e/runner.spec.ts`; both retain the Cursor
|
||||
coverage and mainline's plan-task cases. The native selected-response consistency
|
||||
wait remains intact. This reconciliation does not rebuild or relabel the tested
|
||||
artifacts. Final PR checks validate the combined source separately. Release the
|
||||
qualified package/image pair above; any later package or image needs release
|
||||
verification against its own identity.
|
||||
|
||||
The first CI run of the merged source lost five workers to simultaneous runner
|
||||
shutdowns. A single infrastructure repeat recovered four jobs; the remaining
|
||||
server shard hit a 10-second override in the legacy Cursor sandbox command test.
|
||||
That unchanged file passed all five cases in a focused local run. The test now
|
||||
inherits the server suite's existing 15-second limit, as its adjacent explicit
|
||||
command case already does. Assertions and production code are unchanged. The
|
||||
shutdowns and timeout remain recorded; they are separate from the live campaign.
|
||||
|
||||
### Final live acceptance matrix
|
||||
|
||||
All 18 required cells passed, including cleanup: nine local and nine Daytona.
|
||||
These are real Cursor sessions through the installed product. Each cell used
|
||||
the same frozen runtime, explicit model and normal company credential path.
|
||||
There were 23 attempts; the five failed attempts remain recorded below.
|
||||
|
||||
Campaign IDs have prefix `cursor-v11-final-d7-installed-`, followed by
|
||||
`<environment>-<case>-<attempt>`. The table gives the passing attempt number.
|
||||
|
||||
| Case | Local | Daytona | Cleanup |
|
||||
| --- | --- | --- | --- |
|
||||
| `hello-complete` | Pass (02) | Pass (01) | Both passed |
|
||||
| `file-edit-validate` | Pass (01) | Pass (01) | Both passed |
|
||||
| `structured-question-restart-resume` | Pass (01) | Pass (01) | Both passed |
|
||||
| `native-plan-reject-revise-accept` | Pass (01) | Pass (01) | Both passed |
|
||||
| `native-plan-cancel` | Pass (02) | Pass (01) | Both passed |
|
||||
| `native-write-deny-reconnect` | Pass (03) | Pass (01) | Both passed |
|
||||
| `pending-permission-stop` | Pass (01) | Pass (01) | Both passed |
|
||||
| `warm-three-turn` | Pass (01) | Pass (02) | Both passed |
|
||||
| `pending-permission-provider-loss` | Pass (01) | Pass (01) | Both passed |
|
||||
|
||||
Questions survived controller restart and consumed the answer once. Native plans
|
||||
verified rejection, revision, acceptance and cancellation; accepted planning runs
|
||||
succeeded while tasks remained open awaiting explicit direction. Denied writes
|
||||
stayed absent. Stop cancelled the real pending permission and retired owned
|
||||
processes. Warm continuation preserved three turns and selected persisted replies.
|
||||
Provider loss expired undeliverable input without automatic mutation replay.
|
||||
Changed file bytes, validation results and accessible artifacts were independently
|
||||
verified. Every selected result has complete evidence and no reported evidence
|
||||
leaks or missing evidence.
|
||||
|
||||
### Retained failures and bounded fixture repairs
|
||||
|
||||
Every paid attempt ran serially with automatic retries disabled. Four local attempts and one Daytona attempt remain failed; none is relabeled or removed:
|
||||
|
||||
- `local-hello-complete-01`: the old task-creation helper expected the removed
|
||||
title field. It failed before creating a task or starting a provider. The
|
||||
repaired helper uses the prompt-only composer and captures the real creation
|
||||
response ID. Its diagnosed repeat passed.
|
||||
- `local-native-plan-cancel-01`: native cancellation, exact decision delivery,
|
||||
terminal task/run state, workspace immutability, and cleanup passed. The model
|
||||
inserted `PLAN` into the requested exact summary marker. This remains a
|
||||
model/provider behavior failure. One repeat with the identical prompt and
|
||||
assertions passed; the repeat does not erase the original failure.
|
||||
- `local-native-write-deny-reconnect-01`: the provisional task title caused an
|
||||
earlier task-naming permission request. The strict fixture waited for its
|
||||
exact write command and timed out. Native permission fixtures now create an
|
||||
explicit title through Search's public creation action before starting the
|
||||
provider. No policy or provider prompt changed. The original full cleanup
|
||||
grade remains failed because denial proof was incomplete. Its complete process
|
||||
journal was empty, a later identity check confirmed all ten owned processes
|
||||
were gone, and the continuous watcher recorded no target mutation.
|
||||
- `local-native-write-deny-reconnect-02`: the exact write was denied and the task
|
||||
became Blocked, but the fixture expected the old Cursor-specific error text.
|
||||
The provider lifecycle cleanup correctly emits the provider-neutral diagnostic.
|
||||
The fixture now accepts that exact diagnostic and the exact historical one;
|
||||
all request, command, turn, terminal, no-replay and no-effect requirements stay
|
||||
in place. The original failed cleanup grade remains recorded. Its process
|
||||
journal was empty and its continuous watcher recorded no target mutation.
|
||||
The subsequent live repeat completed every required assertion and cleanup.
|
||||
|
||||
- `daytona-warm-three-turn-01`: all three runs and file updates completed, but
|
||||
the fixture stopped its response wait on an earlier deliverable-preparation
|
||||
comment. The final marker was persisted 53ms after the final run finished and
|
||||
is present with its selected-comment receipt in the retained fallback evidence.
|
||||
The original verdict remains failed and cleanup passed. The consistency wait
|
||||
now hydrates the native run detail and requires its actual selected comment;
|
||||
it cannot substitute a finish summary or a file-preparation message. The
|
||||
affected repeat passed with unchanged prompts and continuity assertions.
|
||||
|
||||
The shared browser helper also received a review fix for remembered project
|
||||
selection. Credential-free browser checks create repeated tasks, switch projects,
|
||||
and verify ordinary and explicit-title creation in standard, planning and ask work modes with
|
||||
paused agents. All three checks pass without provider runs. Fixture typecheck,
|
||||
1,627 Vitest checks (one skip), and 128 Node checks passed; the final diagnostic
|
||||
repair passed 150 focused native lifecycle checks. A failing provider-free search
|
||||
probe remains retained; the corrected probe scopes the exact phrase to tasks.
|
||||
|
||||
The existing seven Runner semantic passes and seven strict accounting failures
|
||||
remain historical results under their original build identities. This campaign
|
||||
is the focused 18-cell installed-release selection, not a rerun of the full
|
||||
catalog. Native AskQuestion and authoritative per-run dollar accounting remain
|
||||
excluded. Per-run USD remains unknown. The original $100 total budget is bounded
|
||||
by the existing $25 account-cycle cap plus retained commitments and infrastructure
|
||||
reservations. The conservative committed/reserved total is $88.080380624, leaving
|
||||
$11.919619376 unreserved; these are spending bounds, not measured provider spend.
|
||||
|
||||
<details>
|
||||
<summary>Exact result receipts for all 23 attempts</summary>
|
||||
|
||||
Each result lives under `tests/runner-e2e/results/<campaign>/<suite>/runner-acpx-cursor/<environment>/<case>/attempt-1/result.json`.
|
||||
The full campaign prefix is defined above. The SHA-256 identifies the original
|
||||
result file; later diagnoses do not rewrite its verdict.
|
||||
|
||||
| Campaign suffix | Verdict / cleanup | Result SHA-256 |
|
||||
| --- | --- | --- |
|
||||
| `local-hello-complete-01` | failed / passed | `59d8dc9194ee74fe1cd9827103f2734abb5e14d81232280d80ffdb5f6dede9e7` |
|
||||
| `local-hello-complete-02` | passed / passed | `e4c0839d417ee7de3d79d5c5898d7ad05fbe1c66103979cdea470a422ec1a0d2` |
|
||||
| `local-file-edit-validate-01` | passed / passed | `d45f3b2975c9ac359e969940b2209f47096cfdcebd3302c6ab42d1933e1df351` |
|
||||
| `local-structured-question-restart-resume-01` | passed / passed | `0ad49f9833236998b597a245de05ca7c05208865c0c5f50d944607b07242b2f9` |
|
||||
| `local-native-plan-reject-revise-accept-01` | passed / passed | `07d83d372061894812e6751679fa4f3dfb1754989aaa63a4ca6828578504ca91` |
|
||||
| `local-native-plan-cancel-01` | failed / passed | `dd22d0cfd3366fbebf041807401d16ff327e92b05e08521b220613df68fc2b1e` |
|
||||
| `local-native-plan-cancel-02` | passed / passed | `a4b06aefec7570af8f818e2a2705f100e10f2e8bd26fa5a500e25a6f7dc85c11` |
|
||||
| `local-native-write-deny-reconnect-01` | failed / failed | `aa27b5df8b1220dfa936934666c708f899d663d2b1c3a15faf19de3842af4c4d` |
|
||||
| `local-native-write-deny-reconnect-02` | failed / failed | `066dbfef92e151b87c31a62f6a8f825af0bbcab8d791b4c0aa448e30e90e3066` |
|
||||
| `local-native-write-deny-reconnect-03` | passed / passed | `33f8fedad38789d37874e7622e7bcb15227fa2412b8aa2b6ef919a03d3903988` |
|
||||
| `local-pending-permission-stop-01` | passed / passed | `b8101e2155f4b6bce481ee0cddb6b1c34af71dbe20798081d8821eef2dc4fbe2` |
|
||||
| `local-warm-three-turn-01` | passed / passed | `65c64e282ef0cb667c89b329c4b0f22104fd0d12c811aa450638bd093d04f2b5` |
|
||||
| `local-pending-permission-provider-loss-01` | passed / passed | `f8e7dc15fa051081f38f5a08292eeec7c83713964b9fb26304a5a9d2dd6edfb5` |
|
||||
| `daytona-hello-complete-01` | passed / passed | `787dd287c2d29350b9ad8e48fee73bdaecbdd059342bed44bc9304e5965bbe01` |
|
||||
| `daytona-file-edit-validate-01` | passed / passed | `bdb575c04162a1a24fc6354a2a284fb156665ea02de2e69be7c250bd211a2bf5` |
|
||||
| `daytona-structured-question-restart-resume-01` | passed / passed | `d72536f01147e8b7b77c1e82ed34c0faf0c99176700f7c101c95b0b5665d01df` |
|
||||
| `daytona-native-plan-reject-revise-accept-01` | passed / passed | `3cdb6ef262788f949d44d7d1b84c258d0494968949fb6c010c4496cc07442e5e` |
|
||||
| `daytona-native-plan-cancel-01` | passed / passed | `48190c0c3338c741aac319cd356dacfe1af011f8bdcea6fbf10d571fe6110211` |
|
||||
| `daytona-native-write-deny-reconnect-01` | passed / passed | `d89dd8794c5866c3612ab9a60f023dd136740a8525fd0c93feacc7bd0e17f90a` |
|
||||
| `daytona-pending-permission-stop-01` | passed / passed | `fc7aae403d32d1a587c4caf711bf9cba61cb7206f8c24bfac02175293874ce07` |
|
||||
| `daytona-warm-three-turn-01` | failed / passed | `528145330b803d21dc25937d3ef768c6dda3bb6583ba35030d69112e8cefbe1a` |
|
||||
| `daytona-warm-three-turn-02` | passed / passed | `e3e3c295714679ee5e7040f52ebeaf95e9f860bde1b5698fbd2566588a0e161a` |
|
||||
| `daytona-pending-permission-provider-loss-01` | passed / passed | `e9d15896ae5fe72e239b4fff8dd4fc8743eb309fef084ad46275e681671f7821` |
|
||||
|
||||
</details>
|
||||
|
||||
## Historical merge-readiness refresh — 2026-10-05
|
||||
|
||||
The branch incorporates mainline `72ff3a9f2` without replacing the qualified
|
||||
Cursor CLI, profile v11, patch or explicit model. Mainline's newer Codex, Claude
|
||||
and OpenCode pins remain intact. Its global sandbox Cursor CLI is separate from
|
||||
the native provider's verified 2026.09.26 distribution. Mainline completion
|
||||
feedback keeps current, company-scoped document links and blocker guidance;
|
||||
Cursor's explicit final-response format still takes precedence. Both regression
|
||||
suites are retained. The combined Runner fixture catalog contains 492 cases.
|
||||
|
||||
The combined-source recursive typecheck and full build passed. Completion
|
||||
feedback passed nine regression tests; native completion source/build contracts
|
||||
passed 128 tests; token gates passed. Runner fixtures passed 90 files and failed
|
||||
one cleanup timing test. An unchanged isolated repeat reproduced its 100ms race.
|
||||
The test now waits for the intended inspection failure before releasing its
|
||||
child; all 13 tests in that affected file passed. Both failed attempts remain
|
||||
recorded. This changes fixture synchronization, not production cleanup behavior.
|
||||
The full default suite and fresh final-head CI/review gates run before merge.
|
||||
|
||||
The resolved dependency lock for this combined source has SHA-256
|
||||
`d3c4cffe7d127d99789cf354c5275246526f5396d2a86ce0d387352f68a2394b`.
|
||||
The image build verifies that digest before installation. The repository lockfile
|
||||
matches mainline because Actions owns lockfile updates. The artifact and live
|
||||
qualification identities below remain historical; they are not relabeled as
|
||||
new builds. A later release must assemble and verify matching packages and images
|
||||
from its release source. Merging this PR does not publish or deploy that release.
|
||||
|
||||
An additional manual trial used the ordinary installed public package on macOS,
|
||||
the real local Cursor ACP executable and normal encrypted company credentials.
|
||||
Cursor generated a 1,777-byte counter page, validated it, registered its deliverable
|
||||
and finished the task through authenticated tools. The task and run reached
|
||||
`done` and `succeeded`. Independent download matched the workspace bytes and
|
||||
SHA-256 `0db0ddc7bcd33aed5fe7e74b2551f1c13403d3a6175239f544cab9a45ad0891e`.
|
||||
The page rendered in the browser and incremented from 0 to 1 to 2 without console
|
||||
errors. Native approval requests were approved through Paperclip. The reviewer
|
||||
did not create or edit the page. This trial uses the previously qualified public
|
||||
package; it is additional live proof, not a new certification identity.
|
||||
|
||||
## Historical qualified release candidate — before generic cleanup
|
||||
|
||||
Cursor admission is enabled in [PR #15075](https://github.com/paperclipai/paperclip/pull/15075).
|
||||
All ten local and ten fresh Daytona gates passed, including cleanup. The final
|
||||
ordinary installed-product local and Daytona smokes also passed. Pi and Copilot
|
||||
remain gated. Production merge and deployment are separate actions; the PR must
|
||||
have green final-head checks before merge.
|
||||
|
||||
The frozen native runtime is `d0b90756e3abe9e7516caded784036f01e76ad25`. Cursor
|
||||
profile v11, patch `paperclip-cursor-usage-v4`, CLI and exact Luna model remain
|
||||
fixed. The native command digest is
|
||||
`sha256:2feb50c7b0a317dff454c00115a5bbe4d5c757189691586577be9c80234d477e`;
|
||||
the ACPX patch SHA-256 is `bd5393058a218040d217fa85449d59a6f30507de54cd645bf0ef21422823f85e`.
|
||||
Promotion and matching provider packs use source
|
||||
`630ed8613191b6875ad448ecdfe067089f3696d9`. The public controller includes the
|
||||
installed-readiness repair at `2684979803dee739563eb6db9e6137c3755dd320`.
|
||||
All three source daemons and all three Cursor native closures match the qualified
|
||||
artifacts byte for byte. Release assembly re-signs copied macOS daemon inodes;
|
||||
the table below records the resulting packaged hashes. These source roles do not replace historical identities.
|
||||
|
||||
| Required case | Local proof | Fresh Daytona proof |
|
||||
| --- | --- | --- |
|
||||
| Completion through authenticated tools | `hello-complete-01`: pass, cleanup pass | `hello-complete-01`: pass, cleanup pass |
|
||||
| File edit, validation and registered download | `file-edit-validate-02`: pass, cleanup pass | `file-edit-validate-01`: pass, cleanup pass |
|
||||
| Semantic question after controller restart | `structured-question-restart-resume-01`: pass, cleanup pass | `structured-question-restart-resume-01`: pass, cleanup pass |
|
||||
| Semantic plan approval and completion | `plan-approve-complete-01`: pass, cleanup pass | `plan-approve-complete-02`: pass, cleanup pass |
|
||||
| Native reject, revise and accept | `native-plan-reject-revise-accept-01`: pass, cleanup pass | `native-plan-reject-revise-accept-01`: pass, cleanup pass |
|
||||
| Native plan cancellation | `native-plan-cancel-01`: pass, cleanup pass | `native-plan-cancel-02`: pass, cleanup pass |
|
||||
| Denied write after reconnect | `native-write-deny-reconnect-01`: pass, cleanup pass | `native-write-deny-reconnect-01`: pass, cleanup pass |
|
||||
| Stop during pending permission | `pending-permission-stop-01`: pass, cleanup pass | `pending-permission-stop-01`: pass, cleanup pass |
|
||||
| Three warm turns | `warm-three-turn-01`: pass, cleanup pass | `warm-three-turn-01`: pass, cleanup pass |
|
||||
| Pending permission followed by provider loss | `pending-permission-provider-loss-01`: pass, cleanup pass | `pending-permission-provider-loss-01`: pass, cleanup pass |
|
||||
|
||||
Local identities start `cursor-v11-d0b907-local-`; remote identities start
|
||||
`cursor-v11-d0b907-daytona-`. Results are under
|
||||
`tests/runner-e2e/results/<identity>/<suite>/runner-acpx-cursor/<environment>/<case>/attempt-1/result.json`.
|
||||
The local file repeat uses controller/fixture 473206; the other local cases use
|
||||
5e6c16. The remote semantic plan repeat and native decision case use 473206;
|
||||
remaining remote cases use AFE diagnostics. All use the frozen native runtime.
|
||||
Automatic retries are zero. The file gates independently download the registered,
|
||||
run-attributed attachment and verify its bytes, size and SHA-256.
|
||||
|
||||
All seven Runner semantic cases passed with owned processes retired:
|
||||
`get-task-context`, `context-before-action`, `create-task-document`, `finish-task`,
|
||||
`request-human-confirmation`, `workflow-context-document-progress`, and
|
||||
`workflow-governed-wait`. Their identities start `cursor-v11-d0b907-` and end `-01`.
|
||||
All seven strict scores remain `accounting_failure` for
|
||||
`provider_budget_coverage_unknown`; per-run USD is null. They are semantic passes
|
||||
and accounting failures. Native AskQuestion and complete accounting are excluded
|
||||
from certification.
|
||||
|
||||
### Exact final artifacts and ordinary installation
|
||||
|
||||
| Platform | Final provider-pack manifest digest | Packaged daemon SHA-256 |
|
||||
| --- | --- | --- |
|
||||
| macOS ARM64 | `40377a1641a434785109a12895af74dbde4ff978a510b126059039306fdd2e6a` | `33bb9276b4d79be33f77c89a76ab71946808583500c6200a144b324479fbd9d8` |
|
||||
| macOS x64 | `a7ba83592a05546ca0aa327b188d8c6991b32d764dc02565b78a8102e7ea76d1` | `1bcd1bdc015f15a8321c9564d008284fd105da4f019632ff81ba0c9f75ae28d7` |
|
||||
| Linux x64 | `6a32a7955c56f7eec26272cde996faa20e678146480ffb95bc909abe25fd0adb` | `34d1b96550669613e91b3df75752164609ddfbeec70ea821e540558f8a96ddb6` |
|
||||
|
||||
The final image is
|
||||
`ghcr.io/paperclipai/paperclip-daytona-runner@sha256:681b56d2e2fcbde12f6677fbd54c617bfcc66df43a02c267bb355d46e16e7caf`.
|
||||
Its source is 630ed. Publication returned this registry OCI index digest;
|
||||
independent registry inspection confirmed it. The image's provider-pack file
|
||||
SHA-256 is `723451cf01d4538693884bc37ece71ff31e64dfc2254e4b8fd5386bf23fb81da`.
|
||||
The complete extracted tree passed manifest verification before publication.
|
||||
The public server ships that exact manifest as its expected Linux image identity.
|
||||
A macOS controller uses its packaged Linux daemon. A mismatched image fails before
|
||||
provider launch; it does not stage borrowed assets or download a replacement.
|
||||
|
||||
The tested public package version is `0.0.0-cursor-verify.2684979803de`.
|
||||
CLI tarball SHA-256: `e67396bf8baa9d4e2d2298d1064a24f495730e84ac428016ab32554906ca25ad`.
|
||||
Server tarball SHA-256: `f3dbc5dd00a45bbd668fd23b36bebd069feaa54b2e6209d87618a7037f8583cc`.
|
||||
The separately installed public Daytona plugin uses the same test version;
|
||||
its tarball SHA-256 is `e991dfc10804c2ec08c9f0a7ed99b0b59761c2edc4d6fa4e1c8540e23b21ca61`.
|
||||
Its dependencies are isolated as in the product plugin installer.
|
||||
The complete resolved build lock SHA-256 is
|
||||
`70af8ab3d7051c85fc1a55c11e9afe8887d9711232e3c6e97666006562217e5f`.
|
||||
Repository policy leaves lockfile commits to Actions.
|
||||
|
||||
The public verifier passed for 18 packages on the isolated consumer image
|
||||
`node:24-trixie@sha256:be40f6a87b9b22215ddb20da0a2320a5c6d583fe3ee3b0024d9fa4f05b40c8fd`.
|
||||
It enabled npm lifecycle hooks, verified the lifecycle sentinel, proved Cursor
|
||||
was absent after npm installation, ran public pinned setup, verified all three
|
||||
daemon targets, launched the installed Linux daemon, and passed the installed
|
||||
Cursor configuration probe. Provider calls were zero. A fresh macOS consumer
|
||||
installed the same tarballs with normal lifecycle hooks and public setup; its
|
||||
installed configuration probe also passed without credentials.
|
||||
|
||||
| Final ordinary installed smoke | Fixture source | Outcome | Result SHA-256 |
|
||||
| --- | --- | --- | --- |
|
||||
| `cursor-v11-268497-installed-local-file-edit-validate-01` | `b9a0180c1a96cf0fd0448cc69d6f47a5de8d258e` | 8/8 matchers, cleanup pass | `26cdd34a8b7c3dc19f0afd3cf94c22e1700bad217d2af92877aeb10dbe1f58bc` |
|
||||
| `cursor-v11-268497-installed-daytona-hello-complete-02` | `327f6df095d38f0e2213cb4572376e01d4629946` | 6/6 matchers, cleanup pass | `7f2f23d1c5dec8e030866781cc09e06990d5149758c0d467df49e63366b29dd1` |
|
||||
|
||||
These launch the actual installed `paperclipai/dist/index.js` from its consumer
|
||||
root. The server removes qualification admission, native binary paths, provider
|
||||
pack overrides, repository executables and TypeScript loader injection. Credentials
|
||||
use company secret bindings. The Daytona plugin is a compiled public npm package
|
||||
installed through the normal plugin API. Image selection uses the normal
|
||||
environment configuration surface. Task results were reloaded and inspected.
|
||||
The local gate verified the registered download independently. Remote cleanup
|
||||
confirmed destruction of the owned environment lease.
|
||||
|
||||
### Retained failures, validation and budget
|
||||
|
||||
The qualification image was
|
||||
`ghcr.io/paperclipai/paperclip-daytona-runner@sha256:16c7be3610f45e409f67873dd4bd829f9a1e0e5f017c8826d01db4bb05720f97`.
|
||||
Its registry push and independent inspection confirmed that OCI index, with Linux
|
||||
amd64 manifest `sha256:c70f25e76503ce2d9cc1f87ed5a221f8e423f4a45f5519e8aa0581b033be353e`.
|
||||
Docker's local containerd store uses the same index identity; it is not an inferred
|
||||
registry digest. All twenty matrix cells retain the frozen artifact identities.
|
||||
|
||||
Remote semantic-plan attempt 01 failed before a provider call because seven empty
|
||||
directories were absent from the controller extraction. Every file byte matched
|
||||
the image; restoring its exact directory inventory passed the unchanged manifest.
|
||||
The failed result and successful cleanup remain recorded. Native cancellation
|
||||
attempt 01 lacked a complete terminal observer receipt and failed its cleanup
|
||||
gate. Independent deletion of its owned sandbox was confirmed. Diagnostic-only
|
||||
AFE fixture changes retained all proof requirements; attempt 02 passed with
|
||||
cleanup. This does not establish a product bug fix or erase the original failure.
|
||||
|
||||
The first installed remote smoke used the workspace plugin and failed before a
|
||||
run or sandbox allocation, with cleanup passed. The fixture now validates a public
|
||||
compiled plugin in its own installed dependency directory; the affected repeat
|
||||
passed. A separate credential-free public probe exposed a development-only Runner
|
||||
import in the installed configuration check. The production fix uses the vendored
|
||||
boundary, and the stronger public verifier and both installed smokes pass.
|
||||
The final fixture also checks the selected public plugin's release version and all
|
||||
compiled entries. A credential-free repeat against the exact installed plugin
|
||||
above passed; missing or mismatched version tests reject stale artifacts. This
|
||||
adds an admission check without changing the certified plugin or provider behavior.
|
||||
|
||||
Promotion checks passed: recursive typecheck, full build, token gates (1,129 files),
|
||||
Runner contract/replay checks, 164 focused Runner tests, executor/profile/runtime-mode coverage,
|
||||
56 readiness-probe tests, 5 vendored dependency tests, 29 installed/admission fixture
|
||||
tests, 56 plan-wait tests, fixture typecheck and 9 UI option tests. The new plan-wait
|
||||
case preserves valid v11 waits saved with pre-promotion admission metadata;
|
||||
incompatible profile identities remain fenced. Original failed test attempts are
|
||||
retained separately from their affected successful repeats. The latest default
|
||||
local command passed all general groups and 107 serialized route suites, then
|
||||
failed on a socket hang up in `issue-thread-interaction-routes.test.ts`. The
|
||||
isolated affected repeat and all remaining 43 suites passed with unchanged source,
|
||||
completing all 150 serialized suites. The failed command remains failed. No
|
||||
unrelated route repair was made. Final recursive typecheck passed again. Repeated
|
||||
assembly exposed a read-only manifest overwrite failure; assembly now replaces
|
||||
that manifest atomically, and the server copy can replace an existing read-only
|
||||
inode. Two isolated assemblies preserved all three packaged daemon hashes and the
|
||||
exact Linux manifest SHA-256/mode. The affected full build and its unchanged
|
||||
repeat passed. The PR's live
|
||||
checks show final-head CI; green checks are required before handoff. Historical
|
||||
verification below retains its original source and outcomes.
|
||||
|
||||
The user reconfirmed all remaining work on 2026-10-04 within the original $100 total
|
||||
ceiling. Prior committed upper bounds total $47.080380624; the remaining envelope
|
||||
is $52.919619376. Infrastructure reserves total $29, including diagnosed repeats,
|
||||
normal installed qualification and uncertain allocation. There are 26 recorded
|
||||
remote attempts and no active paid cells at this checkpoint. The existing $25
|
||||
Cursor account-cycle cap is counted once, not added again. Reservations are not
|
||||
measured spend. Per-run Cursor USD remains null.
|
||||
|
||||
## Source-to-port map
|
||||
|
||||
| Source | Destination / decision |
|
||||
| --- | --- |
|
||||
| Mainline `dd868ed125cd709506dd9b29fca640a44d580501` | Branch `codex/cursor-production-readiness`; preserve its recovery, completion, and managed warm-directory ownership |
|
||||
| Combined snapshot `22c78242a4e0c2369fecf0c2dc4e7600fbad6706` | Cursor installation, native isolation/instructions/modes, extensions, tool evidence and partial usage |
|
||||
| Same snapshot, shared ACP transport | Permission identity, delivery acknowledgement, cancellation, canonical tool lifecycle and recovery-mode binding |
|
||||
| Same snapshot, controller | Accepted-plan wait proof, status arbitration/commit/recovery, durable cancellation request ownership |
|
||||
| Same snapshot, Product E2E | Cursor native interactions, active Stop, warm continuity, remote observers and owned cleanup |
|
||||
| Mainline warm agent-files work | Retained instead of importing the older competing warm-copy implementation; extend its ACP applicability when qualified |
|
||||
| New public installation work | CLI `runtime setup cursor`, bundled provisioner and default provider-pack assets |
|
||||
| Pi/Copilot source and campaign | Excluded; existing pending providers retain mainline identities and admission gates |
|
||||
|
||||
Historical proofs retain their original profile/build identities. In particular,
|
||||
the v10 Stop result at source `22c78242` and the earlier plan/warm/Daytona completion
|
||||
results do not certify this assembled candidate. Strict accounting failures are
|
||||
preserved; semantic behavior is assessed separately.
|
||||
|
||||
## Readiness checklist
|
||||
|
||||
- [x] Create the branch from the agreed mainline base.
|
||||
- [x] Port Cursor and necessary shared implementation without replacing newer controller files wholesale.
|
||||
- [x] Complete targeted tests and make the consolidated branch buildable with admission disabled.
|
||||
- [x] Ship and verify explicit public runtime setup; npm lifecycle must not download Cursor.
|
||||
- [x] Include Cursor in ordinary provider-pack and Daytona image builds.
|
||||
- [x] Verify company secret bindings, exact model diagnostics and Agent/Plan/Ask configuration.
|
||||
- [x] Preserve successful accepted planning runs as open tasks awaiting explicit user direction.
|
||||
- [x] Show unavailable accounting explicitly and keep partial counters diagnostic-only.
|
||||
- [x] Freeze candidate source/profile/patch/pack/image identities and build all three platforms.
|
||||
- [x] Reconcile the remaining campaign budget; run paid cells serially within the existing account cap.
|
||||
- [x] Qualify normal setup/completion locally and on Daytona.
|
||||
- [x] Qualify file editing, independently checked bytes/validation and accessible artifacts on both targets.
|
||||
- [x] Qualify semantic questions/restart with exactly-once answer consumption on both targets.
|
||||
- [x] Qualify native plan reject/revise/accept/cancel and correct task/run states on both targets.
|
||||
- [x] Qualify denied writes and Stop during pending approval, including owned process retirement, on both targets.
|
||||
- [x] Qualify three warm turns with stable session/workspace/agent-files ownership and no duplicate output on both targets.
|
||||
- [x] Qualify provider loss, input expiry and actionable errors without mutation replay or false success.
|
||||
- [x] Run seven semantic Runner cases; retain strict accounting results separately.
|
||||
- [x] Promote Cursor after the complete local and Daytona matrix; leave other pending providers gated.
|
||||
- [x] Run contracts/replay, token gates, recursive typecheck, full tests and build; retain failed local attempts and verify affected repeats separately.
|
||||
- [x] Repeat clean normal-install local and Daytona smokes with qualification and runtime overrides absent.
|
||||
- [x] Deliver exact identities, capability limits and completed acceptance matrix; prepare focused template-based PR.
|
||||
|
||||
Production merge/deployment is a separate final action. Rollback disables new
|
||||
Cursor admission while preserving records, valid committed plan waits and recovery
|
||||
inspection.
|
||||
|
||||
## Consolidation verification
|
||||
|
||||
The assembled workspace build and recursive typecheck pass. Focused Cursor
|
||||
normalization/installation tests, controller settlement tests, CLI setup containment,
|
||||
and 19 native ACP backend tests pass. The 65 protocol/package contract checks pass.
|
||||
Full-suite failures remain retained for diagnosis; these narrow results are not a
|
||||
production certification. All three pinned Cursor distribution closures were
|
||||
materialized and verified afresh. macOS ARM64 ordinary provider-pack preparation
|
||||
passed without candidate flags.
|
||||
|
||||
The resolved campaign lockfile remains local because repository policy gives
|
||||
GitHub Actions ownership of lockfile commits. Its SHA-256 is
|
||||
`70af8ab3d7051c85fc1a55c11e9afe8887d9711232e3c6e97666006562217e5f`;
|
||||
retain these exact resolved bytes with candidate artifacts and pass their digest
|
||||
to the immutable image build.
|
||||
|
||||
## Qualification preparation and demonstrated repairs
|
||||
|
||||
Candidate `9ba53fced5de6dabd1e931b7438d9dd118e45f0f` passed local
|
||||
completion, semantic question continuation, and file-edit/validation Product E2E
|
||||
cells with the explicit Luna model, serial execution, no retries, and successful
|
||||
cleanup. Their original campaign identities remain unchanged. Measured per-run
|
||||
dollar usage is unavailable, not zero. The existing Cursor account-cycle cap is
|
||||
counted once against the reconciled campaign envelope.
|
||||
|
||||
Fresh ordinary provider packs were built for macOS ARM64 and x64; the Linux x64
|
||||
Daytona image also built successfully. These are preparation artifacts, not a
|
||||
completed release certification.
|
||||
|
||||
Public-package inspection found that setup derived the standalone runner layout
|
||||
when embedded in the server's vendored layout. The provisioner now selects its
|
||||
contained public asset root explicitly and rejects an unbundled source invocation.
|
||||
Three containment checks pass. `node scripts/verify-cursor-npm-install.mjs`
|
||||
packages the actual public CLI/server dependency graph and verifies installation,
|
||||
enabled npm hooks, explicit setup, and the installed Cursor execution closure in
|
||||
an isolated consumer. It retains evidence under its printed temporary directory
|
||||
and makes no model calls. npm hooks may fetch normal platform dependencies;
|
||||
Cursor provisioning must remain absent until the public setup command runs.
|
||||
|
||||
The new explicit-only `native-provider-loss` Product E2E suite covers the missing
|
||||
transport-loss gate locally and on Daytona. It loses only the observed per-turn
|
||||
run owner while a native mutation is awaiting permission, then requires a visible
|
||||
failed run, an unanswerable stale approval, an open task, no automatic replay,
|
||||
no changed target, and retirement of the owned process tree. The remote fault uses
|
||||
a Linux pidfd bound to the retained start ticks and boot ID.
|
||||
|
||||
The initial full test run retained resource/startup failures. Targeted reruns
|
||||
passed 43 boundary/file-handoff checks, 196 real-runner checks, and 1,742 of 1,744
|
||||
remaining server checks. The two remaining assertions compare macOS `/var` aliases
|
||||
against canonical `/private/var` paths; no unrelated test repair is ported.
|
||||
|
||||
## Historical candidate and qualification checkpoint
|
||||
|
||||
The runtime candidate is `ccae835581923876ad5ac0ef12bf763e54958db9`,
|
||||
rebased onto mainline `569c7203aa24b95440682983ce7940ba1d4247bd`.
|
||||
Commit `d3dd596c77a9032da639201f1b05dc6891479e68` changes only verification
|
||||
fixtures: public-install probing and the provider-loss oracle/admission. It does
|
||||
not change the candidate runtime. Product results retain their runtime source and
|
||||
catalog fingerprints; the verification commit is an additional harness identity.
|
||||
|
||||
Cursor profile v11 binds command digest
|
||||
`sha256:2feb50c7b0a317dff454c00115a5bbe4d5c757189691586577be9c80234d477e`.
|
||||
The native patch remains `paperclip-cursor-usage-v4`.
|
||||
The ordinary macOS ARM64 pack digest is
|
||||
`sha256:7443adb3ab1d2fbd7081532923bcc6eaf8d9f511aff22fac6836c647ac6a3c8e`.
|
||||
Both macOS targets were built with official standalone Node 24.21.0; the x64
|
||||
daemon also executes under Rosetta. Linux image preparation retains its own
|
||||
manifest identity; the remotely pulled digest must be recorded before a live cell.
|
||||
|
||||
| Required behavior | Local candidate result | Daytona candidate result |
|
||||
| --- | --- | --- |
|
||||
| Ordinary installation and completion | Public setup/closure verified; final full verifier and normal product smoke pending | Pending |
|
||||
| File editing, validation, accessible artifacts | Earlier `9ba53f` preflight passed; assembled-candidate repeat pending | Pending |
|
||||
| Semantic question with controller restart | Passed `structured-question-restart-resume-01` on `ccae835` | Pending |
|
||||
| Semantic plan acceptance | Passed `plan-approve-complete-01` on `ccae835` | Pending |
|
||||
| Native reject, revise, accept | Passed `native-plan-reject-revise-accept-01` on `ccae835` | Pending |
|
||||
| Native plan cancellation | First attempt failed during fixture migration/startup before Cursor ran; affected repeat pending | Pending |
|
||||
| Denied write and pending-permission Stop | Pending | Pending |
|
||||
| Three warm turns | Pending | Pending |
|
||||
| Owned provider loss with pending permission | Passed `pending-permission-provider-loss-03` with clean retirement, stale-answer refusal, blocked open task, failed run, and no mutation | Pending |
|
||||
|
||||
All attempts are serial and have zero automatic retries. The original campaign
|
||||
envelope has $52.919619376 remaining after its prior committed upper bound.
|
||||
The existing $25 Cursor account-cycle cap is counted once; per-run USD is null.
|
||||
Remote runtime estimates and reservations remain separate from missing model spend.
|
||||
|
||||
The seven authored Runner cases remain byte-identical to eval revision
|
||||
`08ae9d4a231e52fc54af0821564cded3d3ec7f37`. A fingerprinted diagnostic
|
||||
overlay binds the v11 profile and adds a closed projection of durable delivery
|
||||
receipts for failure diagnosis. The strict accounting grader remains unchanged.
|
||||
Successful semantic checks do not make an accounting-failure score green.
|
||||
|
||||
The canonical-temporary-path full local test rerun accumulated startup, filesystem,
|
||||
and timing failures under host contention. It was interrupted before completion;
|
||||
the partial log is retained. Earlier recursive typecheck, build, contracts/replay,
|
||||
token gates, and focused runtime checks passed. Full CI and the complete acceptance
|
||||
matrix are required before promotion. Cursor production admission remains disabled.
|
||||
|
||||
## Review blockers and mainline integration
|
||||
|
||||
The branch now preserves mainline `2a8a99e4a`, including stock-harness cleanup
|
||||
checks and the updated skill semantic contract. Earlier candidate results retain
|
||||
their original source identities; this rebase requires fresh artifact identities
|
||||
and affected qualification.
|
||||
|
||||
Two production blockers found by review were repaired. Unsupported Cursor targets
|
||||
omit the provider field rather than hashing an undefined field that disappears
|
||||
when the manifest is written. Disk JSON round-trip digest checks cover all three
|
||||
supported targets, Linux ARM64, and Windows x64.
|
||||
|
||||
Accepted-plan proof reads now budget 1,000 control events and 20,000 tool-progress
|
||||
events separately, retaining every event hash and the terminal event. A single
|
||||
bounded read includes an overflow row and fails closed beyond either budget.
|
||||
Historical unbound committed proofs retain their original event selection and
|
||||
1,000-row limit. Long-plan tests also reject foreign tools, changed sessions,
|
||||
tampered digests, and progress after completion; no later work is hidden by
|
||||
removing progress from the proof.
|
||||
|
||||
The first new denied-write attempt retained a native permission for a different
|
||||
command: Cursor escaped the literal's underscores. The original target stayed
|
||||
absent and the observed process tree retired, but the exact command/denial gate
|
||||
correctly failed. The fixture now uses a hyphenated literal while retaining exact
|
||||
command digest, tool, request, run, and turn checks. This changes the fixture
|
||||
identity and does not qualify the failed attempt retroactively.
|
||||
|
||||
CI found an imported editable-default question test without its corresponding
|
||||
contract. Editable defaults are outside this Cursor release and their partial
|
||||
import was removed instead of extending all question surfaces. Cursor question
|
||||
handling and plans remain intact. Configuration rejects unavailable Pi/Copilot selections explicitly, preserving
|
||||
the selected provider and model rather than substituting Claude. Cursor retains
|
||||
its explicit model and mode selection.
|
||||
|
||||
At source `ae24f0981221dc0b5694120f9bb46ff6c8859e07`, all CI build,
|
||||
typecheck, Rust, unit, and browser jobs passed. Automated review found a remaining
|
||||
provider fallback and a build-timeout test reading an excluded Pi file. Both are
|
||||
repaired with focused coverage; no Pi implementation was added.
|
||||
|
||||
The `cursor-v11-ae24f0-local-native-write-deny-reconnect-01` attempt delivered
|
||||
the exact Reject once, observed no target mutation, and retired the owned process
|
||||
tree. Its original result remains failed: the fixture incorrectly required a
|
||||
failed native tool and a subsequent Stop. Cursor reported transport completion
|
||||
and ended the turn; Paperclip correctly failed missing semantic finalization and
|
||||
kept the task unfinished. The repaired oracle preserves exact request/tool/turn/
|
||||
command identity, delivered rejection, all six independent samples, a complete
|
||||
continuous watcher, and actual process retirement. It additionally requires the
|
||||
correlated terminal and the failed semantic finalization. It does not certify
|
||||
operator cancellation; pending-permission Stop remains a separate required gate.
|
||||
The affected live denial repeat passed on the frozen candidate below.
|
||||
|
||||
### Frozen v11 checkpoint: `5623ff9505a8284301dd5cbd20e07f3c0596355f`
|
||||
|
||||
All ten required local Product E2E cells passed with cleanup, including exact
|
||||
write denial, pending-permission Stop, warm three-turn continuation, questions
|
||||
after controller restart, native plan revision/acceptance/cancellation, and owned
|
||||
provider loss. The denial's first attempt failed during PostgreSQL bootstrap;
|
||||
its second attempt passed. Other local cells passed on their first attempt.
|
||||
|
||||
All seven authored Runner semantic cases passed with independent owned-process
|
||||
retirement. Strict accounting failed all seven cases with
|
||||
`provider_budget_coverage_unknown`; per-run USD remains unavailable. Definitions
|
||||
retain provenance to eval revision `08ae9d4a231e52fc54af0821564cded3d3ec7f37`.
|
||||
Latest-head CI completed with 53 successful and four skipped checks. Greptile
|
||||
reported 5/5. An unchanged chat timing failure was diagnosed and rerun once.
|
||||
|
||||
The actual Linux image is
|
||||
`ghcr.io/paperclipai/paperclip-daytona-runner@sha256:b5d4a95d7b4c2291a3a133afdf475846569e16588e4ae9f4bb6fde326756946c`.
|
||||
Its entire extracted provider pack was verified, and anonymous registry access
|
||||
was confirmed. All three platform packs bind source `5623ff` and Cursor v11.
|
||||
|
||||
The first Daytona attempt failed before remote allocation during local database
|
||||
bootstrap. The second timed out in sandbox allocation before Cursor started;
|
||||
its original cleanup failure remains recorded. Subsequent ownership-filtered
|
||||
inspection found no sandbox for that run. A bounded infrastructure-only probe
|
||||
started this exact image in under one second and confirmed deletion of its
|
||||
verified allocation. That establishes current allocation health, without
|
||||
retroactively qualifying the failed attempt or establishing its cleanup receipt.
|
||||
|
||||
The harness now retains its owner-only private recovery database when process
|
||||
or remote cleanup is unconfirmed. This repairs the demonstrated loss of the
|
||||
failed-create journal after the controller exited. It changes the harness only;
|
||||
the provider packs, daemon, and Linux image remain frozen at `5623ff`. Remote
|
||||
qualification and promotion are still pending.
|
||||
|
||||
### Demonstrated remote blockers after allocation recovered
|
||||
|
||||
The third remote denial attempt reached the native request and delivered the
|
||||
exact rejection. The target stayed absent and the remote process observer proved
|
||||
retirement. Its overall result remains failed: Cursor announced an empty read
|
||||
card before streaming the bootstrap file path, and the strict single-file proof
|
||||
could not attest that origin. The controller then applied generic missing-result
|
||||
recovery after the denied turn ended, making two failed session-resume attempts.
|
||||
|
||||
The blocker repair completes only an entirely empty pending read origin from a
|
||||
full single-path shape update before execution progress. Every attested notice
|
||||
retains that same digest; late, changed, multi-path, and unsafe input stays
|
||||
unproven. This changes passive evidence, not provider permissions or file access.
|
||||
Committed Cursor permission declines followed by a completed turn without a
|
||||
semantic result now fail the run once, block the unfinished task, and assign recovery to
|
||||
the operator with no automatic wake. Other missing-result recovery remains intact.
|
||||
The decision proof binds company, agent, run, turn, normalized session, source,
|
||||
request, and strict event order. It does not assert response delivery or absence
|
||||
of effects; those remain separate live requirements.
|
||||
|
||||
Focused verification passed: 534 controller tests, 43 Cursor runtime tests, and
|
||||
43 fixture evidence tests, plus affected typechecks. The provider CLI pin,
|
||||
native patch, and command/profile digest remain unchanged; the candidate source
|
||||
and Node pack identities must be rebuilt before repeating affected qualification.
|
||||
|
||||
The first local repeat at `c45cf9` confirmed the named permission-declined failure
|
||||
without another provider attempt. It remains failed because the fixture expected
|
||||
In Progress while canonical failure recovery projected Blocked. The release
|
||||
contract now requires Blocked for the new explicit permission-declined failure;
|
||||
historical generic missing-result evidence retains its prior status contract.
|
||||
Exact delivery, a single terminal, absent effects and process retirement remain
|
||||
mandatory. A reproduced Stop-precedence race is also repaired: acknowledged
|
||||
operator and reassignment cancellation wins over the new typed decline error.
|
||||
The frozen v11 runtime packs and image remain at `c45cf9`; these follow-up changes
|
||||
touch the controller and harness only, and their distinct source identity is
|
||||
recorded with subsequent results.
|
||||
|
||||
Review found three recovery gaps. Permission-decline lookup now scopes the query to the exact turn, session and runner before applying its event budget, so earlier turns cannot hide a committed denial. Cleanup retention reads raw results before packaging and records resource admission before any test provisioning; an unpublished result or worker crash can no longer discard uncertain allocation state. Confirmed pre-allocation bootstrap failures retain their original classification without an invented cleanup failure.
|
||||
|
||||
The second local denial repeat passed its behavioral and cleanup assertions but failed a browser assertion because the Blocked status label includes the current blocker count. The assertion now checks the actual Blocked status while allowing that displayed count. This failed attempt retains its original result; the affected repeat remains required.
|
||||
|
||||
Review-fix verification passed: 538 controller tests and 38 fixture tests, server and fixture typechecks, and production verification of the actual Linux pack. The first controller test invocation hit sandbox denial for its default checkpoint directory; the isolated-home repeat passed. The immutable c45cf9 qualification image is `ghcr.io/paperclipai/paperclip-daytona-runner@sha256:1344d8168f15b8c1102ffbf60bf518f278b1804c9672135fb7f10642499e70ec`. All three actual platform packs now bind runtime source c45cf9.
|
||||
|
||||
Both affected local repeats passed on runtime c45cf9 with controller/harness df1c65. The first new Daytona denied-write case also passed, including a completed read attestation, exact delivered denial, no automatic resume, continuous absent effects, owned retirement and confirmed sandbox deletion.
|
||||
|
||||
Remote Stop reached the exact pending-callback cancellation and stale-answer refusal, with a complete remote no-effect watcher and an empty final process journal. Its overall result remains failed: the flow also applied a local API-PID identity check to remote execution, where the controller-command PID legitimately becomes the sandbox runner PID. The repaired flow uses local PID tracking only for local execution. Remote proof still requires the same company/run/lease/sandbox, root boot/start-tick identity, retained descendant journal and continuous watcher. No remote retirement requirement is removed. The failed case and private recovery journal remain retained; only Stop is repeated before continuing the remaining cells.
|
||||
|
||||
The affected remote Stop repeat passed. Remote warm three-turn continuation and normal completion also passed with cleanup. The first remote file case verified exact bytes, successful validation, an accessible registered deliverable and final task/run success. Its overall result remains failed because the automatically generated artifact-preparation comment used the provider-selected file title, which duplicated the completion marker. The fixture now requests the actual filename as its deliverable title and reserves the marker for completion. Exact byte, validation, accessible-output and single-completion checks remain intact; only the file case is repeated before continuing.
|
||||
|
||||
A provider-free normal-install probe also exposed a packaging blocker: the earlier npm consumer had a macOS ARM64 daemon in the universal server package and could not launch it on Linux (exec-format exit 126). Explicit Cursor provisioning itself passed, but that does not certify the installed product. Release packaging must include verified platform-specific daemons for all three promised targets and select the correct bundled daemon without an override. This release fix and the final installed product smoke remain required.
|
||||
|
||||
The remote file repeat and semantic question/restart case passed with cleanup. The
|
||||
next semantic-plan approval case remains failed. The controller admitted its
|
||||
completion report, but returning feedback to the provider's pending semantic
|
||||
tool call was rejected. Shutdown did not prove a settled provider checkpoint;
|
||||
the identity fence correctly prevented recovery. The failure is retained and is
|
||||
not counted as a successful planning continuation.
|
||||
|
||||
Evaluation also found that the shared app-server checkpoint parser discarded
|
||||
Cursor's observed mode. It now preserves Agent/Plan/Ask, with round-trip and
|
||||
mismatched-mode recovery tests. Missing historical mode bindings remain fenced.
|
||||
Retired provider tool callbacks have a closed diagnostic category; private
|
||||
provider text is never copied into error identity. Remote checkpoint failures
|
||||
now distinguish unconfirmed exit, unsettled turns and mode binding through
|
||||
closed categories without weakening admission.
|
||||
|
||||
Public release packaging now selects the daemon beside its own compiled module,
|
||||
including the server's vendored layout, and verifies its actual executable
|
||||
architecture. Release assembly requires one source revision and all three
|
||||
independently built daemons. Run
|
||||
`pnpm --filter @paperclipai/paperclip-runner stage:release-binaries /path/to/manifest.json`
|
||||
with a JSON manifest containing `sourceRevision` and `platforms`, whose exact
|
||||
keys are `darwin-arm64`, `darwin-x64`, and `linux-x64`; each entry contains an
|
||||
absolute artifact `path` and `sha256:<64 hex digits>` digest. Assembly verifies
|
||||
all inputs before staging and records source and packaged hashes in
|
||||
`dist/bin/release-manifest.json`. Rebuild the server after assembly so its
|
||||
vendored distribution contains the entire verified platform set. The ordinary
|
||||
public-install verifier requires this manifest at an ancestor of the current source, requires unchanged Runner source since
|
||||
that frozen runtime, and checks all three packaged identities before launching the normally resolved Linux
|
||||
daemon. The actual installed task smoke remains a separate required gate.
|
||||
|
||||
Focused repair verification: 42 checkpoint/binary-selection tests, nine durable
|
||||
provider-state tests, 43 sidecar tests, and three closed Rust diagnostic tests
|
||||
passed. The first sidecar invocation timed out in the sandbox; the unrestricted
|
||||
local IPC repeat passed. Runtime source changed, so the next affected live
|
||||
attempt requires refreshed platform packs and Linux image identities. Existing
|
||||
results retain their original runtime/controller identities.
|
||||
|
||||
|
||||
The rebuilt d0b907 runtime passed all ten local Product E2E workflows with confirmed cleanup. Controller/harness source for those results is 5e6c16. Its seven Runner cases also passed every semantic check with owned processes retired; all seven strict accounting results remain failures (`provider_budget_coverage_unknown`, per-run USD unknown). The exact attempt identities are `cursor-v11-d0b907-local-<case>-01` and `cursor-v11-d0b907-<runner-case>-01`. No automatic retries were used.
|
||||
|
||||
Review then repaired the installed server's independent daemon lookup to use the Runner's verified platform selector. The public npm probe now verifies both selectors agree. Harness diagnostic retention follows the final verdict and preserves incomplete publication; remote-admission uncertainty is marked only for Daytona. The file gate now additionally downloads the registered run-attributed artifact and verifies its exact bytes and stored hash. The earlier local file result retains its original oracle and identity; an affected repeat is required for the new download proof. These repairs do not change the frozen Runner source.
|
||||
|
||||
The d0b907 Linux qualification image built successfully and its extracted provider pack passed manifest and command verification. Publishing `ghcr.io/paperclipai/paperclip-daytona-runner:cursor-qualification-d0b90756e3ab` was rejected by automatic approval review because explicit authorization for that payload and registry destination is required. The image remains local at `sha256:16c7be3610f45e409f67873dd4bd829f9a1e0e5f017c8826d01db4bb05720f97`; an approval request is pending. The fresh Daytona matrix has not started. Production admission remains disabled.
|
||||
|
||||
The current public npm proof is `/tmp/cursor-public-npm-install-473206.log`; its provider-free report remains at the task-owned consumer root printed there. The file-download repeat is `cursor-v11-d0b907-local-file-edit-validate-02`, with status and cleanup passed. The seven Runner proof summary is `/tmp/cursor-production-20261003/runner-results/semantic-summary-d0b907.json`. Private provider traces and databases are not published.
|
||||
|
||||
At 473206, the explicit Runner protocol, Rust, conformance and replay stages passed. The final authority stage passed 1,851 of 1,852 checks; its single failing test could not start embedded PostgreSQL after five attempts and did not reach its stale-question assertion. That attempt is retained at `/tmp/cursor-runner-contract-replay-473206.log`. Repeat only the affected `runner-api.integration.test.ts` stale source-run question case after the full test run releases its databases.
|
||||
|
||||
The default full local command stopped after its general-server group: 15,207 tests passed, four failed and 88 were skipped, with one additional suite setup failure. The failures were two embedded PostgreSQL startup errors, socket resets and a 500-request Git-scan join count of 497 instead of 498. All five affected files then passed in isolation (123 tests), including the authority-stage stale-question case. Original failures remain retained. The workspace and serialized groups skipped by the stopped command are being run separately; no passing general-server coverage is repeated. No unrelated source or test repair was made.
|
||||
|
||||
|
||||
## Final local verification at code source 473206
|
||||
|
||||
All required default Vitest groups were exercised. The original default command
|
||||
failed in its first group and remains failed in the record. Passing coverage was
|
||||
completed through its remaining groups and isolated repeats, without rerunning
|
||||
the already passing general-server cases:
|
||||
|
||||
- General-server: 15,207 passed in the original run; all five affected files passed
|
||||
in the diagnosed isolated repeat (123 tests). This includes the authority-stage
|
||||
stale-question assertion that previously failed during database setup.
|
||||
- UI: 7,282 passed. CLI: 504 passed initially; the one worktree-seed PostgreSQL
|
||||
startup failure passed in isolation.
|
||||
- Shared and skills-catalog groups passed. Database: 116 passed initially; both
|
||||
migration startup failures passed serially with other database groups idle.
|
||||
- All nine remaining default adapter/plugin projects passed with two workers.
|
||||
- All 150 serialized route/auth files were exercised. The earlier built-in-agent
|
||||
socket-reset case passed during resumption. Two later cross-company socket/
|
||||
timeout failures passed in the final isolated repeat. Their original failed
|
||||
records remain retained.
|
||||
- Runner protocol, Rust, conformance and replay checks passed. The authority
|
||||
stage's one database-startup failure was covered by the passing isolated
|
||||
stale-question test; its other 1,851 checks passed in the original stage.
|
||||
|
||||
No unrelated source or test repair was made. Targeted final logs are
|
||||
`/tmp/cursor-targeted-failures-473206.log`,
|
||||
`/tmp/cursor-cli-seed-repeat-473206.log`,
|
||||
`/tmp/cursor-db-migration-repeat-473206.log`, and
|
||||
`/tmp/cursor-route-repeat-473206.log`. The exact resumed coverage is recorded in
|
||||
`/tmp/cursor-production-20261003/workspaces-b-remaining-summary-473206.json` and
|
||||
`/tmp/cursor-production-20261003/serialized-remainder-summary-473206.json`.
|
||||
|
||||
The remaining critical path is unchanged: approve the exact qualification-image
|
||||
registry publication; qualify the frozen candidate on Daytona; apply Cursor-only
|
||||
production admission; assemble and verify the final package/image combination;
|
||||
run the real installed task smoke without qualification overrides; then prepare
|
||||
the PR for production review. Native AskQuestion and complete per-run dollar
|
||||
accounting remain excluded. Production merge/deployment remains a separate action.
|
||||
|
||||
|
||||
## Provider-neutral mode boundary (2026-10-05)
|
||||
|
||||
The pre-release implementation put `CursorMode` / `cursorMode` into shared Rust,
|
||||
native execution, sidecar and recovery contracts. The production port now carries
|
||||
an opaque bounded `mode` identifier through those contracts. Rust does not define
|
||||
Cursor's Agent/Plan/Ask vocabulary or enforce Cursor-only mode selection.
|
||||
The provider mode capability registry delegates supported values, defaults, native
|
||||
translation and acknowledgement to the Cursor adapter. Other adapters must qualify
|
||||
mode support before registering it; unsupported selections still fail before a
|
||||
provider prompt. Product configuration remains `acpxSessionMode` with Agent as the
|
||||
Cursor default. Session lifecycle and permission policy remain separate controls.
|
||||
|
||||
Exact mode equality remains part of the immutable session key, observed identity,
|
||||
warm continuation, suspension checkpoint and controller recovery checks. Generic
|
||||
transport tests also cover non-Cursor identifiers such as `architect` and
|
||||
`custom/build`. Missing or changed observed modes cannot recover an existing
|
||||
mode-bound session. The pre-release `cursorMode` input and checkpoint format is
|
||||
fenced rather than silently converted or defaulted. Only inspection of an already
|
||||
committed accepted-plan wait may read its old field. That path verifies the original
|
||||
unchanged acceptance proof and preserves task history; it cannot admit a new wait
|
||||
from an old field. A regression covers both preservation and attempted rewriting.
|
||||
|
||||
Validation of the updated source: recursive typecheck, full build, all Rust Runner
|
||||
checks (646 passing test invocations, including conformance/replay repeats), 254
|
||||
focused Runner TypeScript tests and 584 focused server tests passed. The complete
|
||||
Runner TypeScript run passed 67 package contracts and 2,587 Vitest cases, with one
|
||||
failure and ten skips. Its sole failure selected a preserved pre-release daemon
|
||||
from the old three-platform staging directory: that daemon expected OpenCode
|
||||
1.18.32, while current source expects 1.18.34. The historical platform set and
|
||||
manifest were preserved separately; the current-source daemon remained staged.
|
||||
The affected case and all 15 ACPX transport cases passed on a focused repeat
|
||||
against that daemon. The original failed run remains retained. New-head PR checks
|
||||
and review must complete before merge. No new paid provider cells were run for this architecture change.
|
||||
Historical live qualification, image, package and review identities above remain
|
||||
historical evidence and are not relabeled as certification of this new source.
|
||||
Release assembly must build matching daemon, sidecar and controller artifacts.
|
||||
Auto-merge is disabled while the updated boundary receives review.
|
||||
|
||||
## Bounded provider-boundary cleanup (2026-10-06)
|
||||
|
||||
The shared ACPX event decoder validates bounded parent-tool identities and the
|
||||
event's run/turn scope. Native RPC recognition stays in the Cursor adapter before
|
||||
sidecar emission. The runner's authenticated-run-grant attachment policy is now
|
||||
an explicit, runner-owned provider capability selected after pinned-profile
|
||||
validation. Cursor remains the only provider opted into that policy. Its identity,
|
||||
authority, pending-work and unknown-context-field checks are unchanged.
|
||||
|
||||
The controller settles accepted plans and permission declines through a provider
|
||||
lifecycle adapter. Cursor owns the native method names, mode interpretation,
|
||||
accepted revision interpretation and qualified-profile recognition. Shared code
|
||||
owns committed event correlation, delivery acknowledgement, tool lifecycle,
|
||||
status arbitration, receipt hashes and recovery suppression. No other provider
|
||||
gains these settlement capabilities. New receipts use `native_plan_wait` and
|
||||
`planWait`; a separate read-only decoder retains exact committed Cursor receipts,
|
||||
including the earlier unbound-tool profile. Mixed or relabeled receipt formats
|
||||
are rejected. Legacy receipts cannot authorize new waits or implementation work.
|
||||
|
||||
Historical profile-version compatibility now lives in provider metadata. The
|
||||
generic live-session handler delegates optional notice validation to a provider
|
||||
adapter. Cursor's partial counters retain their closed diagnostic format and
|
||||
never become measured usage or spend.
|
||||
|
||||
Focused checks cover normal and historical plan-wait replay, tampering, foreign
|
||||
scope, unsupported adapters, permission declines, optional diagnostics and Rust
|
||||
event projection. No new paid provider cells are part of this bounded cleanup.
|
||||
Historical live results keep their original build identities; new release
|
||||
artifacts still require matching source and qualification.
|
||||
|
||||
## Model selection and activity adapters (2026-10-06)
|
||||
|
||||
The bundled ACPX dependency still checked model discovery catalogs after the
|
||||
Paperclip allowlists were removed. The package now forwards an explicit model ID
|
||||
unchanged, including IDs absent from the catalog. It does not expand Cursor IDs
|
||||
into advertised variants. A native selection response must acknowledge the exact
|
||||
ID. New connections recheck the selected model after session load; rejection,
|
||||
mismatch, or missing model controls prevent prompting without a fallback.
|
||||
|
||||
Fourteen package-level regressions exercise the real installed ACPX manager and
|
||||
ACP wire with offline provider fixtures. They cover all six adapters, startup,
|
||||
loaded-session continuation, incomplete catalogs, rejection, missing controls,
|
||||
mismatched acknowledgements, and Cursor alias handling. These are dependency
|
||||
integration tests, not paid model calls or new live qualification.
|
||||
|
||||
The shared driver and sidecar obtain native tool identity, evidence, and optional
|
||||
usage projection through provider activity hooks. Cursor owns those hooks and its
|
||||
partial usage receipt parser. Capability metadata no longer describes Codex or Pi
|
||||
models as `exact-qualified`. Generic backend fixtures resolve the release profile
|
||||
instead of duplicating package versions and hashes.
|
||||
|
||||
Cursor profile v13 binds the changed ACPX patch. The generator verifies both the
|
||||
patch hash and the canonical release-attestation digest before emitting TypeScript
|
||||
and Rust declarations. Historical profile revisions remain decodable, but a new
|
||||
launch requires current release identity. The pre-manifest recovery fixture keeps
|
||||
its original profile and digest; committed plan-wait records remain inspectable.
|
||||
Existing v11 live evidence and platform artifacts retain their original identities
|
||||
and do not certify this source revision. The PR verification section records the
|
||||
repository checks and any outstanding release verification for this follow-up.
|
||||
|
||||
## Apex review fixes (2026-10-06)
|
||||
|
||||
Apex reviewed `beadd3654bcb1de79c203fb8ddccca714429372b` and returned 3/5
|
||||
with two release-path findings. Explicit setup now installs the pinned runtime
|
||||
under the OS account's cache, independently of npm package ownership. Setup and
|
||||
execution resolve the same closure-keyed path even when the provider has an
|
||||
isolated HOME. Image-owned assets remain authoritative. Setup must run as the
|
||||
Paperclip service account; it does not make root-owned private assets public.
|
||||
|
||||
Release assembly requires the Linux image manifest and verifies its canonical
|
||||
payload digest, source revision, ACPX profiles and Cursor version/profile/closure
|
||||
against current source pins before writing staged assets. The controller also
|
||||
checks current Cursor identity in both normal and legacy inventories. Rehashing
|
||||
an old manifest cannot authorize it. These checks use existing release metadata;
|
||||
they introduce no model catalog or model restriction.
|
||||
|
||||
Verification: 12 installer-layout and release-assembly Node checks, 656 ACPX
|
||||
tests (seven skips), and all 530 native-session executor tests passed. Recursive
|
||||
typecheck and the full build passed. Initial sandbox runs failed on host IPC and
|
||||
private runtime-directory permissions; the same suites passed with those host
|
||||
facilities available. A fresh built provisioner installed the real pinned Cursor
|
||||
distribution from a read-only public server package layout on macOS ARM64. The
|
||||
ESM repeat verified the same cache, and runtime admission verified a private
|
||||
execution snapshot without spawning a provider or sending a model request.
|
||||
The package stayed read-only and contained no downloaded runtime. Historical
|
||||
live matrices retain their original identities; these fixes do not certify a
|
||||
new v13 package/image combination. Follow-up Apex and CI results belong to the
|
||||
PR head reported by GitHub.
|
||||
|
||||
The second Apex pass scored 4/5 and found that the Linux public-install smoke
|
||||
still used the old package-owned path. Its sandbox now supplies an unprivileged
|
||||
passwd entry and a persistent account home. Setup and the offline probe mount
|
||||
the installed package read-only; the probe expects the source-pinned closure in
|
||||
the account cache. Both npm lifecycle execution and explicit setup must leave
|
||||
the package without Cursor assets. The 137 release-registry checks pass,
|
||||
including eight sandbox checks. A focused Linux x64 container run downloaded
|
||||
the real pinned distribution, repeated setup offline, and verified the private
|
||||
runtime snapshot with provider HOME redirected. It used no repository mount,
|
||||
credentials, or model calls. This exercises the corrected installer/cache path;
|
||||
it is not a new full public-package graph, daemon, or live-provider qualification.
|
||||
|
||||
## Mainline reconciliation after Apex (2026-10-06)
|
||||
|
||||
The merge preserves mainline's revised child-completion wake and finalization
|
||||
checks together with Cursor's committed plan-wait authority. Provider selection
|
||||
uses the current shared Select control while retaining Cursor admission and mode
|
||||
binding. Browser fixtures preserve attachment completion, project focus handling,
|
||||
and exact creation-response identities; they do not recover task IDs by generated
|
||||
names. The merged catalog contains both Cursor and connection acceptance cases.
|
||||
|
||||
Cursor profile v14 binds the combined ACPX patch: mainline's ACP resource-not-found
|
||||
response for missing files plus the reviewed exact-model and Cursor lifecycle
|
||||
changes. It adds no model restriction. TypeScript and Rust release declarations
|
||||
are regenerated from the existing source manifest. Earlier v11 live results and
|
||||
v13 Apex results retain their original identities. The merged artifacts still
|
||||
need their own release qualification before publication.
|
||||
@@ -0,0 +1,48 @@
|
||||
# Customer-success inspection foundation
|
||||
|
||||
Approved scope: one designated Paperclip agent uses its existing persistent
|
||||
Ed25519 identity plus a verified, active managed-run credential. Cloud owns
|
||||
short-lived grants, older-stack human approvals, durable replay fencing and audit.
|
||||
Customer stacks expose an explicit read-only API before ordinary auth middleware.
|
||||
Automatic eligibility is seven days from stack creation (or recorded pooled
|
||||
claim), with no user/account/signup-age condition. The eventual bot and morning
|
||||
schedule are separate work.
|
||||
|
||||
## Implementation and qualification
|
||||
|
||||
- Paperclip: `codex/customer-success-inspection`, updated to master `ac8f3eb14`.
|
||||
- Cloud: same branch name, isolated worktree `/private/tmp/paperclip-cloud-customer-success`, updated to master `044160d0`.
|
||||
- Core strict JWT/run-authority, versioned permit routes, explicit resource catalog,
|
||||
read-only transactions, instruction/workspace/asset/snapshot readers implemented.
|
||||
- Cloud broker, dedicated signer, durable store/migration, admin-session/capability
|
||||
routes, registration/kill switch/approval/audit UI and agent client implemented.
|
||||
- Core catalog, file restrictions, active-run authority and existing identity tests pass.
|
||||
The complete database snapshot remains equal before and after catalog reads.
|
||||
- Coordinated qualification passes with a real managed process agent, separate
|
||||
source/customer PostgreSQL databases, two Cloud broker replicas, HTTP permits,
|
||||
runtime-role append-only enforcement, one-year retention, the existing wake
|
||||
controller with a local provider, and binary file reads through Cloud.
|
||||
- Cloud root suite: 2,544 pass, 73 expected skips. Admin web: 779 pass. The 23
|
||||
inspection checks cover scoped approval review, replay, revocation, bounded
|
||||
anonymous audit traffic, and database-pool concurrency. Routing/wake smoke passes.
|
||||
- Browser acceptance verified native admin login, navigation, exact target review,
|
||||
approval, revocation, immediate disablement and persistence after refresh.
|
||||
- Full core typecheck and build pass. The local broad test run recorded 14,277
|
||||
passes and four failures in existing suites (two timeouts and two PR-metadata
|
||||
mock assertions); all three affected suites pass on isolated reruns. The full
|
||||
CI matrix passes at the implementation head. Public PR #15405 and its companion
|
||||
Cloud PR carry current review/check status. No deployment or customer enablement.
|
||||
|
||||
## Required invariants
|
||||
|
||||
No database credentials, owner-login fallback, private identity material, tenant
|
||||
sessions/memberships/activity/read receipts or routine Slack alerts. Preserve
|
||||
existing content redactions, file restrictions and raw-trace suppression. Audit
|
||||
before dispatch and before release; recheck run, policy, grant and age exception.
|
||||
The source bearer never reaches customers. Tenant permits are short-lived,
|
||||
operation-bound and consumed atomically in Cloud. Human approvals override age
|
||||
only, freeze exact targets, and can authorize later runs of the same identity.
|
||||
|
||||
Deploy core support before Cloud migration/broker/UI. Disabled by default; staging
|
||||
and internal canary qualification precede customer enablement. Default retention
|
||||
one year; rollback/reenrollment/exclusions/retention runbooks accompany both PRs.
|
||||
@@ -0,0 +1,125 @@
|
||||
# Native connection guidance: audit and eval preparation
|
||||
|
||||
## Decision
|
||||
|
||||
Keep production connection instructions intact in this slice. The current
|
||||
fixtures cannot justify removing all of the repeated text: decline prompts
|
||||
teach the no-retry behavior, the old provider-decline grader permits an empty
|
||||
explanation, and several boundaries have no direct live oracle. This change
|
||||
prepares a separate neutral comparison suite and preserves the existing cases.
|
||||
It does not claim that either production text or the new live journeys passed
|
||||
a model comparison.
|
||||
|
||||
Audit base: faa8e452c73bae5e044dd6379179a00106abb131.
|
||||
The preceding hiring-only experiment #15389 remains a separate failed
|
||||
qualification. Its results do not establish connection behavior.
|
||||
|
||||
## What reaches the model
|
||||
|
||||
| Layer | Delivery | Relevant source |
|
||||
| --- | --- | --- |
|
||||
| Native fixed prompt | Session instruction context | packages/paperclip-runner/src/contracts/runtime-context.ts |
|
||||
| Connection discovery and request descriptions/schemas | Granted tool catalog; subject to provider/catalog delivery | server/src/services/connection-tool-definitions.ts and packages/shared/src/connection-intent-guidance.ts |
|
||||
| Shared connection guidance | Legacy prompt/environment delivery and native task-context tool results | packages/adapter-utils/src/server-utils.ts and server/src/services/native-runtime/paperclip-runner-tool-authority.ts |
|
||||
| Search, request, and outcome instructions | Returned as the operation/state is encountered | server/src/services/connection-intents.ts |
|
||||
| Assigned connection instructions | Optional per-connection runtime context, separate from fixed guidance | packages/paperclip-runner/src/contracts/runtime-context.ts |
|
||||
|
||||
The fixed connection span is 98 whitespace-separated words / 654 UTF-8 bytes
|
||||
including its trailing space. The complete fixed prompt is 262 words / 1,713
|
||||
bytes. These are source counts, not provider tokens, billed usage, every
|
||||
configuration's total prompt, or proof of upstream loading/truncation.
|
||||
|
||||
The existing full-catalog capture measures nine scripted provider/phase
|
||||
projections plus an authenticated OpenCode MCP tools/list. Its source manifest
|
||||
now also hashes the shared connection text, tool-definition source and input
|
||||
validators that supply these bytes. The captured catalog contains 41 supplied tools. All three providers have
|
||||
normalized projections of 53,341 bytes at start/resume and 50,949 bytes for
|
||||
compact continuation; the authenticated MCP tool catalog is 48,195 bytes.
|
||||
The connection search/request descriptions are 651/503 bytes and their schemas
|
||||
248/611 bytes. The fixture has API tools enabled and no
|
||||
assigned external apps. Per-connection instructions, app catalogs, private
|
||||
vendor prompts, and runtime results are not silently counted as standing text.
|
||||
|
||||
## Rules that must survive a later reduction
|
||||
|
||||
- Explicit connection requests must search before using even an installed
|
||||
service. Ordinary work can use installed access without unnecessary setup.
|
||||
- Provider discovery is not human consent or proof of underlying app access.
|
||||
Saved provider choice and permission gates remain authoritative.
|
||||
- The actual request tool creates setup or Grant access cards. Do not invent
|
||||
access or solicit credentials in task comments.
|
||||
- Complete independent work, then yield. Do not poll or repeat requests while
|
||||
waiting. Continue from the saved outcome and refreshed tools.
|
||||
- Respect decline and use an alternative when possible. An explicit human
|
||||
request is needed to reconsider a declined provider choice.
|
||||
|
||||
The search/request descriptions and result instructions already carry much of
|
||||
this procedure. They do not by themselves prove every model discovers it, nor
|
||||
does moving words into descriptions prove a lower total instruction load.
|
||||
The fixed decline/alternative rule and optional access-grant paths need explicit
|
||||
coverage before removal. No prompt revision or session compatibility changes
|
||||
are needed while production stays byte-identical.
|
||||
|
||||
## New executable coverage
|
||||
|
||||
The manual-only native-connection-guidance suite has five cases on local native
|
||||
Codex, ACPX Claude and OpenCode, for fifteen configured cells:
|
||||
|
||||
| Case | Independent boundary |
|
||||
| --- | --- |
|
||||
| service-approve | No fixture call before approval; one afterwards; saved briefing contains actual titles and hidden marker |
|
||||
| service-decline | Saved refusal; no call or replacement request; attributed post-decision explanation |
|
||||
| connection-decline | Real Notion setup card; Not now saved; no connection or repeat; attributed explanation |
|
||||
| provider-decline | Real provider choice, restart/reload, None saved; instrumented installed gateway receives zero calls; attributed explanation |
|
||||
| provider-second | Saved second-provider choice after restart; no early call, one chosen-provider call; actual marker and no duplicate setup |
|
||||
|
||||
Decline prompts contain a user-permitted fallback but no no-retry, decline,
|
||||
tool-selection, polling or completion-protocol instruction. They still define
|
||||
the requested deliverable: a brief explanation if data is unavailable. This
|
||||
allows Done without pretending that unfinished required work is complete.
|
||||
|
||||
New explanation checks join comment.createdByRunId to a successful run with the same
|
||||
agent and native issue. They reject absent/stale/wrong-agent/wrong-task output,
|
||||
missing decision timestamps, unrelated decisions, duplicate requests, missing
|
||||
call evidence, unauthorized calls and connection changes. They check saved
|
||||
output, not cognitive consumption or arbitrary prose truthfulness. Existing
|
||||
screenshots and the original independent workflow checks remain in use.
|
||||
|
||||
Each cell has one attempt, expected two provider turns, a twelve-run ceiling,
|
||||
a 720-second deadline and verified 1,000-cent company/lead budget hard stops.
|
||||
The bound is a ceiling, not a target. The selected provider supplies its normal
|
||||
model profile and permissions. No production or workflow credentials are
|
||||
changed. No paid campaign is started by defining or testing this suite.
|
||||
|
||||
## Remaining coverage before any broad connection reduction
|
||||
|
||||
A single saved interaction does not prove that an agent avoided repeating an
|
||||
idempotent request-tool call. This suite rejects duplicate saved decisions,
|
||||
not every repeated tool invocation.
|
||||
|
||||
Successful new authentication/setup and tool refresh, existing-connection
|
||||
agent grants, useful independent work before yielding, explicit reconsideration
|
||||
after decline, arbitrary provider compatibility, and unavailable access with
|
||||
remaining mandatory work need separate oracles. A passing subset cannot
|
||||
qualify these missing behaviors. Positive approval of an already installed
|
||||
service is not successful connection creation.
|
||||
|
||||
## Validation and disposition
|
||||
|
||||
Local support validation passes 1,424 TypeScript tests and 128 Node checks.
|
||||
The six full-catalog measurement tests, repository typecheck/build and Product
|
||||
E2E typecheck pass. Exact suite discovery lists fifteen local cells and tests
|
||||
confirm exclusion from default/generic selection. The pre-rebase full test invocation was stopped when master advanced. Its
|
||||
partial log is not a pass. The branch was replayed onto master
|
||||
`a6306ba606eb87c89b9ef0344e9fe8e0025580f9`, retaining the upstream Cursor catalog
|
||||
additions. The measurements above remain tied to the original audit base.
|
||||
Post-rebase verification and source CI/review are pending at preparation.
|
||||
The initial support run exposed catalog-size expectations needing the fifteen
|
||||
new cells, and typecheck caught an unknown comment-body input; both are fixed
|
||||
and the final support/typecheck runs pass.
|
||||
|
||||
This PR changes no production instruction, tool-description, authority or
|
||||
runtime file relative to its master parent. Upstream runtime changes during
|
||||
the rebase are not instruction savings from this PR. There is no baseline/candidate behavioral score yet and no paid
|
||||
usage in this preparation slice. Preserve historical failures and report any
|
||||
future original attempt without rerolling a usable behavioral failure.
|
||||
+3
-1
@@ -88,7 +88,9 @@ or refetching does not undo a write-access opt-out. Nothing is authorized until
|
||||
|
||||
Return to the same Connections entry to see your connected assistants, their
|
||||
read/write access, and revoke access. The list refreshes after consent and only
|
||||
shows your grants for the selected organization. The legacy
|
||||
shows your active grants for the selected organization. Each connection shows your
|
||||
profile avatar and defaults to a name such as “Dotta’s Codex connection.” Revoked
|
||||
connections disappear from the list; their audit records remain. The legacy
|
||||
`/assistant-connections` URL remains available for account-wide management.
|
||||
|
||||
`GET /api/mcp/setup` returns the live experimental gate, canonical endpoint,
|
||||
|
||||
@@ -14,6 +14,11 @@ RUN cargo build --locked --release -j 2 -p paperclip-runner-core --bin paperclip
|
||||
|
||||
FROM node:24.21.0-bookworm@sha256:5a750d3be5e5c80275f8c9a5367c3aed99c2875656590c8d0701c7ee687f5f0a AS provider-pack-build
|
||||
RUN corepack enable && corepack prepare pnpm@9.15.4 --activate
|
||||
# Bound build-time memory on hosts that expose many CPUs but little RAM.
|
||||
# These settings belong only to this build stage, never the runtime image.
|
||||
ENV NODE_OPTIONS=--max-old-space-size=1024 \
|
||||
npm_config_child_concurrency=1 \
|
||||
npm_config_network_concurrency=4
|
||||
WORKDIR /workspace
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc tsconfig.base.json ./
|
||||
COPY patches ./patches
|
||||
@@ -28,15 +33,19 @@ COPY cli/package.json ./cli/package.json
|
||||
# The complete resolved lock (including transitive integrity hashes) is reviewed.
|
||||
# Reject registry-time drift BEFORE installing packages or running lifecycle code.
|
||||
# Refresh this digest together with source/provider dependency changes.
|
||||
ARG PAPERCLIP_RUNNER_LOCK_SHA256=e1856797158f7d0bdb728b9d6c6dda3d65ad5f7896e68216883174de508431a7
|
||||
RUN printf '%s pnpm-lock.yaml\n' "${PAPERCLIP_RUNNER_LOCK_SHA256}" > /tmp/provider-lock.sha256 \
|
||||
ARG PAPERCLIP_RUNNER_LOCK_SHA256=d3c4cffe7d127d99789cf354c5275246526f5396d2a86ce0d387352f68a2394b
|
||||
# pnpm 9 subtracts PNPM_WORKERS from available CPUs; it is not a worker count.
|
||||
# Subtract all available CPUs to select its minimum (one tarball worker).
|
||||
RUN export PNPM_WORKERS="$(node -p 'require("node:os").availableParallelism()')" \
|
||||
&& printf '%s pnpm-lock.yaml\n' "${PAPERCLIP_RUNNER_LOCK_SHA256}" > /tmp/provider-lock.sha256 \
|
||||
&& sha256sum -c /tmp/provider-lock.sha256 \
|
||||
&& pnpm install --frozen-lockfile --filter '@paperclipai/paperclip-runner...'
|
||||
ARG PAPERCLIP_RUNNER_SOURCE_REVISION
|
||||
# Optional qualification assets; this never promotes candidate profiles.
|
||||
ARG PAPERCLIP_RUNNER_CANDIDATE_PROVIDERS=
|
||||
RUN test -n "${PAPERCLIP_RUNNER_SOURCE_REVISION}"
|
||||
RUN pnpm --filter @paperclipai/paperclip-runner build:typescript \
|
||||
RUN export PNPM_WORKERS="$(node -p 'require("node:os").availableParallelism()')" \
|
||||
&& pnpm --filter @paperclipai/paperclip-runner build:typescript \
|
||||
&& PAPERCLIP_RUNNER_SOURCE_REVISION="${PAPERCLIP_RUNNER_SOURCE_REVISION}" \
|
||||
node packages/paperclip-runner/scripts/build-provider-pack.mjs /provider-pack \
|
||||
"--candidate-providers=${PAPERCLIP_RUNNER_CANDIDATE_PROVIDERS}" \
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# Paperclip Daytona runner image
|
||||
|
||||
Ordinary provider packs now include pinned Cursor assets for Linux x64; no
|
||||
`--candidate-providers=cursor` flag is needed. Cursor admission is enabled after
|
||||
the [production readiness gates](../../doc/plans/2026-10-03-cursor-production-readiness.md)
|
||||
passed. The public CLI installation command is `paperclipai runtime setup cursor`;
|
||||
it runs explicitly on the execution host and is never an npm installation hook.
|
||||
|
||||
This image is the Paperclip Cloud fleet sandbox image plus a source-built
|
||||
`paperclip-runnerd` and immutable provider pack. The pack contains Node 24.11,
|
||||
OpenCode 1.18.34, the compiled OpenCode proxy, ACPX 0.13.1 sidecar, qualified ACP
|
||||
@@ -23,6 +29,8 @@ minimum for Sonnet 5.5; the pinned runtime also supports Fable 5.1. Codex
|
||||
[GPT-6.1 Sol, GPT-6 Sol, and GPT-6 Luna model IDs](https://learn.chatgpt.com/docs/models).
|
||||
The same refresh pins Grok CLI 1.0.46, Gemini CLI 0.62.0, Kimi Code 2.1.1,
|
||||
Cursor CLI 2026.10.01-e373342, and GitHub CLI 2.102.0 in the sandbox layer.
|
||||
The native Cursor provider uses its separately verified distribution, pinned to
|
||||
2026.09.26-dd393fe; it does not use the sandbox layer’s global Cursor executable.
|
||||
Grok CLI 1.0.46 supports the current
|
||||
[Grok 4.7](https://docs.x.ai/developers/grok-4-7) model family. Hermes stays
|
||||
at 0.19.0, the newest release on PyPI.
|
||||
|
||||
+1
-1
@@ -59,7 +59,7 @@
|
||||
"smoke:posthog-live": "node scripts/smoke/posthog-live.mjs",
|
||||
"smoke:pipelines-tutorial": "./scripts/smoke/pipelines-tutorial-smoke.sh",
|
||||
"smoke:terminal-bench-loop-skill": "node scripts/smoke/terminal-bench-loop-skill-smoke.mjs",
|
||||
"test:release-registry": "node --test scripts/verify-release-registry-state.test.mjs scripts/release-package-map.test.mjs scripts/check-release-package-bootstrap.test.mjs scripts/check-no-git-push.test.mjs scripts/release-lib.test.mjs scripts/release-registry-versions.test.mjs scripts/link-plugin-dev-sdk.test.js scripts/acpx-patch-packaging.test.mjs scripts/service-onboard-smoke.test.mjs scripts/docker-onboard-smoke.test.mjs scripts/preview-artifacts.test.mjs scripts/cloud-migrator-artifacts.test.mjs",
|
||||
"test:release-registry": "node --test scripts/__tests__/cursor-public-install-sandbox.test.mjs scripts/__tests__/grok-public-install-sandbox.test.mjs scripts/verify-release-registry-state.test.mjs scripts/release-package-map.test.mjs scripts/check-release-package-bootstrap.test.mjs scripts/check-no-git-push.test.mjs scripts/release-lib.test.mjs scripts/release-registry-versions.test.mjs scripts/link-plugin-dev-sdk.test.js scripts/acpx-patch-packaging.test.mjs scripts/service-onboard-smoke.test.mjs scripts/docker-onboard-smoke.test.mjs scripts/preview-artifacts.test.mjs scripts/cloud-migrator-artifacts.test.mjs",
|
||||
"storybook-visual:baseline": "node scripts/storybook-visual-baseline.mjs",
|
||||
"test:storybook-visual": "node scripts/storybook-visual-baseline.mjs download && node scripts/storybook-visual-baseline.mjs verify && pnpm build-storybook && npx playwright test --config tests/storybook-visual/playwright.config.ts",
|
||||
"test:storybook-visual:update": "node scripts/storybook-visual-baseline.mjs download && pnpm build-storybook && npx playwright test --config tests/storybook-visual/playwright.config.ts --update-snapshots && node scripts/storybook-visual-baseline.mjs pack",
|
||||
|
||||
@@ -126,6 +126,7 @@ export {
|
||||
paperclipRunnerTransitionConfig,
|
||||
normalizeLegacyRunnerProvider,
|
||||
resolvePaperclipRunnerPermissionMode,
|
||||
resolvePaperclipRunnerCursorMode,
|
||||
} from "./paperclip-runner-permissions.js";
|
||||
export {
|
||||
PAPERCLIP_RUNNER_INGRESS_PORT,
|
||||
|
||||
@@ -162,6 +162,21 @@ export function resolvePaperclipRunnerPermissionMode(
|
||||
: capability.defaultMode;
|
||||
}
|
||||
|
||||
/** Native session behavior is independent of tool permission policy. */
|
||||
export function resolvePaperclipRunnerCursorMode(
|
||||
provider: unknown,
|
||||
agent: unknown,
|
||||
value: unknown,
|
||||
): "agent" | "plan" | "ask" | undefined {
|
||||
if (provider !== "acpx" || agent !== "cursor") {
|
||||
if (value !== undefined) throw new Error("acpxSessionMode is supported only for Cursor");
|
||||
return undefined;
|
||||
}
|
||||
if (value === undefined) return "agent";
|
||||
if (value === "agent" || value === "plan" || value === "ask") return value;
|
||||
throw new Error("Cursor session mode must be agent, plan, or ask");
|
||||
}
|
||||
|
||||
export function resolvePaperclipRunnerModel(
|
||||
provider: keyof typeof PAPERCLIP_RUNNER_DEFAULT_MODELS,
|
||||
value: unknown,
|
||||
@@ -228,7 +243,7 @@ export function normalizeLegacyRunnerProvider(
|
||||
export const PAPERCLIP_RUNNER_ACPX_PROFILES = Object.freeze([
|
||||
{ value: "grok", label: "Grok Build", qualified: true, credentialEnvironment: ["XAI_API_KEY"] },
|
||||
{ value: "claude", label: "Claude", qualified: true, credentialEnvironment: ["ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN"] },
|
||||
{ value: "cursor", label: "Cursor", qualified: false, credentialEnvironment: ["CURSOR_API_KEY", "CURSOR_AUTH_TOKEN"] },
|
||||
{ value: "cursor", label: "Cursor", qualified: true, credentialEnvironment: ["CURSOR_API_KEY", "CURSOR_AUTH_TOKEN"] },
|
||||
{ value: "copilot", label: "GitHub Copilot", qualified: false, credentialEnvironment: ["COPILOT_GITHUB_TOKEN"] },
|
||||
{ value: "pi", label: "Pi", qualified: false, credentialEnvironment: ["OPENROUTER_API_KEY"] },
|
||||
] as const);
|
||||
@@ -660,6 +660,8 @@ export interface PaperclipQuestion {
|
||||
helpText?: string;
|
||||
required: boolean;
|
||||
answerMode: "single_select" | "multi_select" | "text";
|
||||
/** Editable starting text, never an implicit or submitted answer. Text mode only. */
|
||||
initialText?: string;
|
||||
options?: PaperclipQuestionOption[];
|
||||
customAnswer?: { enabled: true; label?: string; placeholder?: string };
|
||||
textValidation?: {
|
||||
|
||||
@@ -226,19 +226,31 @@ describe("buildPaperclipRunnerConfig", () => {
|
||||
expect(config).not.toHaveProperty("acpxAgent");
|
||||
});
|
||||
|
||||
it("does not materialize the unavailable ACPX Pi profile", () => {
|
||||
expect(buildPaperclipRunnerConfig(makeValues({
|
||||
it.each(["pi", "copilot"])("rejects unavailable ACPX %s without selecting another provider", (acpxAgent) => {
|
||||
expect(() => buildPaperclipRunnerConfig(makeValues({
|
||||
adapterType: "paperclip_runner",
|
||||
model: "",
|
||||
model: "explicit-provider-model",
|
||||
adapterSchemaValues: {
|
||||
provider: "acpx",
|
||||
acpxAgent: "pi",
|
||||
acpxAgent,
|
||||
},
|
||||
}))).toMatchObject({
|
||||
provider: "acpx",
|
||||
acpxAgent: "claude",
|
||||
model: "claude-sonnet-5",
|
||||
});
|
||||
}))).toThrow(/is not enabled for production/);
|
||||
});
|
||||
|
||||
it.each(["agent", "plan", "ask"])("preserves Cursor's explicit model and %s mode", (mode) => {
|
||||
expect(buildPaperclipRunnerConfig(makeValues({
|
||||
model: "explicit-cursor-model",
|
||||
adapterSchemaValues: { provider: "acpx", acpxAgent: "cursor", acpxSessionMode: mode },
|
||||
}))).toMatchObject({ provider: "acpx", acpxAgent: "cursor", model: "explicit-cursor-model", acpxSessionMode: mode });
|
||||
});
|
||||
|
||||
it("defaults Cursor to Agent and requires an explicit model", () => {
|
||||
expect(buildPaperclipRunnerConfig(makeValues({
|
||||
model: "explicit-cursor-model", adapterSchemaValues: { provider: "acpx", acpxAgent: "cursor" },
|
||||
}))).toMatchObject({ acpxAgent: "cursor", acpxSessionMode: "agent", model: "explicit-cursor-model" });
|
||||
expect(() => buildPaperclipRunnerConfig(makeValues({
|
||||
model: "", adapterSchemaValues: { provider: "acpx", acpxAgent: "cursor" },
|
||||
}))).toThrow("cursor requires an explicit provider model");
|
||||
});
|
||||
|
||||
it("builds a Claude Managed profile reference with explicit retention and spend controls", () => {
|
||||
|
||||
@@ -5,6 +5,8 @@ import {
|
||||
resolvePaperclipRunnerModel,
|
||||
resolvePaperclipRunnerIdleTimeoutMs,
|
||||
resolvePaperclipRunnerPermissionMode,
|
||||
resolvePaperclipRunnerCursorMode,
|
||||
PAPERCLIP_RUNNER_ACPX_PROFILES,
|
||||
type CreateConfigValues,
|
||||
} from "@paperclipai/adapter-utils";
|
||||
import { DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX } from "../index.js";
|
||||
@@ -95,7 +97,12 @@ export function buildPaperclipRunnerConfig(v: CreateConfigValues): Record<string
|
||||
const provider = isPaperclipRunnerProvider(providerCandidate)
|
||||
? providerCandidate
|
||||
: "codex";
|
||||
const acpxAgent = schemaValues.acpxAgent === "grok" ? "grok" : "claude";
|
||||
const selectedAcpxProfile = PAPERCLIP_RUNNER_ACPX_PROFILES.find(profile => profile.value === schemaValues.acpxAgent);
|
||||
if (provider === "acpx" && selectedAcpxProfile && !selectedAcpxProfile.qualified) {
|
||||
throw new Error(`${selectedAcpxProfile.label} is not enabled for production`);
|
||||
}
|
||||
const acpxAgent = selectedAcpxProfile?.value ?? "claude";
|
||||
const cursorMode = resolvePaperclipRunnerCursorMode(provider, acpxAgent, schemaValues.acpxSessionMode);
|
||||
|
||||
const schemaModel = typeof schemaValues.model === "string"
|
||||
? schemaValues.model.trim()
|
||||
@@ -103,6 +110,9 @@ export function buildPaperclipRunnerConfig(v: CreateConfigValues): Record<string
|
||||
const configuredModel = typeof config.model === "string"
|
||||
? config.model.trim()
|
||||
: "";
|
||||
if (provider === "acpx" && acpxAgent === "cursor" && !configuredModel && !schemaModel) {
|
||||
throw new Error(`${acpxAgent} requires an explicit provider model`);
|
||||
}
|
||||
const managedProfileId = typeof schemaValues.managedProfileId === "string"
|
||||
? schemaValues.managedProfileId.trim()
|
||||
: "";
|
||||
@@ -177,6 +187,7 @@ export function buildPaperclipRunnerConfig(v: CreateConfigValues): Record<string
|
||||
"codexPermissionMode",
|
||||
"opencodePermissionMode",
|
||||
"acpxPermissionMode",
|
||||
"acpxSessionMode",
|
||||
"managedProfileId",
|
||||
"managedAgentsRetentionAcknowledged",
|
||||
"maxSessionListCostUsd",
|
||||
@@ -235,6 +246,7 @@ export function buildPaperclipRunnerConfig(v: CreateConfigValues): Record<string
|
||||
...(provider === "acpx"
|
||||
? {
|
||||
acpxAgent,
|
||||
...(cursorMode === undefined ? {} : { acpxSessionMode: cursorMode }),
|
||||
model: configuredModel || schemaModel || (acpxAgent === "grok" ? "grok-4.7" : resolvePaperclipRunnerModel("acpx", undefined)),
|
||||
}
|
||||
: {}),
|
||||
|
||||
@@ -466,8 +466,8 @@ async function recordMigrationHistoryEntry(
|
||||
// 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([
|
||||
"0312_private_task_access.sql",
|
||||
"0313_private_task_draft_assets.sql",
|
||||
"0313_private_task_access.sql",
|
||||
"0314_private_task_draft_assets.sql",
|
||||
]);
|
||||
|
||||
async function applyPendingMigrationsManually(
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
startEmbeddedPostgresTestDatabase,
|
||||
} from "./test-embedded-postgres.js";
|
||||
|
||||
const MIGRATION_FILE = "0312_private_task_access.sql";
|
||||
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}`);
|
||||
|
||||
@@ -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);
|
||||
File renamed without changes.
-52371
File diff suppressed because it is too large.
Load diff
+1359
-512
File diff suppressed because it is too large.
Load diff
+2471
-14
File diff suppressed because it is too large.
Load diff
+9
-2
@@ -2175,8 +2175,15 @@
|
||||
{
|
||||
"idx": 312,
|
||||
"version": "7",
|
||||
"when": 1791336853102,
|
||||
"tag": "0312_private_task_access",
|
||||
"when": 1791336871975,
|
||||
"tag": "0312_easy_eternity",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 313,
|
||||
"version": "7",
|
||||
"when": 1791371746435,
|
||||
"tag": "0313_private_task_access",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
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").notNull().references(() => heartbeatRuns.id, { onDelete: "cascade" }),
|
||||
agentId: uuid("agent_id").notNull().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 => ({
|
||||
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(),
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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'`),
|
||||
}),
|
||||
);
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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";
|
||||
@@ -17,6 +18,11 @@ export const costEvents = pgTable(
|
||||
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 +31,16 @@ 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) => ({
|
||||
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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -46,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"),
|
||||
@@ -110,6 +116,7 @@ export const heartbeatRuns = pgTable(
|
||||
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")
|
||||
|
||||
@@ -225,6 +225,7 @@ export * from "./company_skill_sources.js";
|
||||
export * from "./public_mcp.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";
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -41,6 +41,30 @@ Legacy fresh attempts receive the same bounded handoff, including resume-failure
|
||||
fallbacks. Provider
|
||||
authentication repairs retain their existing fresh-session recovery behavior.
|
||||
|
||||
## ACPX release declarations
|
||||
|
||||
`acpx-profiles.json` owns the package versions, command/profile digests, and
|
||||
required execution policies used by TypeScript and Rust. It contains no models.
|
||||
After changing it, run `pnpm --filter @paperclipai/paperclip-runner generate:acpx-profiles`.
|
||||
Normal build and typecheck reject stale generated declarations. Generation also
|
||||
checks installed dependency pins and agreement with Cursor's distribution manifest
|
||||
and immutable release attestation (`cursor-contract.json`). Cursor's per-platform
|
||||
closure pins are generated from `cursor-distributions.json`.
|
||||
|
||||
Every ACPX harness accepts an explicit caller-selected model without a Paperclip
|
||||
model allowlist. The adapter sends that ID unchanged and verifies the provider's
|
||||
effective model before prompting. An incomplete discovery catalog does not block
|
||||
selection; a provider rejection or mismatch fails without choosing a fallback.
|
||||
Qualification model selections live in test catalogs, separately from optional
|
||||
product defaults. The legacy resolved snapshot field `qualificationModel` contains
|
||||
the caller's selected model; its serialized name preserves recovery identities.
|
||||
Historical profile fixtures remain immutable evidence, not release declarations.
|
||||
The bundled ACPX package tests exercise unlisted model selection, rejection,
|
||||
exact acknowledgement, and replay on a loaded connection. Catalog membership
|
||||
and Cursor model-alias expansion do not determine the selected model.
|
||||
Provider activity adapters own native tool identities, evidence, and diagnostic
|
||||
usage projection; the shared driver and sidecar consume those hooks.
|
||||
|
||||
## Public package surfaces
|
||||
|
||||
- `@paperclipai/paperclip-runner` — production contracts, clients/backends,
|
||||
@@ -79,13 +103,37 @@ synchronous trace scan for each pending event: it blocks event delivery and can
|
||||
leave the board showing an active run after the provider turn has already ended.
|
||||
|
||||
The package also builds `paperclip-runner-acpx-sidecar`. This bounded v2
|
||||
stdin/stdout bridge admits the pinned Claude and Codex ACPX profiles. It
|
||||
validates the exact model, session identity, tool catalog, structured input,
|
||||
and terminal settlement at the process boundary. Cursor, Copilot and Pi have
|
||||
separate candidate branches and remain unavailable in production until local
|
||||
and Daytona qualification passes. Their verified distributions are build-owned;
|
||||
no candidate accepts an arbitrary executable. See
|
||||
[the rich ACP capability report](../../doc/architecture/runner-rich-acp-capabilities.md).
|
||||
stdin/stdout bridge admits the pinned Claude, Codex, Grok and Cursor ACPX profiles.
|
||||
It validates the exact model, session identity, tool catalog, structured input,
|
||||
and terminal settlement at the process boundary. Copilot and Pi remain gated.
|
||||
Verified distributions are build-owned; no provider accepts an arbitrary executable.
|
||||
See [the rich ACP capability report](../../doc/architecture/runner-rich-acp-capabilities.md).
|
||||
|
||||
Install Cursor explicitly with `paperclipai runtime setup cursor`; npm installation
|
||||
does not download it. Run setup as the OS user that runs Paperclip (the service
|
||||
account for a managed service). Setup writes to that account's
|
||||
`~/.paperclip/runtimes/cursor/<platform>-<arch>/<closure-sha256>`, so a system-wide
|
||||
npm installation can remain read-only. Isolated provider HOME/XDG settings do not
|
||||
redirect this cache. Container images continue to use their packaged assets.
|
||||
Configure a company secret binding for `CURSOR_API_KEY` or
|
||||
`CURSOR_AUTH_TOKEN`, select Cursor in the Runner configuration, and select an exact
|
||||
model ID. Agent is the default; Plan and Ask are explicit modes. Paperclip semantic
|
||||
questions are supported. Native AskQuestion and authoritative per-run dollar usage
|
||||
are unavailable. Accepting a native plan ends the planning run successfully while
|
||||
the task waits for the next instruction. See the
|
||||
[Cursor release report](../../doc/plans/2026-10-03-cursor-production-readiness.md).
|
||||
|
||||
A release includes all three platform daemons and the Linux provider-pack identity
|
||||
from its matching Daytona image. Run `stage:release-binaries` with a manifest that
|
||||
binds each daemon path and SHA-256, plus `remoteProviderPack: {path, sha256}` for
|
||||
the actual image's `provider-pack.json`. Assemble these assets after the normal
|
||||
build and include them in the server's vendored Runner output before npm packing.
|
||||
Assembly requires the provider pack's source revision to match `sourceRevision`
|
||||
and its ACPX profiles and Cursor distribution to match the current source pins.
|
||||
An independently rehashed older pack is rejected.
|
||||
Ordinary remote Cursor startup uses the packaged Linux daemon and verifies every
|
||||
image asset against that manifest. A mismatched image fails before the provider
|
||||
starts; install the matching package and image together.
|
||||
|
||||
Remote Codex sessions relay assigned app tools through the server's configured
|
||||
gateway. Small catalogs are sent directly. When a catalog would exceed the
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
{
|
||||
"schema": "paperclip.acpx-profiles.v1",
|
||||
"acpxVersion": "0.13.1",
|
||||
"driverKind": "acpx_runtime",
|
||||
"protocolVersion": 1,
|
||||
"profiles": {
|
||||
"grok": {
|
||||
"agentProfileVersion": 1,
|
||||
"agentServerPackage": "builtin:grok-acp",
|
||||
"agentServerVersion": "1",
|
||||
"agentRuntimePackage": "native:grok",
|
||||
"agentRuntimeVersion": "1.0.13",
|
||||
"commandDigest": "sha256:f0b698395a3704ed2ffaf84ea19bdb20c36c8a0a70b7c629c7b6ffe144e59e55",
|
||||
"requiresProviderPolicy": false
|
||||
},
|
||||
"pi": {
|
||||
"agentProfileVersion": 1,
|
||||
"agentServerPackage": "pi-acp",
|
||||
"agentServerVersion": "0.0.33",
|
||||
"agentRuntimePackage": "@earendil-works/pi-coding-agent",
|
||||
"agentRuntimeVersion": "0.84.2",
|
||||
"commandDigest": "sha256:8c696f38296d53d0061fa11534570c5ddd951b63532aed30e0f1fcc676dc169f",
|
||||
"requiresProviderPolicy": true
|
||||
},
|
||||
"cursor": {
|
||||
"agentProfileVersion": 14,
|
||||
"agentServerPackage": "cursor-agent",
|
||||
"agentServerVersion": "2026.09.26-dd393fe",
|
||||
"agentRuntimePackage": null,
|
||||
"agentRuntimeVersion": null,
|
||||
"commandDigest": "sha256:a5e70580e4933a1a9248cd3c1b16500c6c93e1e14913e0a98cd5ef878bd53d39",
|
||||
"requiresProviderPolicy": true
|
||||
},
|
||||
"copilot": {
|
||||
"agentProfileVersion": 2,
|
||||
"agentServerPackage": "@github/copilot",
|
||||
"agentServerVersion": "1.0.88",
|
||||
"agentRuntimePackage": null,
|
||||
"agentRuntimeVersion": null,
|
||||
"commandDigest": "sha256:b18c01603dd0169d233140709cfaa8bf5304a03cf5de78ca4f625f30013e8457",
|
||||
"requiresProviderPolicy": true,
|
||||
"qualificationStatus": "pending"
|
||||
},
|
||||
"claude": {
|
||||
"agentProfileVersion": 1,
|
||||
"agentServerPackage": "@agentclientprotocol/claude-agent-acp",
|
||||
"agentServerVersion": "0.73.0",
|
||||
"agentRuntimePackage": "@anthropic-ai/claude-agent-sdk",
|
||||
"agentRuntimeVersion": "0.3.286",
|
||||
"commandDigest": "sha256:9d73d1f0f121fb96cc8badb28c22d5bff02d8582eb2e40360a81c189e1b9422a",
|
||||
"requiresProviderPolicy": false
|
||||
},
|
||||
"codex": {
|
||||
"agentProfileVersion": 1,
|
||||
"agentServerPackage": "@agentclientprotocol/codex-acp",
|
||||
"agentServerVersion": "1.6.2",
|
||||
"agentRuntimePackage": "@openai/codex",
|
||||
"agentRuntimeVersion": "0.160.0",
|
||||
"commandDigest": "sha256:c4538599d1ab767db5dff50934f13bb5ba313a59d9c4a83e993fac4617ea63d3",
|
||||
"requiresProviderPolicy": false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"schema": "paperclip.cursor.production-contract.v1",
|
||||
"profileVersion": 14,
|
||||
"sourceSnapshot": "22c78242a4e0c2369fecf0c2dc4e7600fbad6706",
|
||||
"base": "dd868ed125cd709506dd9b29fca640a44d580501",
|
||||
"vendor": "2026.09.26-dd393fe",
|
||||
"nativePatch": "paperclip-cursor-usage-v4",
|
||||
"acpxPatchSha256": "fedf1e498c1e19daf9c973d475234488aa9acabbf1b9de890245bfac7a534483",
|
||||
"publicSetup": "paperclipai runtime setup cursor",
|
||||
"assets": "public-server-and-provider-pack",
|
||||
"modes": [
|
||||
"agent",
|
||||
"plan",
|
||||
"ask"
|
||||
],
|
||||
"questions": "paperclip-semantic-native-defensive",
|
||||
"accounting": "unknown-partial-diagnostics-only",
|
||||
"warmFiles": "mainline-owned-checkpoint-handoff",
|
||||
"planAcceptance": "successful-run-open-task-explicit-continuation",
|
||||
"otherProviders": "mainline-gated",
|
||||
"modelSelection": "explicit-provider-verified-startup-and-reconnect",
|
||||
"commandDigest": "sha256:a5e70580e4933a1a9248cd3c1b16500c6c93e1e14913e0a98cd5ef878bd53d39"
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"schema": "paperclip.cursor_distribution.v1",
|
||||
"version": "2026.09.26-dd393fe",
|
||||
"platforms": {
|
||||
"darwin-arm64": {
|
||||
"url": "https://downloads.cursor.com/lab/2026.09.26-dd393fe/darwin/arm64/agent-cli-package.tar.gz",
|
||||
"archiveSha256": "538827d96a779bab854a865c8e42859e8e87db34b5f69770261b90fc8cfff191",
|
||||
"closureSha256": "257424bd48e35412091c6adfc61e4648e836757ec1d240d890bba81a24918c30",
|
||||
"executable": "node",
|
||||
"entrypoint": "index.js",
|
||||
"vendorClosureSha256": "77394184a89b0e7384971181da19c3c82d83a399b04e7944b3f94fe3d7e62b25"
|
||||
},
|
||||
"darwin-x64": {
|
||||
"url": "https://downloads.cursor.com/lab/2026.09.26-dd393fe/darwin/x64/agent-cli-package.tar.gz",
|
||||
"archiveSha256": "ed1771c44cbf0f8059c67cac0d939e6a60eb730bd066d7dc4cbb14de314cfb6e",
|
||||
"closureSha256": "6f28c799c5afdc64fbdff8a2157f565f17ae7615efe014ac389d63bf70cf2be2",
|
||||
"executable": "node",
|
||||
"entrypoint": "index.js",
|
||||
"vendorClosureSha256": "7c8775160af74e5fb8d25a30e8412c1a6476b0661529078a686e5118fd1c5e5f"
|
||||
},
|
||||
"linux-x64": {
|
||||
"url": "https://downloads.cursor.com/lab/2026.09.26-dd393fe/linux/x64/agent-cli-package.tar.gz",
|
||||
"archiveSha256": "8085fd120f5c71f4eae7fea26a043718e5644e3071e4fab3220a0e58c51f9593",
|
||||
"closureSha256": "eadb8bb8ffb0450455b15b88c9b230307a9e149958a0c452d16dd157b4633d74",
|
||||
"executable": "node",
|
||||
"entrypoint": "index.js",
|
||||
"vendorClosureSha256": "bf04ef8ea6a63191067a6b3e15139aa2c793d8419daab246aa3f0a012e1bbcd9"
|
||||
}
|
||||
},
|
||||
"patchVersion": "paperclip-cursor-usage-v4"
|
||||
}
|
||||
@@ -57,7 +57,7 @@ const MODEL_PRESETS = {
|
||||
opencode: ["openrouter/deepseek/deepseek-v4-flash-0731"],
|
||||
acpx: ["openrouter/deepseek/deepseek-v4-flash-0731", "claude-sonnet-5", "gpt-5.6-sol"],
|
||||
} as const;
|
||||
const ACPX_AGENT_MODELS = {
|
||||
const ACPX_DEFAULT_MODELS = {
|
||||
claude: "claude-sonnet-5",
|
||||
codex: "gpt-5.6-sol",
|
||||
} as const;
|
||||
@@ -776,13 +776,6 @@ export function App() {
|
||||
setActionError("OpenCode models must use provider/model form.");
|
||||
return;
|
||||
}
|
||||
if (harness.provider === "acpx") {
|
||||
const agent = harness.acpxAgent ?? "codex";
|
||||
if (model !== ACPX_AGENT_MODELS[agent]) {
|
||||
setActionError(`The qualified ACPX ${agent} profile requires exact model ${ACPX_AGENT_MODELS[agent]}.`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
const configuration = { ...harness, model };
|
||||
persistHarnessConfiguration(configuration);
|
||||
setConfirmReset(false);
|
||||
@@ -989,7 +982,7 @@ export function App() {
|
||||
setHarness((current) => ({
|
||||
...current,
|
||||
acpxAgent,
|
||||
model: ACPX_AGENT_MODELS[acpxAgent],
|
||||
model: ACPX_DEFAULT_MODELS[acpxAgent],
|
||||
}));
|
||||
}}
|
||||
>
|
||||
@@ -1169,7 +1162,7 @@ export function App() {
|
||||
setHarness((current) => ({
|
||||
...current,
|
||||
acpxAgent,
|
||||
model: ACPX_AGENT_MODELS[acpxAgent],
|
||||
model: ACPX_DEFAULT_MODELS[acpxAgent],
|
||||
}));
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -55,25 +55,25 @@ The skill/reference inventory and eval cases are the only normative behavior sou
|
||||
| skill:skills/paperclip/SKILL.md:issue-dependencies-blockers:288 | control_plane_owned | skills/paperclip/SKILL.md:288 |
|
||||
| skill:skills/paperclip/SKILL.md:requesting-board-approval:313 | optional_agent_tool | skills/paperclip/SKILL.md:313 |
|
||||
| skill:skills/paperclip/SKILL.md:issue-thread-interactions:334 | optional_agent_tool | skills/paperclip/SKILL.md:334 |
|
||||
| skill:skills/paperclip/SKILL.md:standalone-decisions:363 | optional_agent_tool | skills/paperclip/SKILL.md:363 |
|
||||
| skill:skills/paperclip/SKILL.md:mcp-tool-approval-gates:467 | optional_agent_tool | skills/paperclip/SKILL.md:467 |
|
||||
| skill:skills/paperclip/SKILL.md:niche-workflow-pointers:509 | optional_agent_tool | skills/paperclip/SKILL.md:509 |
|
||||
| skill:skills/paperclip/SKILL.md:cases:519 | optional_agent_tool | skills/paperclip/SKILL.md:519 |
|
||||
| skill:skills/paperclip/SKILL.md:company-skills-workflow:524 | optional_agent_tool | skills/paperclip/SKILL.md:524 |
|
||||
| skill:skills/paperclip/SKILL.md:routines:535 | optional_agent_tool | skills/paperclip/SKILL.md:535 |
|
||||
| skill:skills/paperclip/SKILL.md:issue-workspace-runtime-controls:546 | optional_agent_tool | skills/paperclip/SKILL.md:546 |
|
||||
| skill:skills/paperclip/SKILL.md:proposing-credentials-safely:553 | optional_agent_tool | skills/paperclip/SKILL.md:553 |
|
||||
| skill:skills/paperclip/SKILL.md:reading-granted-secrets:560 | optional_agent_tool | skills/paperclip/SKILL.md:560 |
|
||||
| skill:skills/paperclip/SKILL.md:critical-rules:586 | optional_agent_tool | skills/paperclip/SKILL.md:586 |
|
||||
| skill:skills/paperclip/SKILL.md:comment-style-required:607 | always_agent_tool | skills/paperclip/SKILL.md:607 |
|
||||
| skill:skills/paperclip/SKILL.md:update:639 | optional_agent_tool | skills/paperclip/SKILL.md:639 |
|
||||
| skill:skills/paperclip/SKILL.md:task-documents-and-deliverables:649 | always_agent_tool | skills/paperclip/SKILL.md:649 |
|
||||
| skill:skills/paperclip/SKILL.md:planning-required-when-planning-requested:663 | optional_agent_tool | skills/paperclip/SKILL.md:663 |
|
||||
| skill:skills/paperclip/SKILL.md:key-endpoints-hot-routes:694 | optional_agent_tool | skills/paperclip/SKILL.md:694 |
|
||||
| skill:skills/paperclip/SKILL.md:searching-issues:723 | optional_agent_tool | skills/paperclip/SKILL.md:723 |
|
||||
| skill:skills/paperclip/SKILL.md:full-reference:733 | optional_agent_tool | skills/paperclip/SKILL.md:733 |
|
||||
| skill:skills/paperclip/SKILL.md:conversational-confirmation-answers:737 | always_agent_tool | skills/paperclip/SKILL.md:737 |
|
||||
| skill:skills/paperclip/SKILL.md:incidental-feedback:754 | optional_agent_tool | skills/paperclip/SKILL.md:754 |
|
||||
| skill:skills/paperclip/SKILL.md:standalone-decisions:364 | optional_agent_tool | skills/paperclip/SKILL.md:364 |
|
||||
| skill:skills/paperclip/SKILL.md:mcp-tool-approval-gates:468 | optional_agent_tool | skills/paperclip/SKILL.md:468 |
|
||||
| skill:skills/paperclip/SKILL.md:niche-workflow-pointers:510 | optional_agent_tool | skills/paperclip/SKILL.md:510 |
|
||||
| skill:skills/paperclip/SKILL.md:cases:520 | optional_agent_tool | skills/paperclip/SKILL.md:520 |
|
||||
| skill:skills/paperclip/SKILL.md:company-skills-workflow:525 | optional_agent_tool | skills/paperclip/SKILL.md:525 |
|
||||
| skill:skills/paperclip/SKILL.md:routines:536 | optional_agent_tool | skills/paperclip/SKILL.md:536 |
|
||||
| skill:skills/paperclip/SKILL.md:issue-workspace-runtime-controls:547 | optional_agent_tool | skills/paperclip/SKILL.md:547 |
|
||||
| skill:skills/paperclip/SKILL.md:proposing-credentials-safely:554 | optional_agent_tool | skills/paperclip/SKILL.md:554 |
|
||||
| skill:skills/paperclip/SKILL.md:reading-granted-secrets:561 | optional_agent_tool | skills/paperclip/SKILL.md:561 |
|
||||
| skill:skills/paperclip/SKILL.md:critical-rules:587 | optional_agent_tool | skills/paperclip/SKILL.md:587 |
|
||||
| skill:skills/paperclip/SKILL.md:comment-style-required:608 | always_agent_tool | skills/paperclip/SKILL.md:608 |
|
||||
| skill:skills/paperclip/SKILL.md:update:640 | optional_agent_tool | skills/paperclip/SKILL.md:640 |
|
||||
| skill:skills/paperclip/SKILL.md:task-documents-and-deliverables:650 | always_agent_tool | skills/paperclip/SKILL.md:650 |
|
||||
| skill:skills/paperclip/SKILL.md:planning-required-when-planning-requested:664 | optional_agent_tool | skills/paperclip/SKILL.md:664 |
|
||||
| skill:skills/paperclip/SKILL.md:key-endpoints-hot-routes:695 | optional_agent_tool | skills/paperclip/SKILL.md:695 |
|
||||
| skill:skills/paperclip/SKILL.md:searching-issues:724 | optional_agent_tool | skills/paperclip/SKILL.md:724 |
|
||||
| skill:skills/paperclip/SKILL.md:full-reference:734 | optional_agent_tool | skills/paperclip/SKILL.md:734 |
|
||||
| skill:skills/paperclip/SKILL.md:conversational-confirmation-answers:738 | always_agent_tool | skills/paperclip/SKILL.md:738 |
|
||||
| skill:skills/paperclip/SKILL.md:incidental-feedback:755 | optional_agent_tool | skills/paperclip/SKILL.md:755 |
|
||||
| skill:skills/paperclip/references/api-reference.md:paperclip-api-reference:1 | optional_agent_tool | skills/paperclip/references/api-reference.md:1 |
|
||||
| skill:skills/paperclip/references/api-reference.md:response-schemas:9 | optional_agent_tool | skills/paperclip/references/api-reference.md:9 |
|
||||
| skill:skills/paperclip/references/api-reference.md:agent-record-get-api-agents-me-or-get-api-agents-agentid:11 | optional_agent_tool | skills/paperclip/references/api-reference.md:11 |
|
||||
|
||||
@@ -128,171 +128,171 @@
|
||||
"expectedMockState": "operation_result"
|
||||
},
|
||||
{
|
||||
"id": "skill:skills/paperclip/SKILL.md:363",
|
||||
"id": "skill:skills/paperclip/SKILL.md:364",
|
||||
"kind": "skill_heading",
|
||||
"sourceAnchor": "skills/paperclip/SKILL.md#L363:standalone-decisions",
|
||||
"sourceAnchor": "skills/paperclip/SKILL.md#L364:standalone-decisions",
|
||||
"heading": "Standalone Decisions",
|
||||
"primaryDisposition": "control_plane_owned",
|
||||
"semanticOperation": "runtime_reconciliation",
|
||||
"expectedMockState": "runtime_decision_record"
|
||||
},
|
||||
{
|
||||
"id": "skill:skills/paperclip/SKILL.md:467",
|
||||
"id": "skill:skills/paperclip/SKILL.md:468",
|
||||
"kind": "skill_heading",
|
||||
"sourceAnchor": "skills/paperclip/SKILL.md#L467:mcp-tool-approval-gates",
|
||||
"sourceAnchor": "skills/paperclip/SKILL.md#L468:mcp-tool-approval-gates",
|
||||
"heading": "MCP Tool Approval Gates",
|
||||
"primaryDisposition": "optional_agent_tool",
|
||||
"semanticOperation": "scoped_discovery",
|
||||
"expectedMockState": "operation_result"
|
||||
},
|
||||
{
|
||||
"id": "skill:skills/paperclip/SKILL.md:509",
|
||||
"id": "skill:skills/paperclip/SKILL.md:510",
|
||||
"kind": "skill_heading",
|
||||
"sourceAnchor": "skills/paperclip/SKILL.md#L509:niche-workflow-pointers",
|
||||
"sourceAnchor": "skills/paperclip/SKILL.md#L510:niche-workflow-pointers",
|
||||
"heading": "Niche Workflow Pointers",
|
||||
"primaryDisposition": "control_plane_owned",
|
||||
"semanticOperation": "runtime_reconciliation",
|
||||
"expectedMockState": "runtime_decision_record"
|
||||
},
|
||||
{
|
||||
"id": "skill:skills/paperclip/SKILL.md:519",
|
||||
"id": "skill:skills/paperclip/SKILL.md:520",
|
||||
"kind": "skill_heading",
|
||||
"sourceAnchor": "skills/paperclip/SKILL.md#L519:cases",
|
||||
"sourceAnchor": "skills/paperclip/SKILL.md#L520:cases",
|
||||
"heading": "Cases",
|
||||
"primaryDisposition": "optional_agent_tool",
|
||||
"semanticOperation": "scoped_discovery",
|
||||
"expectedMockState": "operation_result"
|
||||
},
|
||||
{
|
||||
"id": "skill:skills/paperclip/SKILL.md:524",
|
||||
"id": "skill:skills/paperclip/SKILL.md:525",
|
||||
"kind": "skill_heading",
|
||||
"sourceAnchor": "skills/paperclip/SKILL.md#L524:company-skills-workflow",
|
||||
"sourceAnchor": "skills/paperclip/SKILL.md#L525:company-skills-workflow",
|
||||
"heading": "Company Skills Workflow",
|
||||
"primaryDisposition": "optional_agent_tool",
|
||||
"semanticOperation": "scoped_discovery",
|
||||
"expectedMockState": "operation_result"
|
||||
},
|
||||
{
|
||||
"id": "skill:skills/paperclip/SKILL.md:535",
|
||||
"id": "skill:skills/paperclip/SKILL.md:536",
|
||||
"kind": "skill_heading",
|
||||
"sourceAnchor": "skills/paperclip/SKILL.md#L535:routines",
|
||||
"sourceAnchor": "skills/paperclip/SKILL.md#L536:routines",
|
||||
"heading": "Routines",
|
||||
"primaryDisposition": "optional_agent_tool",
|
||||
"semanticOperation": "scoped_discovery",
|
||||
"expectedMockState": "operation_result"
|
||||
},
|
||||
{
|
||||
"id": "skill:skills/paperclip/SKILL.md:546",
|
||||
"id": "skill:skills/paperclip/SKILL.md:547",
|
||||
"kind": "skill_heading",
|
||||
"sourceAnchor": "skills/paperclip/SKILL.md#L546:issue-workspace-runtime-controls",
|
||||
"sourceAnchor": "skills/paperclip/SKILL.md#L547:issue-workspace-runtime-controls",
|
||||
"heading": "Issue Workspace Runtime Controls",
|
||||
"primaryDisposition": "optional_agent_tool",
|
||||
"semanticOperation": "scoped_discovery",
|
||||
"expectedMockState": "operation_result"
|
||||
},
|
||||
{
|
||||
"id": "skill:skills/paperclip/SKILL.md:553",
|
||||
"id": "skill:skills/paperclip/SKILL.md:554",
|
||||
"kind": "skill_heading",
|
||||
"sourceAnchor": "skills/paperclip/SKILL.md#L553:proposing-credentials-safely",
|
||||
"sourceAnchor": "skills/paperclip/SKILL.md#L554:proposing-credentials-safely",
|
||||
"heading": "Proposing Credentials Safely",
|
||||
"primaryDisposition": "control_plane_owned",
|
||||
"semanticOperation": "runtime_reconciliation",
|
||||
"expectedMockState": "runtime_decision_record"
|
||||
},
|
||||
{
|
||||
"id": "skill:skills/paperclip/SKILL.md:560",
|
||||
"id": "skill:skills/paperclip/SKILL.md:561",
|
||||
"kind": "skill_heading",
|
||||
"sourceAnchor": "skills/paperclip/SKILL.md#L560:reading-granted-secrets",
|
||||
"sourceAnchor": "skills/paperclip/SKILL.md#L561:reading-granted-secrets",
|
||||
"heading": "Reading Granted Secrets",
|
||||
"primaryDisposition": "optional_agent_tool",
|
||||
"semanticOperation": "scoped_discovery",
|
||||
"expectedMockState": "operation_result"
|
||||
},
|
||||
{
|
||||
"id": "skill:skills/paperclip/SKILL.md:586",
|
||||
"id": "skill:skills/paperclip/SKILL.md:587",
|
||||
"kind": "skill_heading",
|
||||
"sourceAnchor": "skills/paperclip/SKILL.md#L586:critical-rules",
|
||||
"sourceAnchor": "skills/paperclip/SKILL.md#L587:critical-rules",
|
||||
"heading": "Critical Rules",
|
||||
"primaryDisposition": "control_plane_owned",
|
||||
"semanticOperation": "runtime_reconciliation",
|
||||
"expectedMockState": "runtime_decision_record"
|
||||
},
|
||||
{
|
||||
"id": "skill:skills/paperclip/SKILL.md:607",
|
||||
"id": "skill:skills/paperclip/SKILL.md:608",
|
||||
"kind": "skill_heading",
|
||||
"sourceAnchor": "skills/paperclip/SKILL.md#L607:comment-style-required",
|
||||
"sourceAnchor": "skills/paperclip/SKILL.md#L608:comment-style-required",
|
||||
"heading": "Comment Style (Required)",
|
||||
"primaryDisposition": "always_agent_tool",
|
||||
"semanticOperation": "report_progress",
|
||||
"expectedMockState": "operation_result"
|
||||
},
|
||||
{
|
||||
"id": "skill:skills/paperclip/SKILL.md:639",
|
||||
"id": "skill:skills/paperclip/SKILL.md:640",
|
||||
"kind": "skill_heading",
|
||||
"sourceAnchor": "skills/paperclip/SKILL.md#L639:update",
|
||||
"sourceAnchor": "skills/paperclip/SKILL.md#L640:update",
|
||||
"heading": "Update",
|
||||
"primaryDisposition": "control_plane_owned",
|
||||
"semanticOperation": "runtime_reconciliation",
|
||||
"expectedMockState": "runtime_decision_record"
|
||||
},
|
||||
{
|
||||
"id": "skill:skills/paperclip/SKILL.md:649",
|
||||
"id": "skill:skills/paperclip/SKILL.md:650",
|
||||
"kind": "skill_heading",
|
||||
"sourceAnchor": "skills/paperclip/SKILL.md#L649:task-documents-and-deliverables",
|
||||
"sourceAnchor": "skills/paperclip/SKILL.md#L650:task-documents-and-deliverables",
|
||||
"heading": "Task Documents and Deliverables",
|
||||
"primaryDisposition": "always_agent_tool",
|
||||
"semanticOperation": "write_document",
|
||||
"expectedMockState": "operation_result"
|
||||
},
|
||||
{
|
||||
"id": "skill:skills/paperclip/SKILL.md:663",
|
||||
"id": "skill:skills/paperclip/SKILL.md:664",
|
||||
"kind": "skill_heading",
|
||||
"sourceAnchor": "skills/paperclip/SKILL.md#L663:planning-required-when-planning-requested",
|
||||
"sourceAnchor": "skills/paperclip/SKILL.md#L664:planning-required-when-planning-requested",
|
||||
"heading": "Planning (Required when planning requested)",
|
||||
"primaryDisposition": "always_agent_tool",
|
||||
"semanticOperation": "write_document",
|
||||
"expectedMockState": "operation_result"
|
||||
},
|
||||
{
|
||||
"id": "skill:skills/paperclip/SKILL.md:694",
|
||||
"id": "skill:skills/paperclip/SKILL.md:695",
|
||||
"kind": "skill_heading",
|
||||
"sourceAnchor": "skills/paperclip/SKILL.md#L694:key-endpoints-hot-routes",
|
||||
"sourceAnchor": "skills/paperclip/SKILL.md#L695:key-endpoints-hot-routes",
|
||||
"heading": "Key Endpoints (Hot Routes)",
|
||||
"primaryDisposition": "control_plane_owned",
|
||||
"semanticOperation": "runtime_reconciliation",
|
||||
"expectedMockState": "runtime_decision_record"
|
||||
},
|
||||
{
|
||||
"id": "skill:skills/paperclip/SKILL.md:723",
|
||||
"id": "skill:skills/paperclip/SKILL.md:724",
|
||||
"kind": "skill_heading",
|
||||
"sourceAnchor": "skills/paperclip/SKILL.md#L723:searching-issues",
|
||||
"sourceAnchor": "skills/paperclip/SKILL.md#L724:searching-issues",
|
||||
"heading": "Searching Issues",
|
||||
"primaryDisposition": "control_plane_owned",
|
||||
"semanticOperation": "runtime_reconciliation",
|
||||
"expectedMockState": "runtime_decision_record"
|
||||
},
|
||||
{
|
||||
"id": "skill:skills/paperclip/SKILL.md:733",
|
||||
"id": "skill:skills/paperclip/SKILL.md:734",
|
||||
"kind": "skill_heading",
|
||||
"sourceAnchor": "skills/paperclip/SKILL.md#L733:full-reference",
|
||||
"sourceAnchor": "skills/paperclip/SKILL.md#L734:full-reference",
|
||||
"heading": "Full Reference",
|
||||
"primaryDisposition": "control_plane_owned",
|
||||
"semanticOperation": "runtime_reconciliation",
|
||||
"expectedMockState": "runtime_decision_record"
|
||||
},
|
||||
{
|
||||
"id": "skill:skills/paperclip/SKILL.md:737",
|
||||
"id": "skill:skills/paperclip/SKILL.md:738",
|
||||
"kind": "skill_heading",
|
||||
"sourceAnchor": "skills/paperclip/SKILL.md#L737:conversational-confirmation-answers",
|
||||
"sourceAnchor": "skills/paperclip/SKILL.md#L738:conversational-confirmation-answers",
|
||||
"heading": "Conversational confirmation answers",
|
||||
"primaryDisposition": "always_agent_tool",
|
||||
"semanticOperation": "call_api",
|
||||
"expectedMockState": "operation_result"
|
||||
},
|
||||
{
|
||||
"id": "skill:skills/paperclip/SKILL.md:754",
|
||||
"id": "skill:skills/paperclip/SKILL.md:755",
|
||||
"kind": "skill_heading",
|
||||
"sourceAnchor": "skills/paperclip/SKILL.md#L754:incidental-feedback",
|
||||
"sourceAnchor": "skills/paperclip/SKILL.md#L755:incidental-feedback",
|
||||
"heading": "Incidental feedback",
|
||||
"primaryDisposition": "optional_agent_tool",
|
||||
"semanticOperation": "scoped_discovery",
|
||||
|
||||
@@ -5,6 +5,6 @@ Generated by `scripts/generate-capability-contract.mjs`; do not edit generated f
|
||||
- Skill/reference headings: 163
|
||||
- Legacy MCP tools: 42
|
||||
- Eval cases: 106 across 16 groups
|
||||
- Deterministic content SHA-256: `3f4939bc27abd5311caf243044f0b2d4ec36a8d27d4eaf34fed7a0738c734c1d`
|
||||
- Deterministic content SHA-256: `70c4d4e90346747d89b722e09bf1c82672437d55e81bfe5f19211642c3749aa8`
|
||||
|
||||
Every row has exactly one primary disposition, a source anchor, a semantic operation, and a mock-state expectation.
|
||||
@@ -58,9 +58,10 @@
|
||||
"sideEffects": false,
|
||||
"scripts": {
|
||||
"build": "pnpm run check:protocol-manifest && pnpm run build:typescript && pnpm run check:capability-contract && pnpm run check:capability-inventory && pnpm run check:protocol-coverage && pnpm run check:semantic-contracts && pnpm run check:runner-workflow-traceability && pnpm run build:binary && node scripts/generate-replay-goldens.mjs --check && node scripts/generate-semantic-action-catalog.mjs --check",
|
||||
"build:typescript": "pnpm run ensure:eval-build-deps && pnpm run check:protocol-types && node ./node_modules/typescript/bin/tsc --version && node ./node_modules/typescript/bin/tsc -p tsconfig.json && node ./node_modules/typescript/bin/tsc -p tsconfig.surfaces.json && node scripts/build-verified-provider-entrypoints.mjs",
|
||||
"build:typescript": "pnpm run check:acpx-profiles && pnpm run ensure:eval-build-deps && pnpm run check:protocol-types && node ./node_modules/typescript/bin/tsc --version && node ./node_modules/typescript/bin/tsc -p tsconfig.json && node ./node_modules/typescript/bin/tsc -p tsconfig.surfaces.json && node scripts/build-verified-provider-entrypoints.mjs",
|
||||
"build:rust": "cargo build --manifest-path runner/Cargo.toml --locked --workspace --bins",
|
||||
"build:binary": "cargo build --release --manifest-path runner/Cargo.toml --locked -p paperclip-runner-core --bin paperclip-runnerd && node scripts/stage-runner-binary.mjs",
|
||||
"stage:release-binaries": "node scripts/stage-release-runner-binaries.mjs",
|
||||
"build:provider-pack": "pnpm run build:typescript && node scripts/build-provider-pack.mjs",
|
||||
"build:runner-binaries": "cargo build --manifest-path runner/Cargo.toml --locked --workspace --bins",
|
||||
"build:browser": "vite build --config vite.config.ts",
|
||||
@@ -69,12 +70,12 @@
|
||||
"build:scenarios": "vite build --config vite.scenarios.config.ts && node scripts/check-capability-csp-bundle.mjs",
|
||||
"build:issue-thread": "vite build --config vite.issue-thread.config.ts",
|
||||
"typecheck": "pnpm run typecheck:typescript && pnpm run typecheck:rust",
|
||||
"typecheck:typescript": "pnpm run ensure:eval-build-deps && node --check scripts/protocol-contract.mjs && node --check scripts/generate-protocol-manifest.mjs && node --check scripts/generate-protocol-schema-module.mjs && node --check scripts/generate-acpx-sidecar-contract.mjs && node --check scripts/generate-replay-goldens.mjs && node --check scripts/generate-semantic-action-catalog.mjs && pnpm run check:protocol-types && node ./node_modules/typescript/bin/tsc --version && node ./node_modules/typescript/bin/tsc -p tsconfig.json --noEmit && node ./node_modules/typescript/bin/tsc -p tsconfig.surfaces.json --noEmit",
|
||||
"typecheck:rust": "cargo fmt --manifest-path runner/Cargo.toml --all -- --check && cargo check --manifest-path runner/Cargo.toml --locked --workspace",
|
||||
"typecheck:typescript": "pnpm run check:acpx-profiles && pnpm run ensure:eval-build-deps && node --check scripts/protocol-contract.mjs && node --check scripts/generate-protocol-manifest.mjs && node --check scripts/generate-protocol-schema-module.mjs && node --check scripts/generate-acpx-sidecar-contract.mjs && node --check scripts/generate-replay-goldens.mjs && node --check scripts/generate-semantic-action-catalog.mjs && pnpm run check:protocol-types && node ./node_modules/typescript/bin/tsc --version && node ./node_modules/typescript/bin/tsc -p tsconfig.json --noEmit && node ./node_modules/typescript/bin/tsc -p tsconfig.surfaces.json --noEmit",
|
||||
"typecheck:rust": "pnpm run check:acpx-profiles && cargo fmt --manifest-path runner/Cargo.toml --all -- --check && cargo check --manifest-path runner/Cargo.toml --locked --workspace",
|
||||
"typecheck:browser": "tsc -p tsconfig.browser.json --noEmit",
|
||||
"test": "pnpm run test:typescript && pnpm run test:rust",
|
||||
"test:typescript": "pnpm run test:typescript:prep && vitest run",
|
||||
"test:typescript:prep": "pnpm run ensure:eval-build-deps && pnpm run build:rust && node --test test/protocol-contract.test.mjs test/acpx-sidecar-contract.test.mjs test/acpx-codex-package-contract.test.mjs test/acpx-rich-extensions-package-contract.test.mjs test/acpx-pi-receipt-package-contract.test.mjs test/acpx-response-delivery-package-contract.test.mjs scripts/candidate-provider-pack.test.mjs scripts/aws-agentcore-provisioning.test.mjs scripts/build-verified-provider-entrypoints.test.mjs scripts/local-provider-smoke-environment.test.mjs scripts/materialize-opencode-binary.test.mjs",
|
||||
"test:typescript:prep": "pnpm run ensure:eval-build-deps && pnpm run build:rust && node --test test/protocol-contract.test.mjs test/acpx-sidecar-contract.test.mjs test/acpx-codex-package-contract.test.mjs test/acpx-model-selection-package-contract.test.mjs test/acpx-rich-extensions-package-contract.test.mjs test/acpx-pi-receipt-package-contract.test.mjs test/acpx-response-delivery-package-contract.test.mjs scripts/candidate-provider-pack.test.mjs scripts/cursor-provisioner-layout.test.mjs scripts/release-provider-pack.test.mjs scripts/aws-agentcore-provisioning.test.mjs scripts/build-verified-provider-entrypoints.test.mjs scripts/local-provider-smoke-environment.test.mjs scripts/materialize-opencode-binary.test.mjs",
|
||||
"test:typescript:vitest": "node ./scripts/run-pr-vitest-lane.mjs",
|
||||
"test:rust": "cargo test --release --manifest-path runner/Cargo.toml --locked --workspace",
|
||||
"test:codex": "cargo test --manifest-path runner/Cargo.toml --locked -p paperclip-runner-core --test codex_provider",
|
||||
@@ -160,7 +161,9 @@
|
||||
"browser:preview": "vite preview --config vite.config.ts",
|
||||
"verify": "pnpm run build && pnpm run typecheck && pnpm run check:replay-goldens && pnpm run test && pnpm run test:sdk && pnpm run test:scenarios && pnpm run check:browser-tokens && pnpm run test:browser && pnpm run test:browser:sdk && pnpm run test:browser:scenarios && pnpm run check:forbidden-imports && pnpm run check:tracked-imports && pnpm run check:numbered-milestones && pnpm run check:package-boundaries && pnpm run check:clean-consumers && pnpm run docs:validate && pnpm run check:conformance-parity && pnpm run check:replay-parity && pnpm run trace:conformance && pnpm run replay:fixture && pnpm run trace:local-runner -- --quiet",
|
||||
"verify:rootless": "bash scripts/verify-rootless-linux.sh",
|
||||
"test:opencode:qualification": "PAPERCLIP_OPENCODE_QUALIFY=1 node --test scripts/qualify-opencode-runtime.test.mjs"
|
||||
"test:opencode:qualification": "PAPERCLIP_OPENCODE_QUALIFY=1 node --test scripts/qualify-opencode-runtime.test.mjs",
|
||||
"generate:acpx-profiles": "node scripts/generate-acpx-profiles.mjs",
|
||||
"check:acpx-profiles": "node scripts/generate-acpx-profiles.mjs --check"
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/claude-agent-acp": "0.73.0",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use serde_json::Value;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::acpx_event_scope::AcpxEventScope;
|
||||
use crate::acpx_sidecar_transport::AcpxSidecarEvent;
|
||||
@@ -61,6 +61,7 @@ pub enum AcpxEventPayload {
|
||||
details: Value,
|
||||
},
|
||||
InputRequested {
|
||||
tool_call_id: Option<String>,
|
||||
request_id: String,
|
||||
question_set: Value,
|
||||
origin: Option<Value>,
|
||||
@@ -124,7 +125,22 @@ pub fn decode_acpx_event(
|
||||
validate_question_set(&question_set)?;
|
||||
let question_set = sanitize_question_set(question_set)?;
|
||||
let origin = optional_object(&event.payload, "origin", "input request origin")?;
|
||||
// Native methods are interpreted by the provider adapter. This
|
||||
// transport validates the opaque reference under the event scope.
|
||||
let tool_call_id = if event.payload.get("toolCallId").is_some() {
|
||||
let id =
|
||||
required_id_with_limit(&event.payload, "toolCallId", "input parent tool", 240)?;
|
||||
if id.len() > 240 || sanitize_value(&json!(id)) != json!(id) {
|
||||
return Err(LocalRunnerError::invalid(
|
||||
"ACPX input parent tool identity is invalid",
|
||||
));
|
||||
}
|
||||
Some(id)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Ok(AcpxEventPayload::InputRequested {
|
||||
tool_call_id,
|
||||
request_id: required_id_with_limit(
|
||||
&event.payload,
|
||||
"requestId",
|
||||
|
||||
+507
-140
@@ -1,3 +1,6 @@
|
||||
use crate::generated_acpx_profiles::{
|
||||
acpx_release_profile, ACPX_DRIVER_KIND, QUALIFIED_ACPX_VERSION,
|
||||
};
|
||||
use std::collections::{BTreeMap, HashMap, HashSet, VecDeque};
|
||||
use std::fs::{self, DirBuilder, File};
|
||||
use std::io::{Read, Write};
|
||||
@@ -12,6 +15,7 @@ use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::acpx_provider_capabilities::{run_attachment_policy, RunAttachmentPolicy};
|
||||
use crate::acpx_provider_session::{
|
||||
AcpxPermissionMode, AcpxProviderRuntimePolicy, AcpxProviderSession, AcpxProviderSessionConfig,
|
||||
AcpxProviderSessionIdentity, AcpxTurnControlCapabilities,
|
||||
@@ -135,6 +139,8 @@ struct AcpxProviderDescriptor {
|
||||
#[serde(default)]
|
||||
instructions: String,
|
||||
permission_mode: AcpxPermissionMode,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
mode: Option<String>,
|
||||
permission_mode_pinned: bool,
|
||||
#[serde(default)]
|
||||
provider_policy: Option<AcpxProviderRuntimePolicy>,
|
||||
@@ -142,82 +148,68 @@ struct AcpxProviderDescriptor {
|
||||
runtime_context: Value,
|
||||
}
|
||||
|
||||
// Only controller-authenticated run grants move between provider lifetimes.
|
||||
// Keep prompt, bundle/skill identities, assignment policy, and unknown fields
|
||||
// in this comparison; a new filesystem copy is not a new provider profile.
|
||||
fn runtime_context_session_identity(context: &Value) -> Option<Value> {
|
||||
let mut identity = context.clone();
|
||||
for pointer in [
|
||||
"/instructions/bundle/rootPath",
|
||||
"/instructions/workingCopy/rootPath",
|
||||
] {
|
||||
if let Some(value) = identity.pointer_mut(pointer) {
|
||||
if !value.as_str().is_some_and(|path| !path.is_empty()) {
|
||||
return None;
|
||||
}
|
||||
*value = Value::Null;
|
||||
}
|
||||
}
|
||||
if let Some(skills) = identity.get_mut("skills").and_then(Value::as_array_mut) {
|
||||
for skill in skills {
|
||||
if let Some(value) = skill.pointer_mut("/bundle/rootPath") {
|
||||
if !value.as_str().is_some_and(|path| !path.is_empty()) {
|
||||
return None;
|
||||
}
|
||||
*value = Value::Null;
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(value) = identity.pointer_mut("/mcp/bindingId") {
|
||||
if !value.is_null() && !value.as_str().is_some_and(|binding| !binding.is_empty()) {
|
||||
return None;
|
||||
}
|
||||
*value = Value::Null;
|
||||
}
|
||||
Some(identity)
|
||||
}
|
||||
|
||||
impl AcpxProviderDescriptor {
|
||||
fn validate_session(
|
||||
&self,
|
||||
context: &AcpxEventProjectionContext,
|
||||
) -> Result<(), DurableRunnerError> {
|
||||
let expected = match self.agent.as_str() {
|
||||
"claude" => (
|
||||
"claude-sonnet-5",
|
||||
"@agentclientprotocol/claude-agent-acp",
|
||||
"0.73.0",
|
||||
Some("@anthropic-ai/claude-agent-sdk"),
|
||||
Some("0.3.286"),
|
||||
"sha256:9d73d1f0f121fb96cc8badb28c22d5bff02d8582eb2e40360a81c189e1b9422a",
|
||||
),
|
||||
"codex" => (
|
||||
"gpt-5.6-sol",
|
||||
"@agentclientprotocol/codex-acp",
|
||||
"1.6.2",
|
||||
Some("@openai/codex"),
|
||||
Some("0.160.0"),
|
||||
"sha256:c4538599d1ab767db5dff50934f13bb5ba313a59d9c4a83e993fac4617ea63d3",
|
||||
),
|
||||
"pi" => (
|
||||
"openrouter/deepseek/deepseek-v4-flash-0731",
|
||||
"pi-acp",
|
||||
"0.0.33",
|
||||
Some("@earendil-works/pi-coding-agent"),
|
||||
Some("0.84.2"),
|
||||
"sha256:8c696f38296d53d0061fa11534570c5ddd951b63532aed30e0f1fcc676dc169f",
|
||||
),
|
||||
"cursor" => (
|
||||
self.model.as_str(),
|
||||
"cursor-agent",
|
||||
"2026.09.26-dd393fe",
|
||||
None,
|
||||
None,
|
||||
"sha256:1157a5d071abbd57ab132f22bace75c65e84cc47a045b0023475488755e14899",
|
||||
),
|
||||
"copilot" => (
|
||||
self.model.as_str(),
|
||||
"@github/copilot",
|
||||
"1.0.88",
|
||||
None,
|
||||
None,
|
||||
"sha256:b18c01603dd0169d233140709cfaa8bf5304a03cf5de78ca4f625f30013e8457",
|
||||
),
|
||||
"grok" => (
|
||||
"grok-4.7",
|
||||
"builtin:grok-acp",
|
||||
"1",
|
||||
Some("native:grok"),
|
||||
Some("1.0.13"),
|
||||
"sha256:f0b698395a3704ed2ffaf84ea19bdb20c36c8a0a70b7c629c7b6ffe144e59e55",
|
||||
),
|
||||
_ => {
|
||||
return Err(DurableRunnerError::invalid(
|
||||
"ACPX agent must name a known immutable profile",
|
||||
))
|
||||
}
|
||||
};
|
||||
let expected = acpx_release_profile(&self.agent).ok_or_else(|| {
|
||||
DurableRunnerError::invalid("ACPX agent must name a known immutable profile")
|
||||
})?;
|
||||
if self.kind != "acpx"
|
||||
|| self.provider != "acpx"
|
||||
|| self.driver != "acpx_runtime"
|
||||
|| self.provider_version != "0.13.1"
|
||||
|| self.acpx_version != "0.13.1"
|
||||
|| (self.agent != "claude" && self.agent != "grok" && self.model != expected.0)
|
||||
|| self.driver != ACPX_DRIVER_KIND
|
||||
|| self.provider_version != QUALIFIED_ACPX_VERSION
|
||||
|| self.acpx_version != QUALIFIED_ACPX_VERSION
|
||||
|| self.model.trim().is_empty()
|
||||
|| self.model.len() > 240
|
||||
|| self.model.contains('\0')
|
||||
|| (matches!(self.agent.as_str(), "pi" | "cursor" | "copilot")
|
||||
&& self.provider_policy.is_none())
|
||||
|| self.agent_server_package != expected.1
|
||||
|| self.agent_server_version != expected.2
|
||||
|| self.agent_runtime_package.as_deref() != expected.3
|
||||
|| self.agent_runtime_version.as_deref() != expected.4
|
||||
|| self.command_digest != expected.5
|
||||
|| self.mode.as_ref().is_some_and(|mode| {
|
||||
mode.trim().is_empty()
|
||||
|| mode.chars().count() > 240
|
||||
|| mode.chars().any(char::is_control)
|
||||
})
|
||||
|| (expected.requires_provider_policy && self.provider_policy.is_none())
|
||||
|| self.agent_server_package != expected.agent_server_package
|
||||
|| self.agent_server_version != expected.agent_server_version
|
||||
|| self.agent_runtime_package.as_deref() != expected.agent_runtime_package
|
||||
|| self.agent_runtime_version.as_deref() != expected.agent_runtime_version
|
||||
|| self.command_digest != expected.command_digest
|
||||
{
|
||||
return Err(DurableRunnerError::invalid(
|
||||
"ACPX provider descriptor does not match a qualified immutable profile",
|
||||
@@ -306,6 +298,7 @@ impl AcpxProviderDescriptor {
|
||||
normalized_session_id: self.normalized_session_id.clone(),
|
||||
working_directory: PathBuf::from(&self.cwd),
|
||||
permission_mode: self.permission_mode,
|
||||
mode: self.mode.clone(),
|
||||
permission_mode_pinned: self.permission_mode_pinned,
|
||||
provider_policy: self.provider_policy.clone(),
|
||||
system_instructions: self.instructions.clone(),
|
||||
@@ -389,7 +382,7 @@ impl AcpxProviderDescriptor {
|
||||
}
|
||||
|
||||
fn public_descriptor(&self, identity: Option<&AcpxProviderSessionIdentity>) -> Value {
|
||||
json!({
|
||||
let mut descriptor = json!({
|
||||
"provider": "acpx",
|
||||
"driver": "acpx_runtime",
|
||||
"providerVersion": self.provider_version,
|
||||
@@ -405,7 +398,11 @@ impl AcpxProviderDescriptor {
|
||||
"providerSessionId": identity.map(|value| value.agent_session_id.as_str()),
|
||||
"acpxRecordId": identity.map(|value| value.acpx_record_id.as_str()),
|
||||
"permissionMode": self.permission_mode,
|
||||
})
|
||||
});
|
||||
if let Some(mode) = self.mode.as_deref() {
|
||||
descriptor["mode"] = json!(mode);
|
||||
}
|
||||
descriptor
|
||||
}
|
||||
}
|
||||
|
||||
@@ -535,9 +532,11 @@ impl AcpxDurableState {
|
||||
identity
|
||||
.validate()
|
||||
.map_err(|error| DurableRunnerError::invalid(error.to_string()))?;
|
||||
if identity.profile_digest != self.descriptor.command_digest {
|
||||
if identity.profile_digest != self.descriptor.command_digest
|
||||
|| identity.mode != self.descriptor.mode
|
||||
{
|
||||
return Err(DurableRunnerError::invalid(
|
||||
"ACPX durable identity no longer matches its qualified profile digest",
|
||||
"ACPX durable identity no longer matches its qualified profile or provider mode",
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -1056,6 +1055,25 @@ impl AcpxCommandExecutor {
|
||||
object.remove("aggregateDigest");
|
||||
}
|
||||
}
|
||||
if run_attachment_policy(&descriptor.agent) == RunAttachmentPolicy::AuthenticatedRunGrants {
|
||||
// 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.
|
||||
let compatible = runtime_context_session_identity(&durable_descriptor.runtime_context)
|
||||
.zip(runtime_context_session_identity(
|
||||
&previous_descriptor.runtime_context,
|
||||
))
|
||||
.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) {
|
||||
return Err(DurableRunnerError::invalid(
|
||||
"ACPX run.attach changed runtime context outside a new authenticated run",
|
||||
));
|
||||
}
|
||||
durable_descriptor.instructions = previous_descriptor.instructions.clone();
|
||||
durable_descriptor.runtime_context = previous_descriptor.runtime_context.clone();
|
||||
}
|
||||
let only_recovery_notice_pending = state
|
||||
.pending_events
|
||||
.iter()
|
||||
@@ -1848,7 +1866,16 @@ impl AcpxCommandExecutor {
|
||||
}
|
||||
if let Some(event_type) = terminal {
|
||||
state.active_turn_id = None;
|
||||
state.lifecycle = "session_open".to_owned();
|
||||
state.lifecycle = if self
|
||||
.session
|
||||
.as_ref()
|
||||
.is_some_and(|session| session.runtime_retired())
|
||||
{
|
||||
"closed"
|
||||
} else {
|
||||
"session_open"
|
||||
}
|
||||
.to_owned();
|
||||
provider_turn_settled = true;
|
||||
// An ACP goal has session lifetime, not prompt lifetime.
|
||||
// Out-of-prompt goal updates remain observable after quiescence.
|
||||
@@ -2250,39 +2277,20 @@ mod tests {
|
||||
}
|
||||
|
||||
fn descriptor(agent: &str) -> Value {
|
||||
let (model, package, version, runtime_package, runtime_version, digest) =
|
||||
if agent == "claude" {
|
||||
(
|
||||
"claude-sonnet-5",
|
||||
"@agentclientprotocol/claude-agent-acp",
|
||||
"0.73.0",
|
||||
json!("@anthropic-ai/claude-agent-sdk"),
|
||||
json!("0.3.286"),
|
||||
"sha256:9d73d1f0f121fb96cc8badb28c22d5bff02d8582eb2e40360a81c189e1b9422a",
|
||||
)
|
||||
} else {
|
||||
(
|
||||
"gpt-5.6-sol",
|
||||
"@agentclientprotocol/codex-acp",
|
||||
"1.6.2",
|
||||
json!("@openai/codex"),
|
||||
json!("0.160.0"),
|
||||
"sha256:c4538599d1ab767db5dff50934f13bb5ba313a59d9c4a83e993fac4617ea63d3",
|
||||
)
|
||||
};
|
||||
json!({
|
||||
let profile = acpx_release_profile(agent).unwrap();
|
||||
let mut value = json!({
|
||||
"kind": "acpx",
|
||||
"provider": "acpx",
|
||||
"driver": "acpx_runtime",
|
||||
"providerVersion": "0.13.1",
|
||||
"providerVersion": QUALIFIED_ACPX_VERSION,
|
||||
"agent": agent,
|
||||
"model": model,
|
||||
"acpxVersion": "0.13.1",
|
||||
"agentServerPackage": package,
|
||||
"agentServerVersion": version,
|
||||
"agentRuntimePackage": runtime_package,
|
||||
"agentRuntimeVersion": runtime_version,
|
||||
"commandDigest": digest,
|
||||
"model": "explicit-test-model",
|
||||
"acpxVersion": QUALIFIED_ACPX_VERSION,
|
||||
"agentServerPackage": profile.agent_server_package,
|
||||
"agentServerVersion": profile.agent_server_version,
|
||||
"agentRuntimePackage": profile.agent_runtime_package,
|
||||
"agentRuntimeVersion": profile.agent_runtime_version,
|
||||
"commandDigest": profile.command_digest,
|
||||
"sidecarCommand": "/qualified/node",
|
||||
"sidecarArgs": ["/qualified/acpx-sidecar.js"],
|
||||
"runtimeDirectory": "/runtime/acpx",
|
||||
@@ -2293,7 +2301,14 @@ mod tests {
|
||||
"permissionMode": "approve-reads",
|
||||
"permissionModePinned": true,
|
||||
"runtimeContext": null,
|
||||
})
|
||||
});
|
||||
if profile.requires_provider_policy {
|
||||
value["providerPolicy"] = json!({"readOnly": true});
|
||||
}
|
||||
if agent == "cursor" {
|
||||
value["mode"] = json!("agent");
|
||||
}
|
||||
value
|
||||
}
|
||||
|
||||
fn pending_input_request(id: &str) -> Value {
|
||||
@@ -2462,6 +2477,7 @@ mod tests {
|
||||
requested_model: descriptor.model.clone(),
|
||||
effective_model: descriptor.model.clone(),
|
||||
permission_mode: Some(descriptor.permission_mode),
|
||||
mode: descriptor.mode.clone(),
|
||||
provider_lifetime_fence_candidates: [60_001, 60_002, 60_003],
|
||||
};
|
||||
let operations = Vec::new();
|
||||
@@ -2711,6 +2727,7 @@ mod tests {
|
||||
requested_model: "gpt-5.6-sol".to_owned(),
|
||||
effective_model: "gpt-5.6-sol".to_owned(),
|
||||
permission_mode: Some(AcpxPermissionMode::ApproveReads),
|
||||
mode: None,
|
||||
provider_lifetime_fence_candidates: [60_001, 60_002, 60_003],
|
||||
};
|
||||
|
||||
@@ -2727,50 +2744,130 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_admission_is_independent_of_qualification_examples() {
|
||||
for agent in ["claude", "grok", "cursor", "copilot", "codex", "pi"] {
|
||||
let original: AcpxProviderDescriptor =
|
||||
serde_json::from_value(descriptor(agent)).unwrap();
|
||||
original.validate_session(&context()).unwrap();
|
||||
let mut alternative = descriptor(agent);
|
||||
alternative["model"] = json!("explicit-model-not-in-a-catalog");
|
||||
let alternative: AcpxProviderDescriptor = serde_json::from_value(alternative).unwrap();
|
||||
alternative.validate_session(&context()).unwrap();
|
||||
for field in [
|
||||
"agentServerPackage",
|
||||
"agentServerVersion",
|
||||
"agentRuntimePackage",
|
||||
"agentRuntimeVersion",
|
||||
"commandDigest",
|
||||
"acpxVersion",
|
||||
"providerVersion",
|
||||
] {
|
||||
let mut tampered = descriptor(agent);
|
||||
tampered[field] = json!("untrusted");
|
||||
let tampered: AcpxProviderDescriptor = serde_json::from_value(tampered).unwrap();
|
||||
assert!(
|
||||
tampered.validate_session(&context()).is_err(),
|
||||
"{agent}: {field}"
|
||||
);
|
||||
}
|
||||
let mut empty = descriptor(agent);
|
||||
empty["model"] = json!(" ");
|
||||
let empty: AcpxProviderDescriptor = serde_json::from_value(empty).unwrap();
|
||||
assert!(empty.validate_session(&context()).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn admits_candidate_descriptors_only_with_explicit_policy_and_exact_distribution() {
|
||||
for (agent, package, version, digest, runtime_package, runtime_version, model) in [
|
||||
(
|
||||
"cursor",
|
||||
"cursor-agent",
|
||||
"2026.09.26-dd393fe",
|
||||
"sha256:1157a5d071abbd57ab132f22bace75c65e84cc47a045b0023475488755e14899",
|
||||
None,
|
||||
None,
|
||||
"explicit-model",
|
||||
),
|
||||
(
|
||||
"copilot",
|
||||
"@github/copilot",
|
||||
"1.0.88",
|
||||
"sha256:b18c01603dd0169d233140709cfaa8bf5304a03cf5de78ca4f625f30013e8457",
|
||||
None,
|
||||
None,
|
||||
"explicit-model",
|
||||
),
|
||||
(
|
||||
"pi",
|
||||
"pi-acp",
|
||||
"0.0.33",
|
||||
"sha256:8c696f38296d53d0061fa11534570c5ddd951b63532aed30e0f1fcc676dc169f",
|
||||
Some("@earendil-works/pi-coding-agent"),
|
||||
Some("0.84.2"),
|
||||
"openrouter/deepseek/deepseek-v4-flash-0731",
|
||||
),
|
||||
] {
|
||||
let mut value = descriptor("codex");
|
||||
value["agent"] = json!(agent);
|
||||
value["model"] = json!(model);
|
||||
value["agentServerPackage"] = json!(package);
|
||||
value["agentServerVersion"] = json!(version);
|
||||
value["agentRuntimePackage"] = json!(runtime_package);
|
||||
value["agentRuntimeVersion"] = json!(runtime_version);
|
||||
value["commandDigest"] = json!(digest);
|
||||
let missing: AcpxProviderDescriptor = serde_json::from_value(value.clone()).unwrap();
|
||||
for agent in ["cursor", "copilot", "pi"] {
|
||||
let mut value = descriptor(agent);
|
||||
let mut missing = value.clone();
|
||||
missing.as_object_mut().unwrap().remove("providerPolicy");
|
||||
let missing: AcpxProviderDescriptor = serde_json::from_value(missing).unwrap();
|
||||
assert!(missing.validate(&context()).is_err());
|
||||
value["providerPolicy"] = json!({"readOnly":true});
|
||||
let valid: AcpxProviderDescriptor = serde_json::from_value(value.clone()).unwrap();
|
||||
valid.validate(&context()).unwrap();
|
||||
if matches!(agent, "cursor" | "copilot") {
|
||||
let mut previous_v9 = value.clone();
|
||||
previous_v9["commandDigest"] = json!(match agent {
|
||||
"cursor" =>
|
||||
"sha256:a76ad26878a3b3328154901563cbda857e53583e4e01787f35a797992ef76162",
|
||||
"copilot" =>
|
||||
"sha256:98936d763497bd6f0e5605f52831a344f456357de58c457e440e69a1a468a2f4",
|
||||
_ => unreachable!(),
|
||||
});
|
||||
let previous_v9: AcpxProviderDescriptor =
|
||||
serde_json::from_value(previous_v9).unwrap();
|
||||
assert!(previous_v9.validate(&context()).is_err());
|
||||
}
|
||||
if agent == "cursor" {
|
||||
let mut previous_v8 = value.clone();
|
||||
previous_v8["commandDigest"] = json!(
|
||||
"sha256:b9e94cbcdce2783665612c85caf019c4defe37d3d245a694de590ebdf5b1f1a3"
|
||||
);
|
||||
let previous_v8: AcpxProviderDescriptor =
|
||||
serde_json::from_value(previous_v8).unwrap();
|
||||
assert!(previous_v8.validate(&context()).is_err());
|
||||
}
|
||||
let mut previous_identity = value.clone();
|
||||
previous_identity["commandDigest"] = json!(match agent {
|
||||
"cursor" =>
|
||||
"sha256:f4c7af914738149cf868d071e53ac4917658fb055224715a2c89d3f59b335503",
|
||||
"copilot" =>
|
||||
"sha256:b11721382293b39c1d6dd363eae547b5eae0ccca2cd5dcafc9127cc060ee9526",
|
||||
"pi" => "sha256:edf058835ee84de3869c4a8e8bdb71a934ffdb9f34daa37ae6faeb92371d1cdf",
|
||||
_ => unreachable!(),
|
||||
});
|
||||
let previous_identity: AcpxProviderDescriptor =
|
||||
serde_json::from_value(previous_identity).unwrap();
|
||||
assert!(previous_identity.validate(&context()).is_err());
|
||||
let mut previous_contract = value.clone();
|
||||
previous_contract["commandDigest"] = json!(match agent {
|
||||
"cursor" =>
|
||||
"sha256:377dcea64a727ce799cc112458d4b40ba4bc6574cd6c6f7233b6efd5917a6c4b",
|
||||
"copilot" =>
|
||||
"sha256:ece77e40876631a69a828b91813722001d71b6fc81be47ecd4b3dced84ff9473",
|
||||
"pi" => "sha256:843d30e419914529755c9827d9151a306da1b50b643be7eab1abe797641a37ce",
|
||||
_ => unreachable!(),
|
||||
});
|
||||
let previous_contract: AcpxProviderDescriptor =
|
||||
serde_json::from_value(previous_contract).unwrap();
|
||||
assert!(previous_contract.validate(&context()).is_err());
|
||||
if agent == "cursor" {
|
||||
assert_eq!(valid.public_descriptor(None)["mode"], json!("agent"));
|
||||
let mut previous_v4 = value.clone();
|
||||
previous_v4["commandDigest"] = json!(
|
||||
"sha256:b1440d559ebc4eef5c7a582f1c81fc153270cfbafa1731a8ee76d83713bdf61b"
|
||||
);
|
||||
let previous_v4: AcpxProviderDescriptor =
|
||||
serde_json::from_value(previous_v4).unwrap();
|
||||
assert!(previous_v4.validate(&context()).is_err());
|
||||
let mut missing = value.clone();
|
||||
missing.as_object_mut().unwrap().remove("mode");
|
||||
let missing: AcpxProviderDescriptor = serde_json::from_value(missing).unwrap();
|
||||
missing.validate(&context()).unwrap();
|
||||
let mut opaque = value.clone();
|
||||
opaque["mode"] = json!("architect");
|
||||
serde_json::from_value::<AcpxProviderDescriptor>(opaque)
|
||||
.unwrap()
|
||||
.validate(&context())
|
||||
.unwrap();
|
||||
let mut invalid = value.clone();
|
||||
invalid["mode"] = json!("");
|
||||
assert!(serde_json::from_value::<AcpxProviderDescriptor>(invalid)
|
||||
.unwrap()
|
||||
.validate(&context())
|
||||
.is_err());
|
||||
} else {
|
||||
assert!(valid.public_descriptor(None).get("mode").is_none());
|
||||
let mut wrong_agent = value.clone();
|
||||
wrong_agent["mode"] = json!("plan");
|
||||
let other_agent: AcpxProviderDescriptor =
|
||||
serde_json::from_value(wrong_agent).unwrap();
|
||||
other_agent.validate(&context()).unwrap();
|
||||
}
|
||||
|
||||
for field in ["model", "agentServerVersion", "commandDigest"] {
|
||||
let mut wrong = value.clone();
|
||||
wrong[field] = json!("");
|
||||
@@ -2898,6 +2995,7 @@ mod tests {
|
||||
requested_model: original_descriptor.model.clone(),
|
||||
effective_model: original_descriptor.model.clone(),
|
||||
permission_mode: Some(original_descriptor.permission_mode),
|
||||
mode: original_descriptor.mode.clone(),
|
||||
provider_lifetime_fence_candidates: [60_001, 60_002, 60_003],
|
||||
};
|
||||
let operations = Vec::new();
|
||||
@@ -3056,6 +3154,273 @@ mod tests {
|
||||
fs::remove_dir_all(directory).unwrap();
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn cursor_attachment_rotates_only_authenticated_run_grants() {
|
||||
let directory = temporary_directory("cursor-cross-run-attach");
|
||||
let runtime = directory.join("runtime");
|
||||
let workspace = directory.join("workspace");
|
||||
fs::create_dir_all(&runtime).unwrap();
|
||||
fs::create_dir_all(&workspace).unwrap();
|
||||
fs::set_permissions(&runtime, fs::Permissions::from_mode(0o700)).unwrap();
|
||||
fs::set_permissions(&workspace, fs::Permissions::from_mode(0o700)).unwrap();
|
||||
let marker = directory.join("provider-started");
|
||||
let command = directory.join("sidecar");
|
||||
write_artifact(
|
||||
&command,
|
||||
format!("#!/bin/sh\ntouch '{}'\n", marker.display()).as_bytes(),
|
||||
true,
|
||||
);
|
||||
let launch_profile = AcpxLaunchProfile {
|
||||
authority_digest: format!("sha256:{}", "d".repeat(64)),
|
||||
command: command.clone(),
|
||||
args: Vec::new(),
|
||||
artifacts: vec![artifact(&command)],
|
||||
};
|
||||
let mut descriptor_value = descriptor("cursor");
|
||||
descriptor_value["sidecarCommand"] = json!(command);
|
||||
descriptor_value["runtimeContext"] = json!({ "instructions": { "digest": "stable" }, "mcp": { "digest": "before" }, "aggregateDigest": "before" });
|
||||
descriptor_value["sidecarArgs"] = json!([]);
|
||||
descriptor_value["runtimeDirectory"] = json!(runtime);
|
||||
descriptor_value["cwd"] = json!(workspace);
|
||||
let prior_root = directory.join("old-registered-copy");
|
||||
let current_root = directory.join("new-registered-copy");
|
||||
fs::create_dir_all(&prior_root).unwrap();
|
||||
fs::create_dir_all(¤t_root).unwrap();
|
||||
descriptor_value["instructions"] = json!(format!(
|
||||
"Current AGENT_HOME: {}. Custom entry.",
|
||||
prior_root.display()
|
||||
));
|
||||
descriptor_value["runtimeContext"] = json!({
|
||||
"aggregateDigest": "a".repeat(64),
|
||||
"prompt": {"revision": "pinned", "digest": "b".repeat(64)},
|
||||
"instructions": {
|
||||
"entryPath": "AGENTS.md",
|
||||
"bundle": {"digest": "c".repeat(64), "rootPath": "/old-bundle"},
|
||||
"workingCopy": {"kind": "agent_files", "entryPath": "AGENTS.md", "rootPath": prior_root},
|
||||
},
|
||||
"skills": [{"key": "skill-1", "bundle": {"digest": "d".repeat(64), "rootPath": "/old-skill"}}],
|
||||
"mcp": {"assignmentSetId": "assignment-1", "digest": "e".repeat(64), "bindingId": "old-run-binding"},
|
||||
"futurePolicy": {"companyId": "company-1"},
|
||||
});
|
||||
let original_descriptor: AcpxProviderDescriptor =
|
||||
serde_json::from_value(descriptor_value.clone()).unwrap();
|
||||
let identity = AcpxProviderSessionIdentity {
|
||||
kind: "acpx".to_owned(),
|
||||
normalized_session_id: "session-1".to_owned(),
|
||||
acpx_record_id: "record-1".to_owned(),
|
||||
backend_session_id: "backend-1".to_owned(),
|
||||
agent_session_id: "agent-1".to_owned(),
|
||||
profile_digest: original_descriptor.command_digest.clone(),
|
||||
workspace_digest: format!("sha256:{}", "a".repeat(64)),
|
||||
requested_model: original_descriptor.model.clone(),
|
||||
effective_model: original_descriptor.model.clone(),
|
||||
permission_mode: Some(original_descriptor.permission_mode),
|
||||
mode: original_descriptor.mode.clone(),
|
||||
provider_lifetime_fence_candidates: [60_001, 60_002, 60_003],
|
||||
};
|
||||
let operations = Vec::new();
|
||||
let tool_set = AuthorizedToolSet {
|
||||
schema: TOOL_SET_SCHEMA.to_owned(),
|
||||
schema_version: 1,
|
||||
catalog_digest: authorized_tool_catalog_digest(&operations).unwrap(),
|
||||
operations,
|
||||
};
|
||||
let launch_profile_digest = launch_profile.canonical_digest().unwrap();
|
||||
let mut state = AcpxDurableState::new(original_descriptor, tool_set, launch_profile_digest);
|
||||
state.lifecycle = "suspended".to_owned();
|
||||
state.identity = Some(identity);
|
||||
let original_config = test_config(&directory, Some(launch_profile.clone()));
|
||||
let mut original = AcpxCommandExecutor::with_runner_config(&directory, &original_config);
|
||||
original.state = Some(state);
|
||||
let settled = original.state.clone().unwrap();
|
||||
let ready = original.snapshot().unwrap().result;
|
||||
assert_eq!(ready["warmAttachReady"], true);
|
||||
assert_eq!(ready["warmAttachBlockers"], json!([]));
|
||||
let blocked_states: [(&str, fn(&mut AcpxDurableState)); 4] = [
|
||||
("durable_closed", |state| {
|
||||
state.lifecycle = "closed".to_owned()
|
||||
}),
|
||||
("provider_exit_unconfirmed", |state| {
|
||||
state.lifecycle = "prepared".to_owned();
|
||||
state.provider_exit_unconfirmed = true;
|
||||
}),
|
||||
("provider_identity_unavailable", |state| {
|
||||
state.lifecycle = "prepared".to_owned();
|
||||
state.identity = None;
|
||||
}),
|
||||
("durable_active_turn", |state| {
|
||||
state.lifecycle = "turn_active".to_owned();
|
||||
state.active_turn_id = Some("turn-1".to_owned());
|
||||
}),
|
||||
];
|
||||
for (blocker, mutate) in blocked_states {
|
||||
original.state = Some(settled.clone());
|
||||
mutate(original.state.as_mut().unwrap());
|
||||
let snapshot = original.snapshot().unwrap().result;
|
||||
assert_eq!(snapshot["warmAttachReady"], false, "{blocker}");
|
||||
assert_eq!(snapshot["warmAttachBlockers"], json!([blocker]));
|
||||
}
|
||||
// A readiness probe must retain the old authority's audit events until
|
||||
// the durable runner commits and acknowledges them, including recovery
|
||||
// notices that run.attach itself is allowed to consume.
|
||||
for event_type in ["session.resumed", "harness.diagnostic"] {
|
||||
original.state = Some(settled.clone());
|
||||
original
|
||||
.state
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.push(NormalizedProviderEvent {
|
||||
event_type: event_type.to_owned(),
|
||||
priority: EventPriority::P0,
|
||||
payload: json!({}),
|
||||
})
|
||||
.unwrap();
|
||||
let snapshot = original.snapshot().unwrap().result;
|
||||
assert_eq!(snapshot["warmAttachReady"], false);
|
||||
assert_eq!(
|
||||
snapshot["warmAttachBlockers"],
|
||||
json!(["durable_pending_events"])
|
||||
);
|
||||
assert_eq!(original.retained_events().unwrap().len(), 1);
|
||||
original.acknowledge_events(1).unwrap();
|
||||
assert_eq!(original.snapshot().unwrap().result["warmAttachReady"], true);
|
||||
}
|
||||
original.state = Some(settled);
|
||||
assert!(!marker.exists(), "readiness must not start a provider");
|
||||
original.save_state().unwrap();
|
||||
|
||||
let mut wrong_session_config = original_config.clone();
|
||||
wrong_session_config.run_id = "run-2".to_owned();
|
||||
wrong_session_config.normalized_session_id = "session-2".to_owned();
|
||||
let mut wrong_session =
|
||||
AcpxCommandExecutor::with_runner_config(&directory, &wrong_session_config);
|
||||
assert!(wrong_session.restore().is_err());
|
||||
|
||||
let mut attached_config = original_config.clone();
|
||||
attached_config.run_id = "run-2".to_owned();
|
||||
let mut attached = AcpxCommandExecutor::with_runner_config(&directory, &attached_config);
|
||||
attached.restore().unwrap();
|
||||
assert!(!marker.exists());
|
||||
let non_attach_error = attached
|
||||
.execute(&Command {
|
||||
schema: "paperclip.prp.command.v1".to_owned(),
|
||||
command_id: "command-before-attach".to_owned(),
|
||||
controller_seq: 1,
|
||||
command_type: "session.snapshot".to_owned(),
|
||||
issued_at: "2026-09-01T00:00:00.000Z".to_owned(),
|
||||
deadline_at: None,
|
||||
precondition: None,
|
||||
payload: json!({}),
|
||||
})
|
||||
.unwrap_err();
|
||||
assert!(non_attach_error
|
||||
.to_string()
|
||||
.contains("requires run.attach before commands from a new run"));
|
||||
|
||||
descriptor_value["runId"] = json!("run-2");
|
||||
fs::remove_dir_all(&prior_root).unwrap();
|
||||
descriptor_value["instructions"] = json!(format!(
|
||||
"Current AGENT_HOME: {}. Fresh custom entry.",
|
||||
current_root.display()
|
||||
));
|
||||
descriptor_value["runtimeContext"]["instructions"]["workingCopy"]["rootPath"] =
|
||||
json!(current_root);
|
||||
descriptor_value["runtimeContext"]["instructions"]["bundle"]["rootPath"] =
|
||||
json!("/new-bundle");
|
||||
descriptor_value["runtimeContext"]["skills"][0]["bundle"]["rootPath"] = json!("/new-skill");
|
||||
descriptor_value["runtimeContext"]["mcp"]["bindingId"] = json!("new-run-binding");
|
||||
attached
|
||||
.attach_run(&json!({"provider": descriptor_value}))
|
||||
.unwrap();
|
||||
assert_eq!(attached.state.as_ref().unwrap().descriptor.run_id, "run-2");
|
||||
assert!(!marker.exists());
|
||||
let refreshed = &attached.state.as_ref().unwrap().descriptor;
|
||||
assert_eq!(
|
||||
refreshed.runtime_context,
|
||||
descriptor_value["runtimeContext"]
|
||||
);
|
||||
assert_eq!(refreshed.instructions, descriptor_value["instructions"]);
|
||||
let session_config = refreshed
|
||||
.session_config(
|
||||
attached.state.as_ref().unwrap().tool_set.clone(),
|
||||
attached.state.as_ref().unwrap().identity.clone(),
|
||||
Some(&launch_profile),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
session_config.runtime_context,
|
||||
descriptor_value["runtimeContext"]
|
||||
);
|
||||
assert_eq!(
|
||||
session_config.system_instructions,
|
||||
descriptor_value["instructions"]
|
||||
);
|
||||
// Both persistence and the sidecar launch config receive the new grant.
|
||||
let persisted: AcpxDurableState =
|
||||
serde_json::from_slice(&fs::read(attached.state_path()).unwrap()).unwrap();
|
||||
assert_eq!(
|
||||
persisted.descriptor.runtime_context,
|
||||
descriptor_value["runtimeContext"]
|
||||
);
|
||||
let mut same_run_mutation = descriptor_value.clone();
|
||||
same_run_mutation["instructions"] = json!("A different grant in the same run");
|
||||
assert!(attached
|
||||
.attach_run(&json!({"provider": same_run_mutation}))
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("new authenticated run"));
|
||||
|
||||
// In-place warm handoff executes under the old authority. Only the
|
||||
// authenticated next-authority boundary may admit the new descriptor;
|
||||
// event correlation stays on run-1 until durable activation completes.
|
||||
assert!(original
|
||||
.attach_run(&json!({"provider": descriptor_value}))
|
||||
.is_err());
|
||||
let warm_payload = json!({
|
||||
"provider": descriptor_value,
|
||||
"paperclipNextAuthority": {
|
||||
"identity": {
|
||||
"runnerInstanceId": original_config.runner_instance_id,
|
||||
"environmentLeaseId": original_config.environment_lease_id,
|
||||
"runId": "run-2",
|
||||
"normalizedSessionId": original_config.normalized_session_id,
|
||||
"turnId": "turn-2",
|
||||
"itemId": "item-2",
|
||||
},
|
||||
"connection": {"mode": "connect", "connectUrl": original_config.connect_url},
|
||||
},
|
||||
});
|
||||
let mut wrong_run = warm_payload.clone();
|
||||
wrong_run["paperclipNextAuthority"]["identity"]["runId"] = json!("run-3");
|
||||
assert!(original.attach_run(&wrong_run).is_err());
|
||||
let mut wrong_session = warm_payload.clone();
|
||||
wrong_session["paperclipNextAuthority"]["identity"]["normalizedSessionId"] =
|
||||
json!("other-session");
|
||||
assert!(original.attach_run(&wrong_session).is_err());
|
||||
let mut changed_profile = warm_payload.clone();
|
||||
changed_profile["provider"]["cwd"] = json!("/different-workspace");
|
||||
assert!(original.attach_run(&changed_profile).is_err());
|
||||
let mut refreshed = warm_payload.clone();
|
||||
refreshed["provider"]["runtimeContext"]["mcp"] = json!({ "digest": "after" });
|
||||
refreshed["provider"]["runtimeContext"]["aggregateDigest"] = json!("after");
|
||||
let mut changed_context = refreshed.clone();
|
||||
changed_context["provider"]["runtimeContext"]["instructions"] =
|
||||
json!({ "digest": "changed" });
|
||||
assert!(original.attach_run(&changed_context).is_err());
|
||||
original.attach_run(&refreshed).unwrap();
|
||||
assert_eq!(
|
||||
original.state.as_ref().unwrap().descriptor.runtime_context["mcp"]["digest"],
|
||||
"after"
|
||||
);
|
||||
assert_eq!(original.state.as_ref().unwrap().descriptor.run_id, "run-2");
|
||||
assert_eq!(original.context.run_id, "run-1");
|
||||
original.rotate_authority(&attached_config);
|
||||
assert_eq!(original.context.run_id, "run-2");
|
||||
assert!(!marker.exists());
|
||||
fs::remove_dir_all(directory).unwrap();
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn active_turn_recovery_closes_without_starting_the_provider() {
|
||||
@@ -3098,6 +3463,7 @@ mod tests {
|
||||
requested_model: descriptor.model.clone(),
|
||||
effective_model: descriptor.model.clone(),
|
||||
permission_mode: Some(descriptor.permission_mode),
|
||||
mode: descriptor.mode.clone(),
|
||||
provider_lifetime_fence_candidates,
|
||||
};
|
||||
let operations = Vec::new();
|
||||
@@ -3269,6 +3635,7 @@ mod tests {
|
||||
requested_model: provider_descriptor.model.clone(),
|
||||
effective_model: provider_descriptor.model.clone(),
|
||||
permission_mode: Some(provider_descriptor.permission_mode),
|
||||
mode: provider_descriptor.mode.clone(),
|
||||
provider_lifetime_fence_candidates,
|
||||
});
|
||||
state.provider_exit_unconfirmed = true;
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
//! Runner-owned provider admission metadata. These capabilities are selected
|
||||
//! only after the descriptor's pinned profile and launch authority validate;
|
||||
//! they cannot be enabled by a sidecar event or a caller-supplied flag.
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum RunAttachmentPolicy {
|
||||
ImmutableInstructions,
|
||||
AuthenticatedRunGrants,
|
||||
}
|
||||
|
||||
pub(crate) fn run_attachment_policy(agent: &str) -> RunAttachmentPolicy {
|
||||
match agent {
|
||||
"cursor" => RunAttachmentPolicy::AuthenticatedRunGrants,
|
||||
_ => RunAttachmentPolicy::ImmutableInstructions,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn only_qualified_providers_opt_into_run_grant_rotation() {
|
||||
assert_eq!(
|
||||
run_attachment_policy("cursor"),
|
||||
RunAttachmentPolicy::AuthenticatedRunGrants
|
||||
);
|
||||
for agent in ["claude", "codex", "grok", "pi", "copilot", "unknown"] {
|
||||
assert_eq!(
|
||||
run_attachment_policy(agent),
|
||||
RunAttachmentPolicy::ImmutableInstructions
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -48,6 +48,8 @@ struct PersistedAcpxProviderSessionIdentity {
|
||||
requested_model: String,
|
||||
effective_model: String,
|
||||
permission_mode: AcpxPermissionMode,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
mode: Option<String>,
|
||||
provider_lifetime_fence_candidates: [u16; 3],
|
||||
}
|
||||
|
||||
@@ -70,6 +72,7 @@ impl PersistedAcpxProviderSessionIdentity {
|
||||
requested_model: identity.requested_model,
|
||||
effective_model: identity.effective_model,
|
||||
permission_mode,
|
||||
mode: identity.mode,
|
||||
provider_lifetime_fence_candidates: identity.provider_lifetime_fence_candidates,
|
||||
})
|
||||
}
|
||||
@@ -86,6 +89,7 @@ impl PersistedAcpxProviderSessionIdentity {
|
||||
requested_model: self.requested_model.clone(),
|
||||
effective_model: self.effective_model.clone(),
|
||||
permission_mode: Some(self.permission_mode),
|
||||
mode: self.mode.clone(),
|
||||
provider_lifetime_fence_candidates: self.provider_lifetime_fence_candidates,
|
||||
}
|
||||
}
|
||||
@@ -106,6 +110,7 @@ impl AcpxSuspensionCheckpoint {
|
||||
|| identity.requested_model != config.model
|
||||
|| identity.effective_model != config.model
|
||||
|| identity.permission_mode != Some(config.permission_mode)
|
||||
|| identity.mode != config.mode
|
||||
|| config
|
||||
.expected_identity
|
||||
.as_ref()
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use crate::generated_acpx_profiles::acpx_release_profile;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -10,7 +11,8 @@ use crate::acpx_provider_state::{
|
||||
};
|
||||
use crate::acpx_sidecar_transport::{AcpxSidecarTransport, AcpxSidecarTransportConfig};
|
||||
use crate::generated_acpx_sidecar_contract::{
|
||||
GeneratedAcpxSidecarCommand, GENERATED_ACPX_SIDECAR_PROTOCOL_VERSION,
|
||||
GeneratedAcpxSidecarCommand, GeneratedAcpxSidecarEventType,
|
||||
GENERATED_ACPX_SIDECAR_PROTOCOL_VERSION,
|
||||
};
|
||||
use crate::local_runner::LocalRunnerError;
|
||||
use crate::provider_bridge::{
|
||||
@@ -35,7 +37,7 @@ pub enum AcpxPermissionMode {
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct AcpxProviderSessionIdentity {
|
||||
pub kind: String,
|
||||
pub normalized_session_id: String,
|
||||
@@ -48,6 +50,8 @@ pub struct AcpxProviderSessionIdentity {
|
||||
pub effective_model: String,
|
||||
#[serde(default)]
|
||||
pub permission_mode: Option<AcpxPermissionMode>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub mode: Option<String>,
|
||||
pub provider_lifetime_fence_candidates: [u16; 3],
|
||||
}
|
||||
|
||||
@@ -68,6 +72,7 @@ pub struct AcpxProviderSessionConfig {
|
||||
pub normalized_session_id: String,
|
||||
pub working_directory: PathBuf,
|
||||
pub permission_mode: AcpxPermissionMode,
|
||||
pub mode: Option<String>,
|
||||
pub permission_mode_pinned: bool,
|
||||
pub provider_policy: Option<AcpxProviderRuntimePolicy>,
|
||||
pub system_instructions: String,
|
||||
@@ -79,28 +84,14 @@ pub struct AcpxProviderSessionConfig {
|
||||
impl AcpxProviderSessionConfig {
|
||||
pub fn validate(&self) -> Result<(), LocalRunnerError> {
|
||||
self.transport.validate()?;
|
||||
let qualified_model = match self.agent.as_str() {
|
||||
"claude" => "claude-sonnet-5",
|
||||
"grok" => "grok-4.7",
|
||||
"codex" => "gpt-5.6-sol",
|
||||
"pi" => "openrouter/deepseek/deepseek-v4-flash-0731",
|
||||
"cursor" | "copilot" => self.model.as_str(),
|
||||
_ => {
|
||||
return Err(LocalRunnerError::invalid(
|
||||
"ACPX agent must name a known immutable profile",
|
||||
))
|
||||
}
|
||||
};
|
||||
if self.agent != "claude" && self.agent != "grok" && self.model != qualified_model {
|
||||
return Err(LocalRunnerError::invalid(format!(
|
||||
"ACPX {} profile requires exact model {qualified_model}",
|
||||
self.agent
|
||||
)));
|
||||
}
|
||||
let profile = acpx_release_profile(&self.agent).ok_or_else(|| {
|
||||
LocalRunnerError::invalid("ACPX agent must name a known immutable profile")
|
||||
})?;
|
||||
validate_text(&self.model, MAX_MODEL_CHARS, "ACPX model")?;
|
||||
if matches!(self.agent.as_str(), "pi" | "cursor" | "copilot")
|
||||
&& self.provider_policy.is_none()
|
||||
{
|
||||
if let Some(mode) = self.mode.as_deref() {
|
||||
validate_text(mode, MAX_ID_CHARS, "ACPX provider mode")?;
|
||||
}
|
||||
if profile.requires_provider_policy && self.provider_policy.is_none() {
|
||||
return Err(LocalRunnerError::invalid(
|
||||
"ACPX candidate requires explicit provider read-only policy",
|
||||
));
|
||||
@@ -169,6 +160,7 @@ impl AcpxProviderSessionConfig {
|
||||
|| expected_identity.requested_model != self.model
|
||||
|| expected_identity.effective_model != self.model
|
||||
|| expected_identity.permission_mode != Some(self.permission_mode)
|
||||
|| expected_identity.mode != self.mode
|
||||
{
|
||||
return Err(LocalRunnerError::invalid(
|
||||
"ACPX expected identity conflicts with the requested session",
|
||||
@@ -186,6 +178,9 @@ impl AcpxProviderSessionIdentity {
|
||||
"ACPX session identity kind is invalid",
|
||||
));
|
||||
}
|
||||
if let Some(mode) = self.mode.as_deref() {
|
||||
validate_text(mode, MAX_ID_CHARS, "ACPX provider mode")?;
|
||||
}
|
||||
for (value, label) in [
|
||||
(&self.normalized_session_id, "normalized session"),
|
||||
(&self.acpx_record_id, "record"),
|
||||
@@ -261,6 +256,7 @@ pub struct AcpxProviderSession {
|
||||
working_directory: PathBuf,
|
||||
closed: bool,
|
||||
transport_terminated: bool,
|
||||
runtime_retired: bool,
|
||||
}
|
||||
|
||||
impl AcpxProviderSession {
|
||||
@@ -295,9 +291,14 @@ impl AcpxProviderSession {
|
||||
working_directory: config.working_directory.clone(),
|
||||
closed: false,
|
||||
transport_terminated: false,
|
||||
runtime_retired: false,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn runtime_retired(&self) -> bool {
|
||||
self.runtime_retired
|
||||
}
|
||||
|
||||
pub fn process_id(&self) -> u32 {
|
||||
self.transport.process_id()
|
||||
}
|
||||
@@ -343,6 +344,11 @@ impl AcpxProviderSession {
|
||||
working_directory: &Path,
|
||||
) -> Result<Value, LocalRunnerError> {
|
||||
self.ensure_open()?;
|
||||
if self.runtime_retired {
|
||||
return Err(LocalRunnerError::invalid(
|
||||
"ACPX provider runtime was retired by cancellation",
|
||||
));
|
||||
}
|
||||
validate_stable_id(turn_id, DURABLE_STABLE_ID_CHARS, "ACPX turn id")?;
|
||||
validate_turn_message(message)?;
|
||||
if working_directory != self.working_directory {
|
||||
@@ -514,6 +520,9 @@ impl AcpxProviderSession {
|
||||
"ACPX sidecar did not confirm turn cancellation",
|
||||
)));
|
||||
}
|
||||
// Polling still owns the terminal frame queued before this response.
|
||||
// Only future prompt admission is revoked by the confirmed close.
|
||||
self.runtime_retired = response.get("sessionClosed").and_then(Value::as_bool) == Some(true);
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
@@ -526,9 +535,27 @@ impl AcpxProviderSession {
|
||||
Ok(event) => event,
|
||||
Err(error) => return Err(self.fail_closed(error)),
|
||||
};
|
||||
let Some(event) = event else {
|
||||
let Some(mut event) = event else {
|
||||
return Ok(None);
|
||||
};
|
||||
if event.event_type == GeneratedAcpxSidecarEventType::RuntimePermissionRequested {
|
||||
// Authority comes from this admitted connection's profile. A sidecar
|
||||
// claim cannot relabel another provider; old frames may omit origin.
|
||||
let origin = json!({"adapter":"acpx-runtime-sidecar", "provider":self.config.agent,
|
||||
"method":"session/request_permission"});
|
||||
if event
|
||||
.payload
|
||||
.get("origin")
|
||||
.is_some_and(|claimed| claimed != &origin)
|
||||
{
|
||||
return Err(self.fail_closed(LocalRunnerError::invalid(
|
||||
"ACPX permission origin conflicts with the admitted provider profile",
|
||||
)));
|
||||
}
|
||||
if let Some(payload) = event.payload.as_object_mut() {
|
||||
payload.insert("origin".to_owned(), origin);
|
||||
}
|
||||
}
|
||||
let mut next_state = self.state.clone();
|
||||
let events = match next_state.accept_event(&event) {
|
||||
Ok(events) => events,
|
||||
@@ -1201,6 +1228,27 @@ impl Drop for AcpxProviderSession {
|
||||
}
|
||||
}
|
||||
|
||||
fn session_open_params(config: &AcpxProviderSessionConfig, sidecar_tools: &[Value]) -> Value {
|
||||
let mut params = json!({
|
||||
"runtimeDirectory": config.runtime_directory,
|
||||
"normalizedSessionId": config.normalized_session_id,
|
||||
"workingDirectory": config.working_directory,
|
||||
"agent": config.agent,
|
||||
"model": config.model,
|
||||
"permissionMode": config.permission_mode,
|
||||
"permissionModePinned": config.permission_mode_pinned,
|
||||
"providerPolicy": config.provider_policy,
|
||||
"systemInstructions": config.system_instructions,
|
||||
"runtimeContext": config.runtime_context,
|
||||
"tools": &sidecar_tools,
|
||||
"expectedIdentity": config.expected_identity,
|
||||
});
|
||||
if let Some(mode) = config.mode.as_deref() {
|
||||
params["mode"] = json!(mode);
|
||||
}
|
||||
params
|
||||
}
|
||||
|
||||
fn bootstrap(
|
||||
transport: &mut AcpxSidecarTransport,
|
||||
config: &AcpxProviderSessionConfig,
|
||||
@@ -1221,20 +1269,7 @@ fn bootstrap(
|
||||
|
||||
let opened = transport.request(
|
||||
GeneratedAcpxSidecarCommand::SessionOpen,
|
||||
json!({
|
||||
"runtimeDirectory": config.runtime_directory,
|
||||
"normalizedSessionId": config.normalized_session_id,
|
||||
"workingDirectory": config.working_directory,
|
||||
"agent": config.agent,
|
||||
"model": config.model,
|
||||
"permissionMode": config.permission_mode,
|
||||
"permissionModePinned": config.permission_mode_pinned,
|
||||
"providerPolicy": config.provider_policy,
|
||||
"systemInstructions": config.system_instructions,
|
||||
"runtimeContext": config.runtime_context,
|
||||
"tools": &sidecar_tools,
|
||||
"expectedIdentity": config.expected_identity,
|
||||
}),
|
||||
session_open_params(config, &sidecar_tools),
|
||||
)?;
|
||||
let identity = verify_open_response(&opened, transport.process_id(), config)?;
|
||||
let turn_controls = verified_turn_controls(opened.get("turnControls"), &config.agent)?;
|
||||
@@ -1320,6 +1355,7 @@ fn verify_open_response(
|
||||
|| identity.requested_model != config.model
|
||||
|| identity.effective_model != config.model
|
||||
|| identity.permission_mode != Some(config.permission_mode)
|
||||
|| identity.mode != config.mode
|
||||
|| config
|
||||
.expected_identity
|
||||
.as_ref()
|
||||
@@ -1542,3 +1578,169 @@ mod permission_mode_tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod mode_tests {
|
||||
use super::*;
|
||||
|
||||
fn config() -> AcpxProviderSessionConfig {
|
||||
let operations = Vec::new();
|
||||
AcpxProviderSessionConfig {
|
||||
transport: AcpxSidecarTransportConfig {
|
||||
command: std::env::current_exe().unwrap(),
|
||||
args: Vec::new(),
|
||||
verified_launch: None,
|
||||
request_timeout: Duration::from_secs(1),
|
||||
shutdown_grace: Duration::from_millis(100),
|
||||
},
|
||||
agent: "cursor".to_owned(),
|
||||
model: "explicit-model".to_owned(),
|
||||
run_id: "run-1".to_owned(),
|
||||
catalog_revision: 1,
|
||||
runtime_directory: std::env::temp_dir(),
|
||||
normalized_session_id: "session-1".to_owned(),
|
||||
working_directory: std::env::temp_dir(),
|
||||
permission_mode: AcpxPermissionMode::ApproveReads,
|
||||
mode: Some("plan".to_owned()),
|
||||
permission_mode_pinned: true,
|
||||
provider_policy: Some(AcpxProviderRuntimePolicy { read_only: false }),
|
||||
system_instructions: String::new(),
|
||||
runtime_context: Value::Null,
|
||||
tool_set: AuthorizedToolSet {
|
||||
schema: TOOL_SET_SCHEMA.to_owned(),
|
||||
schema_version: 1,
|
||||
catalog_digest: authorized_tool_catalog_digest(&operations).unwrap(),
|
||||
operations,
|
||||
},
|
||||
expected_identity: None,
|
||||
}
|
||||
}
|
||||
fn identity() -> AcpxProviderSessionIdentity {
|
||||
AcpxProviderSessionIdentity {
|
||||
kind: "acpx".to_owned(),
|
||||
normalized_session_id: "session-1".to_owned(),
|
||||
acpx_record_id: "record-1".to_owned(),
|
||||
backend_session_id: "backend-1".to_owned(),
|
||||
agent_session_id: "agent-1".to_owned(),
|
||||
profile_digest: format!("sha256:{}", "1".repeat(64)),
|
||||
workspace_digest: format!("sha256:{}", "2".repeat(64)),
|
||||
requested_model: "explicit-model".to_owned(),
|
||||
effective_model: "explicit-model".to_owned(),
|
||||
permission_mode: Some(AcpxPermissionMode::ApproveReads),
|
||||
mode: Some("plan".to_owned()),
|
||||
provider_lifetime_fence_candidates: [60_001, 60_002, 60_003],
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn model_is_explicit_and_provider_verified_for_every_agent() {
|
||||
let mut config = config();
|
||||
for agent in ["claude", "codex", "pi", "grok", "cursor", "copilot"] {
|
||||
config.agent = agent.to_owned();
|
||||
config.model = "custom/model[context=272k,reasoning=medium]".to_owned();
|
||||
config.validate().unwrap();
|
||||
assert_eq!(
|
||||
session_open_params(&config, &[])["model"],
|
||||
json!(config.model)
|
||||
);
|
||||
for invalid in [
|
||||
"".to_owned(),
|
||||
" ".to_owned(),
|
||||
"x".repeat(241),
|
||||
"model\0".to_owned(),
|
||||
] {
|
||||
config.model = invalid;
|
||||
assert!(config.validate().is_err());
|
||||
}
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn mode_is_opaque_bounded_and_not_defaulted_in_rust() {
|
||||
let mut config = config();
|
||||
// Generic transport accepts ids beyond Cursor's vocabulary and leaves
|
||||
// capability checks and native translation to the provider adapter.
|
||||
for agent in ["cursor", "copilot"] {
|
||||
config.agent = agent.to_owned();
|
||||
for mode in [
|
||||
None,
|
||||
Some("architect".to_owned()),
|
||||
Some("custom/build".to_owned()),
|
||||
] {
|
||||
config.mode = mode;
|
||||
config.validate().unwrap();
|
||||
}
|
||||
}
|
||||
for invalid in [
|
||||
"".to_owned(),
|
||||
" ".to_owned(),
|
||||
"x".repeat(241),
|
||||
"plan\0".to_owned(),
|
||||
] {
|
||||
config.mode = Some(invalid.clone());
|
||||
assert!(config.validate().is_err());
|
||||
let mut identity = identity();
|
||||
identity.mode = Some(invalid);
|
||||
assert!(identity.validate().is_err());
|
||||
}
|
||||
for invalid in [json!(1), json!({})] {
|
||||
let mut value = serde_json::to_value(identity()).unwrap();
|
||||
value["mode"] = invalid;
|
||||
assert!(serde_json::from_value::<AcpxProviderSessionIdentity>(value).is_err());
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn open_wire_and_identity_bind_exact_mode() {
|
||||
let mut config = config();
|
||||
for mode in [
|
||||
"agent".to_owned(),
|
||||
"architect".to_owned(),
|
||||
"custom/build".to_owned(),
|
||||
] {
|
||||
config.mode = Some(mode.clone());
|
||||
assert_eq!(session_open_params(&config, &[])["mode"], json!(mode));
|
||||
let mut identity = identity();
|
||||
identity.mode = Some(mode.clone());
|
||||
let response = json!({"sidecarPid": 100, "status": {}, "identity": identity});
|
||||
assert_eq!(
|
||||
verify_open_response(&response, 100, &config).unwrap().mode,
|
||||
Some(mode.clone())
|
||||
);
|
||||
for wrong in [
|
||||
None,
|
||||
Some("agent".to_owned()),
|
||||
Some("plan".to_owned()),
|
||||
Some("ask".to_owned()),
|
||||
] {
|
||||
if wrong == Some(mode.clone()) {
|
||||
continue;
|
||||
}
|
||||
let mut changed = response.clone();
|
||||
changed["identity"]["mode"] = json!(wrong);
|
||||
assert!(verify_open_response(&changed, 100, &config).is_err());
|
||||
}
|
||||
}
|
||||
config.agent = "copilot".to_owned();
|
||||
config.mode = None;
|
||||
assert!(session_open_params(&config, &[]).get("mode").is_none());
|
||||
let mut other = identity();
|
||||
other.mode = None;
|
||||
assert!(serde_json::to_value(&other).unwrap().get("mode").is_none());
|
||||
let response = json!({"sidecarPid": 100, "status": {}, "identity": other});
|
||||
verify_open_response(&response, 100, &config).unwrap();
|
||||
}
|
||||
#[test]
|
||||
fn warm_reopen_and_suspension_reject_mode_changes() {
|
||||
let mut config = config();
|
||||
let identity = identity();
|
||||
config.expected_identity = Some(identity.clone());
|
||||
config.validate().unwrap();
|
||||
config.mode = Some("ask".to_owned());
|
||||
assert!(config.validate().is_err());
|
||||
let mut changed = identity.clone();
|
||||
changed.mode = Some("agent".to_owned());
|
||||
assert!(verify_suspend_response(
|
||||
&json!({"suspended": true, "identity": changed}),
|
||||
&identity
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
}
|
||||
@@ -57,11 +57,13 @@ pub enum AcpxProviderStateEvent {
|
||||
details: Value,
|
||||
},
|
||||
InputRequest {
|
||||
tool_call_id: Option<String>,
|
||||
request_id: String,
|
||||
question_set: Value,
|
||||
origin: Option<Value>,
|
||||
},
|
||||
RuntimeRequestEnded {
|
||||
tool_call_id: Option<String>,
|
||||
request_id: String,
|
||||
question_set: Option<Value>,
|
||||
origin: Option<Value>,
|
||||
@@ -87,6 +89,7 @@ pub enum AcpxProviderStateEvent {
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
struct PendingInput {
|
||||
tool_call_id: Option<String>,
|
||||
runtime_request_id: String,
|
||||
value_bytes: usize,
|
||||
question_set: Value,
|
||||
@@ -106,6 +109,8 @@ pub struct AcpxProviderState {
|
||||
plan_revision: u64,
|
||||
assistant_text: String,
|
||||
assistant_message_id: Option<String>,
|
||||
pi_assistant_message: Option<String>,
|
||||
pi_assistant_message_ids: BTreeSet<String>,
|
||||
pending_tools: BTreeMap<String, AcpxPendingTool>,
|
||||
pending_tool_input_bytes: usize,
|
||||
pending_permissions: BTreeMap<String, (usize, Value)>,
|
||||
@@ -123,6 +128,8 @@ impl AcpxProviderState {
|
||||
plan_revision: 0,
|
||||
assistant_text: String::new(),
|
||||
assistant_message_id: None,
|
||||
pi_assistant_message: None,
|
||||
pi_assistant_message_ids: BTreeSet::new(),
|
||||
pending_tools: BTreeMap::new(),
|
||||
pending_tool_input_bytes: 0,
|
||||
pending_permissions: BTreeMap::new(),
|
||||
@@ -215,6 +222,8 @@ impl AcpxProviderState {
|
||||
self.plan_revision = 0;
|
||||
self.assistant_text.clear();
|
||||
self.assistant_message_id = None;
|
||||
self.pi_assistant_message = None;
|
||||
self.pi_assistant_message_ids.clear();
|
||||
self.semantic_result = None;
|
||||
self.attempted_turn_controls.clear();
|
||||
Ok(())
|
||||
@@ -276,11 +285,13 @@ impl AcpxProviderState {
|
||||
}])
|
||||
}
|
||||
AcpxEventPayload::InputRequested {
|
||||
tool_call_id,
|
||||
request_id,
|
||||
question_set,
|
||||
origin,
|
||||
} => {
|
||||
let value_bytes = value_bytes(&question_set)?
|
||||
+ tool_call_id.as_ref().map(|value| value.len()).unwrap_or(0)
|
||||
+ origin.as_ref().map(value_bytes).transpose()?.unwrap_or(0);
|
||||
self.admit_runtime_request(&request_id, value_bytes)?;
|
||||
let runtime_request_id = project_acpx_runtime_request_id(&request_id)
|
||||
@@ -300,6 +311,7 @@ impl AcpxProviderState {
|
||||
.insert(
|
||||
request_id.clone(),
|
||||
PendingInput {
|
||||
tool_call_id: tool_call_id.clone(),
|
||||
runtime_request_id,
|
||||
value_bytes,
|
||||
question_set: question_set.clone(),
|
||||
@@ -314,6 +326,7 @@ impl AcpxProviderState {
|
||||
}
|
||||
self.pending_runtime_request_bytes += value_bytes;
|
||||
Ok(vec![AcpxProviderStateEvent::InputRequest {
|
||||
tool_call_id,
|
||||
request_id,
|
||||
question_set,
|
||||
origin,
|
||||
@@ -358,6 +371,11 @@ impl AcpxProviderState {
|
||||
}])
|
||||
}
|
||||
AcpxEventPayload::TurnTerminal { status, error } => {
|
||||
if status == AcpxTurnStatus::Completed && self.pi_assistant_message.is_some() {
|
||||
return Err(LocalRunnerError::invalid(
|
||||
"Pi assistant message ended without its native boundary",
|
||||
));
|
||||
}
|
||||
let turn_id = event
|
||||
.turn_id
|
||||
.as_deref()
|
||||
@@ -376,6 +394,8 @@ impl AcpxProviderState {
|
||||
self.assistant_text.clear();
|
||||
}
|
||||
self.assistant_message_id = None;
|
||||
self.pi_assistant_message = None;
|
||||
self.pi_assistant_message_ids.clear();
|
||||
events.push(AcpxProviderStateEvent::TurnTerminal {
|
||||
turn_id,
|
||||
status,
|
||||
@@ -496,7 +516,9 @@ impl AcpxProviderState {
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if kind == AcpxRuntimeEventKind::TextDelta {
|
||||
if kind == AcpxRuntimeEventKind::TextDelta
|
||||
&& payload.get("piMessageHistory").and_then(Value::as_bool) != Some(true)
|
||||
{
|
||||
let provider_message_id = payload
|
||||
.get("messageId")
|
||||
.and_then(Value::as_str)
|
||||
@@ -511,12 +533,64 @@ impl AcpxProviderState {
|
||||
.get("text")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
let mut native_boundary_clears = false;
|
||||
if let Some(boundary) = payload.get("piMessageBoundary") {
|
||||
let phase = boundary.get("phase").and_then(Value::as_str);
|
||||
let reason = boundary.get("stopReason").and_then(Value::as_str);
|
||||
if !raw_text.is_empty() || provider_message_id.is_none() {
|
||||
return Err(LocalRunnerError::invalid(
|
||||
"Pi assistant boundary contains invalid content or identity",
|
||||
));
|
||||
}
|
||||
match phase {
|
||||
Some("start") if self.pi_assistant_message.is_none() && reason.is_none() => {
|
||||
if self.pi_assistant_message_ids.len() >= 4096
|
||||
|| !self
|
||||
.pi_assistant_message_ids
|
||||
.insert(provider_message_id.clone().unwrap())
|
||||
{
|
||||
return Err(LocalRunnerError::invalid(
|
||||
"Pi assistant identity is duplicated or exceeds its bound",
|
||||
));
|
||||
}
|
||||
self.pi_assistant_message = provider_message_id.clone();
|
||||
native_boundary_clears = true;
|
||||
}
|
||||
Some("end")
|
||||
if self.pi_assistant_message.is_some()
|
||||
&& self.pi_assistant_message == provider_message_id =>
|
||||
{
|
||||
if !matches!(
|
||||
reason,
|
||||
Some("stop" | "length" | "toolUse" | "error" | "aborted")
|
||||
) {
|
||||
return Err(LocalRunnerError::invalid(
|
||||
"Pi assistant stop reason is invalid",
|
||||
));
|
||||
}
|
||||
self.pi_assistant_message = None;
|
||||
native_boundary_clears = !matches!(reason, Some("stop" | "length"));
|
||||
}
|
||||
_ => {
|
||||
return Err(LocalRunnerError::invalid(
|
||||
"Pi assistant boundaries are out of order",
|
||||
))
|
||||
}
|
||||
}
|
||||
} else if !self.pi_assistant_message_ids.is_empty()
|
||||
&& (self.pi_assistant_message.is_none()
|
||||
|| self.pi_assistant_message != provider_message_id)
|
||||
{
|
||||
return Err(LocalRunnerError::invalid(
|
||||
"Pi assistant delta has no matching native message",
|
||||
));
|
||||
}
|
||||
// ACP can produce more than one assistant message in a turn. All
|
||||
// deltas remain durable progress, but only the latest compatible
|
||||
// provider message is eligible to become the terminal reply.
|
||||
// Folding earlier messages into it duplicates the transcript in
|
||||
// the task UI and can promote intermediate prose as final output.
|
||||
if starts_new_message {
|
||||
if starts_new_message || native_boundary_clears {
|
||||
self.assistant_text.clear();
|
||||
}
|
||||
if self
|
||||
@@ -631,6 +705,7 @@ impl AcpxProviderState {
|
||||
events.push(AcpxProviderStateEvent::RuntimeRequestEnded {
|
||||
request_id,
|
||||
question_set: None,
|
||||
tool_call_id: None,
|
||||
origin: None,
|
||||
status,
|
||||
});
|
||||
@@ -639,6 +714,7 @@ impl AcpxProviderState {
|
||||
events.push(AcpxProviderStateEvent::RuntimeRequestEnded {
|
||||
request_id,
|
||||
question_set: Some(pending.question_set),
|
||||
tool_call_id: pending.tool_call_id,
|
||||
origin: pending.origin,
|
||||
status,
|
||||
});
|
||||
|
||||
@@ -722,8 +722,11 @@ fn response_error_classification(error: &ResponseError) -> &'static str {
|
||||
"AGENT_STARTUP_FAILED.EXIT_NONZERO" => return "agent_startup_exit_nonzero",
|
||||
"AGENT_STARTUP_FAILED.OTHER" => return "agent_startup_other",
|
||||
"AGENT_DISCONNECTED" => return "agent_disconnected",
|
||||
"ACPX_TOOL_CALL_STALE" => return "provider_tool_call_retired",
|
||||
"AUTH_REQUIRED" => return "authentication_required",
|
||||
"COPILOT_AUTH_REQUIRED" => return "authentication_required",
|
||||
"COPILOT_POLICY_VIOLATION" => return "copilot_policy_violation",
|
||||
"COPILOT_DETACHED_WORK_UNSUPPORTED" => return "copilot_detached_work_unsupported",
|
||||
"COPILOT_ENTITLEMENT_DENIED" => return "provider_entitlement_denied",
|
||||
"COPILOT_MODEL_UNAVAILABLE" => return "requested_model_unsupported",
|
||||
"SESSION_RESUME_REQUIRED" => return "session_resume_required",
|
||||
@@ -839,6 +842,8 @@ mod tests {
|
||||
#[test]
|
||||
fn candidate_auth_diagnostics_use_only_closed_codes_and_never_provider_text() {
|
||||
for (code, expected) in [
|
||||
("ACPX_TOOL_CALL_STALE", "provider_tool_call_retired"),
|
||||
("ACPX_TOOL_CALL_STALE_EXTRA", "unclassified"),
|
||||
("COPILOT_AUTH_REQUIRED", "authentication_required"),
|
||||
("COPILOT_ENTITLEMENT_DENIED", "provider_entitlement_denied"),
|
||||
("COPILOT_MODEL_UNAVAILABLE", "requested_model_unsupported"),
|
||||
@@ -915,6 +920,11 @@ mod tests {
|
||||
"provider_lifetime_owned"
|
||||
);
|
||||
let admission_failures = [
|
||||
("COPILOT_POLICY_VIOLATION", "copilot_policy_violation"),
|
||||
(
|
||||
"COPILOT_DETACHED_WORK_UNSUPPORTED",
|
||||
"copilot_detached_work_unsupported",
|
||||
),
|
||||
(
|
||||
"ACPX_RUNTIME_ADMISSION_VERIFICATION_TIMEOUT",
|
||||
"runtime_admission_verification_timeout",
|
||||
|
||||
@@ -25,10 +25,26 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
.find(|pair| pair[0] == "--profile-digest")
|
||||
.map(|pair| pair[1].as_str())
|
||||
.unwrap_or("sha256:1111111111111111111111111111111111111111111111111111111111111111");
|
||||
// Only the receiver-admission fixture writes this task-owned command journal.
|
||||
let mut admission_journal = if matches!(mode, "admission-tool" | "admission-tool-oversized") {
|
||||
let path = args
|
||||
.windows(2)
|
||||
.find(|pair| pair[0] == "--journal")
|
||||
.ok_or("admission journal is missing")?;
|
||||
Some(
|
||||
std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(&path[1])?,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let stdin = io::stdin();
|
||||
let mut stdout = io::stdout().lock();
|
||||
let mut next_sequence = 1_u64;
|
||||
let mut goal = Value::Null;
|
||||
let mut session_identity = Value::Null;
|
||||
for line in stdin.lock().lines() {
|
||||
let request: Value = serde_json::from_str(&line?)?;
|
||||
let id = request
|
||||
@@ -39,6 +55,41 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
.get("command")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or("request command is missing")?;
|
||||
if let Some(journal) = admission_journal.as_mut() {
|
||||
write_json(journal, &json!({"request":request}))?;
|
||||
let response = if command == "tool.resolve" {
|
||||
json!({"protocolVersion":GENERATED_ACPX_SIDECAR_PROTOCOL_VERSION,
|
||||
"id":id,"ok":true,"result":{"resolved":
|
||||
request["params"]["callId"] == "call-admission"
|
||||
&& request["params"]["turnId"] == "turn-admission"
|
||||
&& request["params"]["result"] == json!({"id":"issue-1"})
|
||||
&& request["params"]["error"].is_null()}})
|
||||
} else {
|
||||
bootstrap_success(
|
||||
id,
|
||||
command,
|
||||
&request,
|
||||
mode,
|
||||
profile_digest,
|
||||
&mut session_identity,
|
||||
)
|
||||
};
|
||||
write_json(&mut stdout, &response)?;
|
||||
if command == "turn.start" {
|
||||
let frame = json!({
|
||||
"protocolVersion":GENERATED_ACPX_SIDECAR_PROTOCOL_VERSION,
|
||||
"sequence":next_sequence,"eventType":"runtime.tool_called",
|
||||
"runId":"run-1","turnId":request["params"]["turnId"],
|
||||
"payload":{"callId":"call-admission","operationId":"issues.read",
|
||||
"input":{"id":"issue-1","padding":"x".repeat(
|
||||
if mode == "admission-tool-oversized" { 300 * 1024 } else { 8 })}},
|
||||
});
|
||||
write_json(journal, &json!({"emitted":frame}))?;
|
||||
write_json(&mut stdout, &frame)?;
|
||||
next_sequence += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if mode == "goals" && command.starts_with("session.goal.") {
|
||||
let params = request.get("params").cloned().unwrap_or_else(|| json!({}));
|
||||
match command {
|
||||
@@ -79,7 +130,14 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
if command == "permission.resolve" {
|
||||
write_json(
|
||||
&mut stdout,
|
||||
&bootstrap_success(id, command, &request, mode, profile_digest),
|
||||
&bootstrap_success(
|
||||
id,
|
||||
command,
|
||||
&request,
|
||||
mode,
|
||||
profile_digest,
|
||||
&mut session_identity,
|
||||
),
|
||||
)?;
|
||||
continue;
|
||||
}
|
||||
@@ -232,6 +290,9 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
| "turns-mismatched-reserved-result-terminal"
|
||||
| "turns-unauthorized-tool"
|
||||
| "turns-permission"
|
||||
| "permissions-forged-origin"
|
||||
| "turns-retired"
|
||||
| "turns-retired-terminal-first"
|
||||
| "permissions-interactive"
|
||||
| "permissions-wrong-ack"
|
||||
| "resolutions"
|
||||
@@ -242,9 +303,31 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
| "suspend-wrong-ack"
|
||||
| "suspend-wrong-identity"
|
||||
| "suspend-missing-identity" => {
|
||||
if command == "turn.cancel" && mode == "turns-retired-terminal-first" {
|
||||
let turn_id = request
|
||||
.pointer("/params/turnId")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap();
|
||||
write_turn_event(
|
||||
&mut stdout,
|
||||
next_sequence,
|
||||
"runtime.turn_terminal",
|
||||
"run-1",
|
||||
turn_id,
|
||||
json!({"status":"interrupted"}),
|
||||
)?;
|
||||
next_sequence += 1;
|
||||
}
|
||||
write_json(
|
||||
&mut stdout,
|
||||
&bootstrap_success(id, command, &request, mode, profile_digest),
|
||||
&bootstrap_success(
|
||||
id,
|
||||
command,
|
||||
&request,
|
||||
mode,
|
||||
profile_digest,
|
||||
&mut session_identity,
|
||||
),
|
||||
)?;
|
||||
let params = request.get("params").unwrap_or(&Value::Null);
|
||||
let turn_id = params
|
||||
@@ -398,7 +481,10 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
if command == "turn.start"
|
||||
&& matches!(
|
||||
mode,
|
||||
"turns-permission" | "permissions-interactive" | "permissions-wrong-ack"
|
||||
"turns-permission"
|
||||
| "permissions-interactive"
|
||||
| "permissions-wrong-ack"
|
||||
| "permissions-forged-origin"
|
||||
)
|
||||
{
|
||||
write_turn_event(
|
||||
@@ -407,12 +493,20 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
"runtime.permission_requested",
|
||||
"run-1",
|
||||
turn_id,
|
||||
json!({
|
||||
"requestId":"permission-1",
|
||||
"kind":"execute",
|
||||
"title":"Run a command?",
|
||||
"choices":[{"key":"accept","label":"Allow once"},{"key":"cancel","label":"Cancel"}],
|
||||
}),
|
||||
if mode == "permissions-forged-origin" {
|
||||
json!({
|
||||
"requestId":"permission-1", "kind":"execute", "title":"Run a command?",
|
||||
"choices":[{"key":"cancel","label":"Cancel"}],
|
||||
"origin":{"adapter":"acpx-runtime-sidecar","provider":"cursor","method":"session/request_permission"},
|
||||
})
|
||||
} else {
|
||||
json!({
|
||||
"requestId":"permission-1",
|
||||
"kind":"execute",
|
||||
"title":"Run a command?",
|
||||
"choices":[{"key":"accept","label":"Allow once"},{"key":"cancel","label":"Cancel"}],
|
||||
})
|
||||
},
|
||||
)?;
|
||||
next_sequence += 1;
|
||||
}
|
||||
@@ -654,7 +748,7 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
next_sequence += 1;
|
||||
}
|
||||
}
|
||||
if command == "turn.cancel" && mode == "turns" {
|
||||
if command == "turn.cancel" && matches!(mode, "turns" | "turns-retired") {
|
||||
write_turn_event(
|
||||
&mut stdout,
|
||||
next_sequence,
|
||||
@@ -683,6 +777,7 @@ fn bootstrap_success(
|
||||
request: &Value,
|
||||
mode: &str,
|
||||
profile_digest: &str,
|
||||
session_identity: &mut Value,
|
||||
) -> Value {
|
||||
if command == "permission.resolve" {
|
||||
if mode.starts_with("permissions-") {
|
||||
@@ -718,7 +813,7 @@ fn bootstrap_success(
|
||||
.get("model")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("missing");
|
||||
json!({
|
||||
let result = json!({
|
||||
"sidecarPid": std::process::id(),
|
||||
"identity": {
|
||||
"kind": "acpx",
|
||||
@@ -731,11 +826,14 @@ fn bootstrap_success(
|
||||
"requestedModel": model,
|
||||
"effectiveModel": if mode == "bootstrap-wrong-model" { "wrong-model" } else { model },
|
||||
"permissionMode": params.get("permissionMode"),
|
||||
"mode": params.get("mode"),
|
||||
"providerLifetimeFenceCandidates": [60001, 60002, 60003],
|
||||
},
|
||||
"status": {},
|
||||
"turnControls": {"steering": matches!(mode, "controls" | "controls-wrong-ack" | "controls-downgrade"), "queuedFollowUp":matches!(mode, "controls" | "controls-wrong-ack" | "controls-downgrade")},
|
||||
})
|
||||
});
|
||||
*session_identity = result["identity"].clone();
|
||||
result
|
||||
}
|
||||
"run.attach" => json!({
|
||||
"runId": if mode == "bootstrap-wrong-run" { "wrong-run" } else { params.get("runId").and_then(Value::as_str).unwrap_or("missing") },
|
||||
@@ -749,23 +847,21 @@ fn bootstrap_success(
|
||||
"accepted": true, "turnId": params.get("turnId"), "controlId": params.get("controlId"),
|
||||
"mode": if mode == "controls-wrong-ack" { json!("wrong") } else { params["mode"].clone() },
|
||||
}),
|
||||
"turn.cancel" => json!({"cancelled":mode != "turns-wrong-cancel"}),
|
||||
"session.suspend" => json!({
|
||||
"suspended":mode != "suspend-wrong-ack",
|
||||
"identity": if mode == "suspend-missing-identity" { Value::Null } else { json!({
|
||||
"kind": "acpx",
|
||||
"normalizedSessionId": if mode == "suspend-wrong-identity" { "another-session" } else { "session-1" },
|
||||
"acpxRecordId": "record-1",
|
||||
"backendSessionId": "backend-1",
|
||||
"agentSessionId": "agent-1",
|
||||
"profileDigest": profile_digest,
|
||||
"workspaceDigest": format!("sha256:{}", "2".repeat(64)),
|
||||
"requestedModel": "gpt-5.6-sol",
|
||||
"effectiveModel": "gpt-5.6-sol",
|
||||
"permissionMode": "approve-reads",
|
||||
"providerLifetimeFenceCandidates": [60001, 60002, 60003],
|
||||
})},
|
||||
}),
|
||||
"turn.cancel" => {
|
||||
json!({"cancelled":mode != "turns-wrong-cancel", "sessionClosed":matches!(mode, "turns-retired" | "turns-retired-terminal-first")})
|
||||
}
|
||||
"session.suspend" => {
|
||||
let mut identity = session_identity.clone();
|
||||
if mode == "suspend-missing-identity" {
|
||||
identity = Value::Null;
|
||||
} else if mode == "suspend-wrong-identity" {
|
||||
identity["normalizedSessionId"] = json!("another-session");
|
||||
}
|
||||
json!({
|
||||
"suspended": mode != "suspend-wrong-ack",
|
||||
"identity": identity,
|
||||
})
|
||||
}
|
||||
"tool.resolve" => json!({
|
||||
"resolved":if mode == "resolutions-error-redaction" {
|
||||
params.get("callId").and_then(Value::as_str) == Some("call-1")
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
// Generated from acpx-profiles.json and cursor-distributions.json. Do not edit.
|
||||
pub(crate) const QUALIFIED_ACPX_VERSION: &str = "0.13.1";
|
||||
pub(crate) const ACPX_DRIVER_KIND: &str = "acpx_runtime";
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct AcpxReleaseProfile {
|
||||
pub agent_server_package: &'static str,
|
||||
pub agent_server_version: &'static str,
|
||||
pub agent_runtime_package: Option<&'static str>,
|
||||
pub agent_runtime_version: Option<&'static str>,
|
||||
pub command_digest: &'static str,
|
||||
pub requires_provider_policy: bool,
|
||||
}
|
||||
|
||||
pub(crate) fn acpx_release_profile(agent: &str) -> Option<AcpxReleaseProfile> {
|
||||
Some(match agent {
|
||||
"grok" => AcpxReleaseProfile {
|
||||
agent_server_package: "builtin:grok-acp",
|
||||
agent_server_version: "1",
|
||||
agent_runtime_package: Some("native:grok"),
|
||||
agent_runtime_version: Some("1.0.13"),
|
||||
command_digest:
|
||||
"sha256:f0b698395a3704ed2ffaf84ea19bdb20c36c8a0a70b7c629c7b6ffe144e59e55",
|
||||
requires_provider_policy: false,
|
||||
},
|
||||
"pi" => AcpxReleaseProfile {
|
||||
agent_server_package: "pi-acp",
|
||||
agent_server_version: "0.0.33",
|
||||
agent_runtime_package: Some("@earendil-works/pi-coding-agent"),
|
||||
agent_runtime_version: Some("0.84.2"),
|
||||
command_digest:
|
||||
"sha256:8c696f38296d53d0061fa11534570c5ddd951b63532aed30e0f1fcc676dc169f",
|
||||
requires_provider_policy: true,
|
||||
},
|
||||
"cursor" => AcpxReleaseProfile {
|
||||
agent_server_package: "cursor-agent",
|
||||
agent_server_version: "2026.09.26-dd393fe",
|
||||
agent_runtime_package: None,
|
||||
agent_runtime_version: None,
|
||||
command_digest:
|
||||
"sha256:a5e70580e4933a1a9248cd3c1b16500c6c93e1e14913e0a98cd5ef878bd53d39",
|
||||
requires_provider_policy: true,
|
||||
},
|
||||
"copilot" => AcpxReleaseProfile {
|
||||
agent_server_package: "@github/copilot",
|
||||
agent_server_version: "1.0.88",
|
||||
agent_runtime_package: None,
|
||||
agent_runtime_version: None,
|
||||
command_digest:
|
||||
"sha256:b18c01603dd0169d233140709cfaa8bf5304a03cf5de78ca4f625f30013e8457",
|
||||
requires_provider_policy: true,
|
||||
},
|
||||
"claude" => AcpxReleaseProfile {
|
||||
agent_server_package: "@agentclientprotocol/claude-agent-acp",
|
||||
agent_server_version: "0.73.0",
|
||||
agent_runtime_package: Some("@anthropic-ai/claude-agent-sdk"),
|
||||
agent_runtime_version: Some("0.3.286"),
|
||||
command_digest:
|
||||
"sha256:9d73d1f0f121fb96cc8badb28c22d5bff02d8582eb2e40360a81c189e1b9422a",
|
||||
requires_provider_policy: false,
|
||||
},
|
||||
"codex" => AcpxReleaseProfile {
|
||||
agent_server_package: "@agentclientprotocol/codex-acp",
|
||||
agent_server_version: "1.6.2",
|
||||
agent_runtime_package: Some("@openai/codex"),
|
||||
agent_runtime_version: Some("0.160.0"),
|
||||
command_digest:
|
||||
"sha256:c4538599d1ab767db5dff50934f13bb5ba313a59d9c4a83e993fac4617ea63d3",
|
||||
requires_provider_policy: false,
|
||||
},
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
pub mod acpx_event_payload;
|
||||
pub mod acpx_event_scope;
|
||||
pub mod acpx_provider_backend;
|
||||
mod acpx_provider_capabilities;
|
||||
pub mod acpx_provider_checkpoint;
|
||||
pub mod acpx_provider_session;
|
||||
pub mod acpx_provider_state;
|
||||
@@ -13,6 +14,7 @@ pub mod codex_provider;
|
||||
mod codex_startup_trust;
|
||||
pub mod durable;
|
||||
pub mod fake_harness;
|
||||
mod generated_acpx_profiles;
|
||||
pub mod generated_acpx_sidecar_contract;
|
||||
pub mod local_runner;
|
||||
pub mod managed_provider;
|
||||
|
||||
+417
-1
@@ -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, ¶ms))?;
|
||||
let current =
|
||||
complete_managed_usage(&state.descriptor, ¶ms).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, ¶ms);
|
||||
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(¶ms).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, ¤t).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 {
|
||||
|
||||
@@ -224,6 +224,12 @@ pub fn project_acpx_state_event(
|
||||
));
|
||||
}
|
||||
}
|
||||
let origin = match details.get("origin") {
|
||||
Some(origin) => project_runtime_request_origin(Some(origin))?,
|
||||
None => {
|
||||
json!({"adapter":"acpx-runtime-sidecar", "provider":"acpx", "method":"session/request_permission"})
|
||||
}
|
||||
};
|
||||
one(
|
||||
"runtime_request.created",
|
||||
EventPriority::P0,
|
||||
@@ -232,11 +238,12 @@ pub fn project_acpx_state_event(
|
||||
"requestId":request_id, "turnId":context.turn_id, "itemId":context.item_id,
|
||||
"type":"permission", "status":"pending", "prompt":title, "choices":choices,
|
||||
"details":details,
|
||||
"origin":{"adapter":"acpx-runtime-sidecar","provider":"acpx","method":"session/request_permission"},
|
||||
"origin":origin,
|
||||
}}),
|
||||
)
|
||||
}
|
||||
AcpxProviderStateEvent::InputRequest {
|
||||
tool_call_id,
|
||||
request_id,
|
||||
question_set,
|
||||
origin,
|
||||
@@ -265,7 +272,7 @@ pub fn project_acpx_state_event(
|
||||
"requestKind": "runtime",
|
||||
"requestId": request_id,
|
||||
"turnId": context.turn_id,
|
||||
"itemId": context.item_id,
|
||||
"itemId": tool_call_id.as_ref().map(|id| acpx_opaque_item_id(id, &context.item_id, "tool")).unwrap_or_else(|| context.item_id.to_owned()),
|
||||
"type": "input",
|
||||
"status": "pending",
|
||||
"prompt": prompt,
|
||||
@@ -276,6 +283,7 @@ pub fn project_acpx_state_event(
|
||||
)
|
||||
}
|
||||
AcpxProviderStateEvent::RuntimeRequestEnded {
|
||||
tool_call_id,
|
||||
request_id,
|
||||
question_set,
|
||||
origin,
|
||||
@@ -295,6 +303,7 @@ pub fn project_acpx_state_event(
|
||||
context,
|
||||
&AcpxProviderStateEvent::InputRequest {
|
||||
request_id: request_id.clone(),
|
||||
tool_call_id: tool_call_id.clone(),
|
||||
question_set: question_set.clone(),
|
||||
origin: origin.clone(),
|
||||
},
|
||||
@@ -317,6 +326,7 @@ pub fn project_acpx_state_event(
|
||||
"reason":reason, "replayAllowed":false, "adapter":"acpx-runtime-sidecar",
|
||||
});
|
||||
if let Some(request) = request {
|
||||
payload["itemId"] = request["itemId"].clone();
|
||||
payload["request"] = request;
|
||||
}
|
||||
one(
|
||||
|
||||
@@ -401,3 +401,60 @@ fn rejects_payloads_before_decoding_when_scope_or_size_is_invalid() {
|
||||
let error = decode_acpx_event(&scope, &oversized).unwrap_err();
|
||||
assert!(error.to_string().contains("256 KiB"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn input_parent_tool_is_bounded_scope_checked_and_provider_neutral() {
|
||||
let input = json!({"requestId":"input-1","toolCallId":"tool with spaces",
|
||||
"questionSet":{"schema":"paperclip.question_set.v1","questions":[{"id":"q","prompt":"Proceed?","required":true,"answerMode":"text"}]},
|
||||
"origin":{"adapter":"acpx-runtime-sidecar","provider":"cursor","method":"cursor/create_plan"}});
|
||||
let decode = |payload| {
|
||||
decode_acpx_event(
|
||||
&active_scope(),
|
||||
&event(
|
||||
GeneratedAcpxSidecarEventType::RuntimeInputRequested,
|
||||
payload,
|
||||
),
|
||||
)
|
||||
};
|
||||
let AcpxEventPayload::InputRequested { tool_call_id, .. } = decode(input.clone()).unwrap()
|
||||
else {
|
||||
panic!("input expected")
|
||||
};
|
||||
assert_eq!(tool_call_id.as_deref(), Some("tool with spaces"));
|
||||
for bad in [
|
||||
json!(null),
|
||||
json!(""),
|
||||
json!("x".repeat(241)),
|
||||
json!("bad\u{0000}id"),
|
||||
json!(["tool-1"]),
|
||||
] {
|
||||
let mut changed = input.clone();
|
||||
changed["toolCallId"] = bad;
|
||||
assert!(decode(changed).is_err());
|
||||
}
|
||||
// Vendor method recognition belongs to the adapter. Neither a provider name
|
||||
// nor an RPC spelling changes how this scoped opaque reference is decoded.
|
||||
for (field, value) in [("provider", "other-provider"), ("method", "other/plan")] {
|
||||
let mut changed = input.clone();
|
||||
changed["origin"][field] = json!(value);
|
||||
assert!(decode(changed).is_ok());
|
||||
}
|
||||
for (run, turn) in [("foreign-run", "turn-1"), ("run-1", "foreign-turn")] {
|
||||
let mut scoped = event(
|
||||
GeneratedAcpxSidecarEventType::RuntimeInputRequested,
|
||||
input.clone(),
|
||||
);
|
||||
scoped.run_id = Some(run.to_owned());
|
||||
scoped.turn_id = Some(turn.to_owned());
|
||||
assert!(decode_acpx_event(&active_scope(), &scoped).is_err());
|
||||
}
|
||||
let mut legacy = input;
|
||||
legacy.as_object_mut().unwrap().remove("toolCallId");
|
||||
assert!(matches!(
|
||||
decode(legacy).unwrap(),
|
||||
AcpxEventPayload::InputRequested {
|
||||
tool_call_id: None,
|
||||
..
|
||||
}
|
||||
));
|
||||
}
|
||||
@@ -135,6 +135,7 @@ fn keeps_durable_correlation_separate_from_the_active_provider_turn() {
|
||||
let request = project_acpx_state_event(
|
||||
&context,
|
||||
&AcpxProviderStateEvent::InputRequest {
|
||||
tool_call_id: None,
|
||||
request_id: "request-1".to_owned(),
|
||||
question_set: json!({
|
||||
"schema":"paperclip.question_set.v1",
|
||||
@@ -215,6 +216,7 @@ fn projects_structured_input_and_semantic_results_without_provider_envelopes() {
|
||||
"questions":[],
|
||||
});
|
||||
let input = project(AcpxProviderStateEvent::InputRequest {
|
||||
tool_call_id: None,
|
||||
request_id: "request-1".to_owned(),
|
||||
question_set: question_set.clone(),
|
||||
origin: None,
|
||||
@@ -271,6 +273,7 @@ fn projects_structured_input_and_semantic_results_without_provider_envelopes() {
|
||||
#[test]
|
||||
fn projects_runtime_request_prompt_and_origin_into_the_strict_schema() {
|
||||
let empty_title = AcpxProviderStateEvent::InputRequest {
|
||||
tool_call_id: None,
|
||||
request_id: "request-1".to_owned(),
|
||||
question_set: json!({
|
||||
"schema":"paperclip.question_set.v1",
|
||||
@@ -299,6 +302,7 @@ fn projects_runtime_request_prompt_and_origin_into_the_strict_schema() {
|
||||
json!({"adapter":"codex-acpx","method":null}),
|
||||
] {
|
||||
let invalid = AcpxProviderStateEvent::InputRequest {
|
||||
tool_call_id: None,
|
||||
request_id: "request-1".to_owned(),
|
||||
question_set: json!({
|
||||
"schema":"paperclip.question_set.v1",
|
||||
@@ -450,6 +454,7 @@ fn rejects_invalid_durable_projection_identity() {
|
||||
|
||||
for request_id in [String::new(), "x".repeat(241), "request\n1".to_owned()] {
|
||||
let request = AcpxProviderStateEvent::InputRequest {
|
||||
tool_call_id: None,
|
||||
request_id,
|
||||
question_set: json!({
|
||||
"schema":"paperclip.question_set.v1",
|
||||
@@ -519,6 +524,7 @@ fn deterministically_projects_bounded_upstream_request_ids() {
|
||||
"x".repeat(240),
|
||||
] {
|
||||
let event = AcpxProviderStateEvent::InputRequest {
|
||||
tool_call_id: None,
|
||||
request_id: upstream_id.clone(),
|
||||
question_set: question_set.clone(),
|
||||
origin: None,
|
||||
@@ -536,6 +542,7 @@ fn deterministically_projects_bounded_upstream_request_ids() {
|
||||
}
|
||||
|
||||
let canonical = AcpxProviderStateEvent::InputRequest {
|
||||
tool_call_id: None,
|
||||
request_id: "request-1".to_owned(),
|
||||
question_set,
|
||||
origin: None,
|
||||
@@ -556,6 +563,7 @@ fn runtime_request_projection_preserves_durable_identity_boundaries() {
|
||||
runtime_request_schema["properties"]["input"] = json!({});
|
||||
let request_validator = jsonschema::validator_for(&runtime_request_schema).unwrap();
|
||||
let request_event = AcpxProviderStateEvent::InputRequest {
|
||||
tool_call_id: None,
|
||||
request_id: "request-1".to_owned(),
|
||||
question_set: json!({
|
||||
"schema":"paperclip.question_set.v1",
|
||||
@@ -635,3 +643,48 @@ fn recovery_preserves_the_preceding_provider_turn_answer() {
|
||||
assert_eq!(first[0].payload["text"], "Useful answer");
|
||||
assert_eq!(second[0].payload["text"], "Recovery update");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cursor_plan_request_item_matches_tool_projection_and_survives_terminal_reconstruction() {
|
||||
use paperclip_runner_core::acpx_event_payload::AcpxRuntimeEventKind;
|
||||
use paperclip_runner_core::provider_events::normalize_acpx_runtime_event;
|
||||
for id in [
|
||||
"tool-1".to_owned(),
|
||||
"tool with spaces".to_owned(),
|
||||
"🧭/plan".to_owned(),
|
||||
"x".repeat(240),
|
||||
] {
|
||||
let input = AcpxProviderStateEvent::InputRequest {
|
||||
tool_call_id: Some(id.clone()),
|
||||
request_id: "input-1".to_owned(),
|
||||
question_set: json!({"schema":"paperclip.question_set.v1","questions":[]}),
|
||||
origin: Some(
|
||||
json!({"adapter":"acpx-runtime-sidecar","provider":"cursor","method":"cursor/create_plan"}),
|
||||
),
|
||||
};
|
||||
let request =
|
||||
project_acpx_state_event(&context(), &input).unwrap()[0].payload["request"].clone();
|
||||
let tool = normalize_acpx_runtime_event(
|
||||
AcpxRuntimeEventKind::ToolCall,
|
||||
&json!({"type":"tool_call","toolCallId":id,"kind":"execute","status":"pending","title":"arbitrary"}),
|
||||
Some("execute"),
|
||||
"item-1",
|
||||
"turn-1",
|
||||
0,
|
||||
);
|
||||
assert_eq!(request["itemId"], tool[0].payload["executionId"]);
|
||||
for status in [
|
||||
AcpxTurnStatus::Cancelled,
|
||||
AcpxTurnStatus::Failed,
|
||||
AcpxTurnStatus::Completed,
|
||||
] {
|
||||
let ended = project_acpx_state_event(&context(), &AcpxProviderStateEvent::RuntimeRequestEnded {
|
||||
tool_call_id: Some(id.clone()), request_id: "input-1".to_owned(),
|
||||
question_set: Some(json!({"schema":"paperclip.question_set.v1","questions":[]})),
|
||||
origin: Some(json!({"adapter":"acpx-runtime-sidecar","provider":"cursor","method":"cursor/create_plan"})), status,
|
||||
}).unwrap();
|
||||
assert_eq!(ended[0].payload["itemId"], request["itemId"]);
|
||||
assert_eq!(ended[0].payload["request"]["itemId"], request["itemId"]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -50,6 +50,7 @@ fn config(directory: &std::path::Path) -> AcpxProviderSessionConfig {
|
||||
normalized_session_id: "session-1".to_owned(),
|
||||
working_directory: directory.to_owned(),
|
||||
permission_mode: AcpxPermissionMode::ApproveReads,
|
||||
mode: None,
|
||||
permission_mode_pinned: true,
|
||||
provider_policy: None,
|
||||
system_instructions: "Complete the supplied task.".to_owned(),
|
||||
@@ -76,6 +77,7 @@ fn identity() -> AcpxProviderSessionIdentity {
|
||||
requested_model: "gpt-5.6-sol".to_owned(),
|
||||
effective_model: "gpt-5.6-sol".to_owned(),
|
||||
permission_mode: Some(AcpxPermissionMode::ApproveReads),
|
||||
mode: None,
|
||||
provider_lifetime_fence_candidates: [60_001, 60_002, 60_003],
|
||||
}
|
||||
}
|
||||
@@ -257,3 +259,34 @@ fn rejects_an_existing_runner_state_directory_that_is_not_private() {
|
||||
);
|
||||
fs::remove_dir_all(directory).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mode_round_trips_checkpoint_and_rejects_changed_or_missing_recovery_mode() {
|
||||
use paperclip_runner_core::acpx_provider_session::AcpxProviderRuntimePolicy;
|
||||
let directory = temporary_directory("cursor-mode");
|
||||
let mut config = config(&directory);
|
||||
config.agent = "cursor".to_owned();
|
||||
config.mode = Some("plan".to_owned());
|
||||
config.provider_policy = Some(AcpxProviderRuntimePolicy { read_only: false });
|
||||
let mut identity = identity();
|
||||
identity.mode = Some("plan".to_owned());
|
||||
let checkpoint = AcpxSuspensionCheckpoint::from_suspension(&config, identity.clone()).unwrap();
|
||||
let wire = serde_json::to_value(&checkpoint).unwrap();
|
||||
assert_eq!(wire["identity"]["mode"], json!("plan"));
|
||||
let recovered: AcpxSuspensionCheckpoint = serde_json::from_value(wire.clone()).unwrap();
|
||||
assert_eq!(recovered.admit_recovery(&config).unwrap(), identity);
|
||||
for mode in [None, Some("agent".to_owned()), Some("ask".to_owned())] {
|
||||
let mut changed = config.clone();
|
||||
changed.mode = mode.clone();
|
||||
assert!(recovered.admit_recovery(&changed).is_err());
|
||||
let mut changed = wire.clone();
|
||||
changed["identity"]["mode"] = json!(mode);
|
||||
let changed: AcpxSuspensionCheckpoint = serde_json::from_value(changed).unwrap();
|
||||
assert!(changed.admit_recovery(&config).is_err());
|
||||
}
|
||||
let mut other = config.clone();
|
||||
other.agent = "copilot".to_owned();
|
||||
other.mode = None;
|
||||
assert!(recovered.admit_recovery(&other).is_err());
|
||||
fs::remove_dir_all(directory).unwrap();
|
||||
}
|
||||
+56
-2
@@ -42,13 +42,14 @@ fn config(mode: &str) -> AcpxProviderSessionConfig {
|
||||
shutdown_grace: Duration::from_millis(100),
|
||||
},
|
||||
agent: "codex".to_owned(),
|
||||
model: "gpt-5.6-sol".to_owned(),
|
||||
model: "explicit-test-model".to_owned(),
|
||||
run_id: "run-1".to_owned(),
|
||||
catalog_revision: 1,
|
||||
runtime_directory: std::env::temp_dir(),
|
||||
normalized_session_id: "session-1".to_owned(),
|
||||
working_directory: std::env::temp_dir(),
|
||||
permission_mode: AcpxPermissionMode::ApproveReads,
|
||||
mode: None,
|
||||
permission_mode_pinned: true,
|
||||
provider_policy: None,
|
||||
system_instructions: "Complete the supplied task.".to_owned(),
|
||||
@@ -269,7 +270,6 @@ fn interactive_permission_requires_an_offered_action_and_acknowledgement() {
|
||||
for mode in ["permissions-interactive", "permissions-wrong-ack"] {
|
||||
let mut cfg = config(mode);
|
||||
cfg.agent = "claude".to_owned();
|
||||
cfg.model = "claude-sonnet-5".to_owned();
|
||||
let mut session = AcpxProviderSession::start(&cfg).unwrap();
|
||||
session
|
||||
.start_turn("turn-1", "Run validation", &std::env::temp_dir())
|
||||
@@ -302,3 +302,57 @@ fn interactive_permission_requires_an_offered_action_and_acknowledgement() {
|
||||
session.shutdown("verified").unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn permission_origin_is_bound_to_the_admitted_connection_and_survives_projection() {
|
||||
for agent in ["claude", "copilot", "cursor", "pi"] {
|
||||
let mut cfg = config("permissions-interactive");
|
||||
cfg.agent = agent.to_owned();
|
||||
if agent == "cursor" {
|
||||
cfg.mode = Some("agent".to_owned());
|
||||
}
|
||||
cfg.provider_policy = Some(
|
||||
paperclip_runner_core::acpx_provider_session::AcpxProviderRuntimePolicy {
|
||||
read_only: false,
|
||||
},
|
||||
);
|
||||
let mut session = AcpxProviderSession::start(&cfg).unwrap();
|
||||
session
|
||||
.start_turn("turn-1", "Request permission", &std::env::temp_dir())
|
||||
.unwrap();
|
||||
let events = session.poll_event(Duration::from_secs(1)).unwrap().unwrap();
|
||||
let expected = json!({"adapter":"acpx-runtime-sidecar","provider":agent,"method":"session/request_permission"});
|
||||
let projected = project_acpx_state_event(
|
||||
&AcpxEventProjectionContext {
|
||||
run_id: "run-1".to_owned(),
|
||||
normalized_session_id: "session-1".to_owned(),
|
||||
turn_id: "turn-1".to_owned(),
|
||||
provider_turn_id: None,
|
||||
item_id: "permission-1".to_owned(),
|
||||
},
|
||||
&events[0],
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(projected[0].payload["request"]["origin"], expected);
|
||||
assert_eq!(projected[0].payload["request"]["turnId"], "turn-1");
|
||||
assert_eq!(projected[0].payload["request"]["requestId"], "permission-1");
|
||||
session.shutdown("provenance checked").unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_a_permission_origin_claim_from_another_provider() {
|
||||
let mut cfg = config("permissions-forged-origin");
|
||||
cfg.agent = "claude".to_owned();
|
||||
let mut session = AcpxProviderSession::start(&cfg).unwrap();
|
||||
session
|
||||
.start_turn("turn-1", "Request permission", &std::env::temp_dir())
|
||||
.unwrap();
|
||||
assert!(session
|
||||
.poll_event(Duration::from_secs(1))
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("origin conflicts"));
|
||||
assert!(session.state().pending_permission("permission-1").is_none());
|
||||
session.shutdown("rejected provenance").unwrap();
|
||||
}
|
||||
+121
-22
@@ -2,7 +2,8 @@ use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
use paperclip_runner_core::acpx_provider_session::{
|
||||
AcpxPermissionMode, AcpxProviderSession, AcpxProviderSessionConfig, AcpxProviderSessionIdentity,
|
||||
AcpxPermissionMode, AcpxProviderRuntimePolicy, AcpxProviderSession, AcpxProviderSessionConfig,
|
||||
AcpxProviderSessionIdentity,
|
||||
};
|
||||
use paperclip_runner_core::acpx_sidecar_transport::AcpxSidecarTransportConfig;
|
||||
use paperclip_runner_core::generated_acpx_sidecar_contract::GeneratedAcpxSidecarCommand as GoalCommand;
|
||||
@@ -44,6 +45,7 @@ fn config(mode: &str) -> AcpxProviderSessionConfig {
|
||||
normalized_session_id: "session-1".to_owned(),
|
||||
working_directory: std::env::temp_dir(),
|
||||
permission_mode: AcpxPermissionMode::ApproveReads,
|
||||
mode: None,
|
||||
permission_mode_pinned: true,
|
||||
provider_policy: None,
|
||||
system_instructions: "Complete the supplied task.".to_owned(),
|
||||
@@ -65,6 +67,7 @@ fn expected_identity() -> AcpxProviderSessionIdentity {
|
||||
requested_model: "gpt-5.6-sol".to_owned(),
|
||||
effective_model: "gpt-5.6-sol".to_owned(),
|
||||
permission_mode: Some(AcpxPermissionMode::ApproveReads),
|
||||
mode: None,
|
||||
provider_lifetime_fence_candidates: [60_001, 60_002, 60_003],
|
||||
}
|
||||
}
|
||||
@@ -154,28 +157,19 @@ fn validates_qualified_policy_and_tool_catalog_before_spawning() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn admits_custom_claude_models_and_legacy_codex_profile() {
|
||||
for (agent, model) in [
|
||||
("codex", "gpt-5.6-sol"),
|
||||
("claude", "claude-sonnet-5"),
|
||||
("claude", "claude-opus-5"),
|
||||
("claude", "custom-provider-model"),
|
||||
("grok", "grok-4.7"),
|
||||
("grok", "future-exact-model"),
|
||||
] {
|
||||
let mut qualified = config("bootstrap");
|
||||
qualified.agent = agent.to_owned();
|
||||
qualified.model = model.to_owned();
|
||||
qualified.validate().unwrap();
|
||||
fn bootstraps_unlisted_models_confirmed_by_the_sidecar_for_every_agent() {
|
||||
for agent in ["claude", "codex", "pi", "grok", "cursor", "copilot"] {
|
||||
let mut selected = config("bootstrap");
|
||||
selected.agent = agent.to_owned();
|
||||
selected.model = "custom/model[context=272k,reasoning=medium]".to_owned();
|
||||
selected.provider_policy = Some(AcpxProviderRuntimePolicy { read_only: false });
|
||||
let mut session = AcpxProviderSession::start(&selected).unwrap();
|
||||
assert_eq!(session.identity().requested_model, selected.model);
|
||||
assert_eq!(session.identity().effective_model, selected.model);
|
||||
session
|
||||
.shutdown("model verification test complete")
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let mut drifted = config("bootstrap");
|
||||
drifted.model = "custom-codex-model".to_owned();
|
||||
assert!(drifted
|
||||
.validate()
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("exact model"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -225,3 +219,108 @@ fn rejects_non_utf8_directories_before_spawning() {
|
||||
|
||||
assert!(error.contains("must be valid UTF-8"), "{error}");
|
||||
}
|
||||
|
||||
/// Exercise the subprocess transport, real event decoder/reducer and tool bridge.
|
||||
/// The command journal distinguishes no admission from a late sidecar rejection.
|
||||
fn check_tool_receiver_admission(oversized: bool) {
|
||||
let root = std::env::temp_dir().join(format!("acpx-admission-{}", uuid::Uuid::new_v4()));
|
||||
std::fs::create_dir(&root).unwrap();
|
||||
let journal = root.join("commands.jsonl");
|
||||
let mut fixture = config(if oversized {
|
||||
"admission-tool-oversized"
|
||||
} else {
|
||||
"admission-tool"
|
||||
});
|
||||
fixture
|
||||
.transport
|
||||
.args
|
||||
.extend(["--journal".to_owned(), journal.to_str().unwrap().to_owned()]);
|
||||
let mut session = AcpxProviderSession::start(&fixture).unwrap();
|
||||
session
|
||||
.start_turn("turn-admission", "Read the issue", &std::env::temp_dir())
|
||||
.unwrap();
|
||||
let result = paperclip_runner_core::provider_bridge::ToolResult {
|
||||
call_id: "call-admission".to_owned(),
|
||||
operation_id: "issues.read".to_owned(),
|
||||
result: json!({"id":"issue-1"}),
|
||||
is_error: false,
|
||||
};
|
||||
if oversized {
|
||||
let error = session
|
||||
.poll_event(Duration::from_secs(1))
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(error.contains("256 KiB admission limit"), "{error}");
|
||||
assert!(!session.state().has_pending_tools());
|
||||
assert!(session.state().pending_tool("call-admission").is_none());
|
||||
// Rejection cannot be bypassed by supplying a matching callback result.
|
||||
assert!(session.deliver_tool_result(&result).is_err());
|
||||
} else {
|
||||
let events = session.poll_event(Duration::from_secs(1)).unwrap().unwrap();
|
||||
assert_eq!(events.len(), 1);
|
||||
assert!(session.state().pending_tool("call-admission").is_some());
|
||||
let mut foreign = result.clone();
|
||||
foreign.call_id = "another-call".to_owned();
|
||||
assert!(session.deliver_tool_result(&foreign).is_err());
|
||||
assert!(session.state().pending_tool("call-admission").is_some());
|
||||
session.deliver_tool_result(&result).unwrap();
|
||||
assert!(!session.state().has_pending_tools());
|
||||
// An exact durable delivery replay is acknowledged without a second
|
||||
// sidecar resolution. The command journal below proves that boundary.
|
||||
session.deliver_tool_result(&result).unwrap();
|
||||
let mut changed = result.clone();
|
||||
changed.result = json!({"id":"different-issue"});
|
||||
assert!(session.deliver_tool_result(&changed).is_err());
|
||||
changed = result.clone();
|
||||
changed.operation_id = "issues.update".to_owned();
|
||||
assert!(session.deliver_tool_result(&changed).is_err());
|
||||
}
|
||||
session.shutdown("admission test complete").unwrap();
|
||||
let rows: Vec<serde_json::Value> = std::fs::read_to_string(&journal)
|
||||
.unwrap()
|
||||
.lines()
|
||||
.map(|line| serde_json::from_str(line).unwrap())
|
||||
.collect();
|
||||
let emitted: Vec<_> = rows.iter().filter_map(|row| row.get("emitted")).collect();
|
||||
assert_eq!(emitted.len(), 1);
|
||||
let frame = emitted[0];
|
||||
assert_eq!(frame["eventType"], "runtime.tool_called");
|
||||
let payload_bytes = serde_json::to_vec(&frame["payload"]).unwrap().len();
|
||||
let frame_bytes = serde_json::to_vec(frame).unwrap().len() + 1;
|
||||
assert!(
|
||||
frame_bytes < 1024 * 1024,
|
||||
"must reach payload admission, not the line limit"
|
||||
);
|
||||
assert_eq!(payload_bytes > 256 * 1024, oversized);
|
||||
let resolutions: Vec<_> = rows
|
||||
.iter()
|
||||
.filter_map(|row| row.get("request"))
|
||||
.filter(|request| request["command"] == "tool.resolve")
|
||||
.collect();
|
||||
if oversized {
|
||||
assert!(
|
||||
resolutions.is_empty(),
|
||||
"rejected event must never send tool.resolve"
|
||||
);
|
||||
} else {
|
||||
assert_eq!(resolutions.len(), 1);
|
||||
assert_eq!(
|
||||
resolutions[0]["params"],
|
||||
json!({
|
||||
"callId":"call-admission","turnId":"turn-admission",
|
||||
"result":{"id":"issue-1"},"error":null,
|
||||
})
|
||||
);
|
||||
}
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn receiver_rejects_sub_megabyte_tool_event_before_pending_admission_or_resolution() {
|
||||
check_tool_receiver_admission(true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn receiver_admits_normal_tool_event_and_resolves_only_correlated_call_once() {
|
||||
check_tool_receiver_admission(false);
|
||||
}
|
||||
@@ -175,6 +175,95 @@ fn promotes_only_the_latest_provider_message_as_the_terminal_reply() {
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pi_native_boundaries_keep_progress_but_never_promote_tool_narration_or_history() {
|
||||
for final_reason in ["stop", "length", "toolUse", "error", "aborted", "empty"] {
|
||||
let mut state = AcpxProviderState::new("run-1").unwrap();
|
||||
state.begin_turn("turn-1").unwrap();
|
||||
let mut messages = vec![
|
||||
json!({"type":"text_delta","messageId":"history","text":"Earlier session answer","piMessageHistory":true}),
|
||||
json!({"type":"text_delta","messageId":"first","text":"","piMessageBoundary":{"phase":"start"}}),
|
||||
json!({"type":"text_delta","messageId":"first","text":"Calling finish."}),
|
||||
json!({"type":"text_delta","messageId":"first","text":"","piMessageBoundary":{"phase":"end","stopReason":"toolUse"}}),
|
||||
json!({"type":"text_delta","messageId":"last","text":"","piMessageBoundary":{"phase":"start"}}),
|
||||
];
|
||||
if final_reason != "empty" {
|
||||
messages.push(json!({"type":"text_delta","messageId":"last","text":"EXACT_MARKER"}));
|
||||
}
|
||||
messages.push(json!({"type":"text_delta","messageId":"last","text":"","piMessageBoundary":{"phase":"end","stopReason":if final_reason == "empty" { "stop" } else { final_reason }}}));
|
||||
let count = messages.len() as u64;
|
||||
for (index, payload) in messages.into_iter().enumerate() {
|
||||
state
|
||||
.accept_event(&event(
|
||||
index as u64 + 1,
|
||||
GeneratedAcpxSidecarEventType::RuntimeEvent,
|
||||
Some("turn-1"),
|
||||
payload,
|
||||
))
|
||||
.unwrap();
|
||||
}
|
||||
let terminal = state
|
||||
.accept_event(&event(
|
||||
count + 1,
|
||||
GeneratedAcpxSidecarEventType::RuntimeTurnTerminal,
|
||||
Some("turn-1"),
|
||||
json!({"status":"completed"}),
|
||||
))
|
||||
.unwrap();
|
||||
let replies: Vec<_> = terminal
|
||||
.iter()
|
||||
.filter_map(|entry| {
|
||||
if let AcpxProviderStateEvent::AssistantMessage { text, .. } = entry {
|
||||
Some(text.as_str())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(
|
||||
replies,
|
||||
if matches!(final_reason, "stop" | "length") {
|
||||
vec!["EXACT_MARKER"]
|
||||
} else {
|
||||
vec![]
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pi_native_boundary_order_and_terminal_completeness_fail_closed() {
|
||||
for malformed in [
|
||||
json!({"type":"text_delta","messageId":"first","text":"","piMessageBoundary":{"phase":"start"}}),
|
||||
json!({"type":"text_delta","messageId":"other","text":"","piMessageBoundary":{"phase":"end","stopReason":"stop"}}),
|
||||
json!({"type":"text_delta","messageId":"first","text":"","piMessageBoundary":{"phase":"end","stopReason":"invented"}}),
|
||||
json!({"type":"text_delta","messageId":"other","text":"Out of order"}),
|
||||
] {
|
||||
let mut state = AcpxProviderState::new("run-1").unwrap();
|
||||
state.begin_turn("turn-1").unwrap();
|
||||
state.accept_event(&event(1, GeneratedAcpxSidecarEventType::RuntimeEvent, Some("turn-1"), json!({"type":"text_delta","messageId":"first","text":"","piMessageBoundary":{"phase":"start"}}))).unwrap();
|
||||
assert!(state
|
||||
.accept_event(&event(
|
||||
2,
|
||||
GeneratedAcpxSidecarEventType::RuntimeEvent,
|
||||
Some("turn-1"),
|
||||
malformed
|
||||
))
|
||||
.is_err());
|
||||
}
|
||||
let mut state = AcpxProviderState::new("run-1").unwrap();
|
||||
state.begin_turn("turn-1").unwrap();
|
||||
state.accept_event(&event(1, GeneratedAcpxSidecarEventType::RuntimeEvent, Some("turn-1"), json!({"type":"text_delta","messageId":"first","text":"","piMessageBoundary":{"phase":"start"}}))).unwrap();
|
||||
assert!(state
|
||||
.accept_event(&event(
|
||||
2,
|
||||
GeneratedAcpxSidecarEventType::RuntimeTurnTerminal,
|
||||
Some("turn-1"),
|
||||
json!({"status":"completed"})
|
||||
))
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_an_idless_prefix_when_the_provider_begins_identifying_deltas() {
|
||||
let mut state = AcpxProviderState::new("run-1").unwrap();
|
||||
@@ -656,3 +745,63 @@ fn terminal_requests_expire_with_their_projected_identity_before_terminal_and_ne
|
||||
.is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cursor_plan_parent_identity_survives_decode_pending_and_terminal_projection() {
|
||||
use paperclip_runner_core::acpx_event_payload::AcpxRuntimeEventKind;
|
||||
use paperclip_runner_core::provider_events::normalize_acpx_runtime_event;
|
||||
for id in [
|
||||
"x".repeat(160),
|
||||
"x".repeat(161),
|
||||
"x".repeat(240),
|
||||
"é".repeat(120),
|
||||
] {
|
||||
for status in ["completed", "cancelled", "failed"] {
|
||||
let mut state = AcpxProviderState::new("run-1").unwrap();
|
||||
state.begin_turn("turn-1").unwrap();
|
||||
let context = AcpxEventProjectionContext {
|
||||
run_id: "run-1".into(),
|
||||
normalized_session_id: "session-1".into(),
|
||||
turn_id: "turn-1".into(),
|
||||
provider_turn_id: None,
|
||||
item_id: "item-1".into(),
|
||||
};
|
||||
let input = state.accept_event(&event(1, GeneratedAcpxSidecarEventType::RuntimeInputRequested, Some("turn-1"), json!({"requestId":"input-1","toolCallId":id,"questionSet":question_set(),"origin":{"adapter":"acpx-runtime-sidecar","provider":"cursor","method":"cursor/create_plan"}}))).unwrap();
|
||||
let created = project_acpx_state_event(&context, &input[0]).unwrap();
|
||||
let tool = normalize_acpx_runtime_event(
|
||||
AcpxRuntimeEventKind::ToolCall,
|
||||
&json!({"type":"tool_call","toolCallId":id,"kind":"execute","status":"pending"}),
|
||||
Some("execute"),
|
||||
"item-1",
|
||||
"turn-1",
|
||||
0,
|
||||
);
|
||||
assert_eq!(
|
||||
created[0].payload["request"]["itemId"],
|
||||
tool[0].payload["executionId"]
|
||||
);
|
||||
let ended = state
|
||||
.accept_event(&event(
|
||||
2,
|
||||
GeneratedAcpxSidecarEventType::RuntimeTurnTerminal,
|
||||
Some("turn-1"),
|
||||
json!({"status":status}),
|
||||
))
|
||||
.unwrap();
|
||||
let terminal = project_acpx_state_event(&context, &ended[0]).unwrap();
|
||||
assert_eq!(
|
||||
terminal[0].payload["request"]["itemId"],
|
||||
created[0].payload["request"]["itemId"]
|
||||
);
|
||||
assert_eq!(
|
||||
terminal[0].payload["itemId"],
|
||||
created[0].payload["request"]["itemId"]
|
||||
);
|
||||
}
|
||||
}
|
||||
for id in ["x".repeat(241), format!("{}x", "é".repeat(120))] {
|
||||
let mut state = AcpxProviderState::new("run-1").unwrap();
|
||||
state.begin_turn("turn-1").unwrap();
|
||||
assert!(state.accept_event(&event(1, GeneratedAcpxSidecarEventType::RuntimeInputRequested, Some("turn-1"), json!({"requestId":"input-1","toolCallId":id,"questionSet":question_set(),"origin":{"provider":"cursor","method":"cursor/create_plan"}}))).is_err());
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@ fn config(mode: &str) -> AcpxProviderSessionConfig {
|
||||
normalized_session_id: "session-1".to_owned(),
|
||||
working_directory: std::env::temp_dir(),
|
||||
permission_mode: AcpxPermissionMode::ApproveReads,
|
||||
mode: None,
|
||||
permission_mode_pinned: true,
|
||||
provider_policy: None,
|
||||
system_instructions: "Complete the supplied task.".to_owned(),
|
||||
|
||||
@@ -42,18 +42,14 @@ fn config(mode: &str) -> AcpxProviderSessionConfig {
|
||||
"codex"
|
||||
}
|
||||
.to_owned(),
|
||||
model: if mode.starts_with("controls") {
|
||||
"openrouter/deepseek/deepseek-v4-flash-0731"
|
||||
} else {
|
||||
"gpt-5.6-sol"
|
||||
}
|
||||
.to_owned(),
|
||||
model: "explicit-test-model".to_owned(),
|
||||
run_id: "run-1".to_owned(),
|
||||
catalog_revision: 1,
|
||||
runtime_directory: std::env::temp_dir(),
|
||||
normalized_session_id: "session-1".to_owned(),
|
||||
working_directory: std::env::temp_dir(),
|
||||
permission_mode: AcpxPermissionMode::ApproveReads,
|
||||
mode: None,
|
||||
permission_mode_pinned: true,
|
||||
provider_policy: if mode.starts_with("controls") {
|
||||
Some(
|
||||
@@ -761,3 +757,28 @@ fn lazy_warm_handshake_updates_live_turn_control_discovery() {
|
||||
.unwrap();
|
||||
session.shutdown("verified live handshake").unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forced_cancellation_retirement_preserves_terminal_polling_but_rejects_live_reuse() {
|
||||
for mode in ["turns-retired", "turns-retired-terminal-first"] {
|
||||
let mut session = AcpxProviderSession::start(&config(mode)).unwrap();
|
||||
session
|
||||
.start_turn("turn-1", "Please help", &std::env::temp_dir())
|
||||
.unwrap();
|
||||
session
|
||||
.interrupt_turn("turn-1", "Stop stuck provider")
|
||||
.unwrap();
|
||||
assert!(session.runtime_retired());
|
||||
let terminal = session.poll_event(Duration::from_secs(1)).unwrap().unwrap();
|
||||
assert!(
|
||||
matches!(terminal.last().unwrap(), AcpxProviderStateEvent::TurnTerminal { turn_id, .. } if turn_id == "turn-1")
|
||||
);
|
||||
assert!(session.state().active_turn_id().is_none());
|
||||
assert!(session
|
||||
.start_turn("turn-2", "Must not reuse", &std::env::temp_dir())
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("runtime was retired"));
|
||||
session.shutdown("test complete").unwrap();
|
||||
}
|
||||
}
|
||||
+1
@@ -219,6 +219,7 @@ fn acpx_projection_matches_shared_plan_question_final_and_terminal_identity() {
|
||||
project_acpx_state_event(
|
||||
&context,
|
||||
&AcpxProviderStateEvent::InputRequest {
|
||||
tool_call_id: None,
|
||||
request_id: expected_question["requestId"]
|
||||
.as_str()
|
||||
.expect("request id")
|
||||
|
||||
+4
-7
@@ -199,7 +199,7 @@ fn prepare_payload_with_mode(directory: &Path, agent: &str, mode: &str) -> Value
|
||||
"driver": "acpx_runtime",
|
||||
"providerVersion": "0.13.1",
|
||||
"agent": agent,
|
||||
"model": "gpt-5.6-sol",
|
||||
"model": "explicit-test-model",
|
||||
"acpxVersion": "0.13.1",
|
||||
"agentServerPackage": "@agentclientprotocol/codex-acp",
|
||||
"agentServerVersion": "1.6.2",
|
||||
@@ -228,7 +228,6 @@ fn prepare_payload_with_mode(directory: &Path, agent: &str, mode: &str) -> Value
|
||||
fn pi_prepare_payload(directory: &Path, mode: &str) -> Value {
|
||||
let mut payload = prepare_payload_with_mode(directory, "pi", mode);
|
||||
let provider = &mut payload["provider"];
|
||||
provider["model"] = json!("openrouter/deepseek/deepseek-v4-flash-0731");
|
||||
provider["agentServerPackage"] = json!("pi-acp");
|
||||
provider["agentServerVersion"] = json!("0.0.33");
|
||||
provider["agentRuntimePackage"] = json!("@earendil-works/pi-coding-agent");
|
||||
@@ -262,7 +261,6 @@ fn pending_acpx_runtime_request(
|
||||
.unwrap() = digest.into();
|
||||
let provider = &mut payload["provider"];
|
||||
provider["agent"] = json!("claude");
|
||||
provider["model"] = json!("claude-sonnet-5");
|
||||
provider["agentServerPackage"] = json!("@agentclientprotocol/claude-agent-acp");
|
||||
provider["agentServerVersion"] = json!("0.73.0");
|
||||
provider["agentRuntimePackage"] = json!("@anthropic-ai/claude-agent-sdk");
|
||||
@@ -1355,13 +1353,12 @@ fn rejects_opencode_launch_profile_drift_across_fresh_recovery() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_pi_with_an_unqualified_model_before_starting_a_sidecar() {
|
||||
fn rejects_pi_without_an_explicit_model_before_starting_a_sidecar() {
|
||||
let directory = temporary_directory("pi-model");
|
||||
let config = pi_acpx_config(&directory, "bootstrap");
|
||||
let mut payload = pi_prepare_payload(&directory, "bootstrap");
|
||||
// All Pi distribution and policy fields are correct. Admission must reject
|
||||
// the model itself, not rely on the old blanket exclusion of this harness.
|
||||
payload["provider"]["model"] = json!("gpt-5.6-sol");
|
||||
// Distribution and policy are valid, but model selection is still required.
|
||||
payload["provider"]["model"] = json!(" ");
|
||||
let mut executor = NativeProviderCommandExecutor::with_runner_config(&directory, &config);
|
||||
let error = executor
|
||||
.execute(&command(1, "run.prepare", payload))
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
/** Build-time probes only; provider/session admission deadlines are unchanged. */
|
||||
export function buildNodeStartupTimeout(platform = process.platform, architecture = process.arch) {
|
||||
// A freshly copied x64 Node can spend more than 10s in Rosetta's first launch.
|
||||
// Keep this bounded and target-specific instead of weakening other probes.
|
||||
return platform === "darwin" && architecture === "x64" ? 30_000 : 10_000;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { buildNodeStartupTimeout } from "./build-node-startup-timeout.mjs";
|
||||
|
||||
test("cold Rosetta Node startup has a bounded build-only allowance", () => {
|
||||
assert.equal(buildNodeStartupTimeout("darwin", "x64"), 30_000);
|
||||
for (const [platform, architecture] of [["darwin", "arm64"], ["linux", "x64"], ["linux", "arm64"], ["win32", "x64"]]) {
|
||||
assert.equal(buildNodeStartupTimeout(platform, architecture), 10_000);
|
||||
}
|
||||
assert.equal(buildNodeStartupTimeout(), buildNodeStartupTimeout(process.platform, process.arch));
|
||||
});
|
||||
|
||||
test("the ordinary provider pack Node probe uses the bounded startup allowance", () => {
|
||||
const pack = readFileSync(new URL("./build-provider-pack.mjs", import.meta.url), "utf8");
|
||||
assert.match(pack, /spawnSync\(stableNodeCommand, \["--version"\], \{[^}]*env: \{[^}]*\}[^}]*timeout: buildNodeStartupTimeout\(\)/);
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import profiles from "../acpx-profiles.json" with { type: "json" };
|
||||
import { createHash } from "node:crypto";
|
||||
import { execFileSync, spawnSync } from "node:child_process";
|
||||
import {
|
||||
@@ -18,7 +19,8 @@ import { tmpdir } from "node:os";
|
||||
import { dirname, join, relative, resolve } from "node:path";
|
||||
import { createRequire } from "node:module";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { parseProviderPackArguments, materializeCandidateProviderPack } from "./candidate-provider-pack.mjs";
|
||||
import { parseProviderPackArguments, materializeCandidateProviderPack, providerPackProviders, providerPackManifestFields } from "./candidate-provider-pack.mjs";
|
||||
import { buildNodeStartupTimeout } from "./build-node-startup-timeout.mjs";
|
||||
|
||||
const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const workspaceRoot = resolve(packageRoot, "../..");
|
||||
@@ -165,21 +167,21 @@ try {
|
||||
copyFileSync(process.execPath, stableNodeCommand);
|
||||
chmodSync(stableNodeCommand, 0o755);
|
||||
const relocatedNode = spawnSync(stableNodeCommand, ["--version"], {
|
||||
cwd: temporaryRoot, env: { PATH: "/usr/bin:/bin" }, encoding: "utf8", timeout: 10_000,
|
||||
cwd: temporaryRoot, env: { PATH: "/usr/bin:/bin" }, encoding: "utf8", timeout: buildNodeStartupTimeout(),
|
||||
});
|
||||
if (relocatedNode.status !== 0 || relocatedNode.stdout.trim() !== `v${process.versions.node}`) {
|
||||
throw new Error("Provider pack Node is not portable after relocation; build with a standalone Node distribution");
|
||||
}
|
||||
|
||||
const candidateProviders = {};
|
||||
for (const provider of candidates) {
|
||||
for (const provider of providerPackProviders(process.platform, process.arch, candidates)) {
|
||||
const assetPath = `provider-assets/${provider}/${process.platform}-${process.arch}`;
|
||||
const metadata = await materializeCandidateProviderPack({ provider, outputRoot: join(temporaryRoot, assetPath) });
|
||||
if (typeof metadata?.version !== "string" || !metadata.version || metadata.version.length > 120
|
||||
|| !/^sha256:[a-f0-9]{64}$/.test(metadata.profileDigest)
|
||||
|| !/^sha256:[a-f0-9]{64}$/.test(metadata.closureDigest)) throw new Error("Candidate builder omitted its pinned identity");
|
||||
candidateProviders[provider] = { version: metadata.version, profileDigest: metadata.profileDigest,
|
||||
closureDigest: metadata.closureDigest, qualification: "pending", path: assetPath,
|
||||
closureDigest: metadata.closureDigest, qualification: provider === "cursor" ? "qualified" : "pending", path: assetPath,
|
||||
sha256: sha256Tree(join(temporaryRoot, assetPath)) };
|
||||
}
|
||||
|
||||
@@ -309,12 +311,12 @@ try {
|
||||
const payload = {
|
||||
pins: {
|
||||
nodeMinimum: minimumNodeVersion.join("."),
|
||||
codex: "0.160.0",
|
||||
codex: profiles.profiles.codex.agentRuntimeVersion,
|
||||
opencode: "1.18.34",
|
||||
acpx: "0.13.1",
|
||||
grok: "1.0.13",
|
||||
claudeAcp: "0.73.0",
|
||||
codexAcp: "1.6.2",
|
||||
acpx: profiles.acpxVersion,
|
||||
grok: profiles.profiles.grok.agentRuntimeVersion,
|
||||
claudeAcp: profiles.profiles.claude.agentServerVersion,
|
||||
codexAcp: profiles.profiles.codex.agentServerVersion,
|
||||
},
|
||||
target: { platform: process.platform, architecture: process.arch },
|
||||
runnerSourceRevision: `${revision}${dirty ? "-dirty" : ""}`,
|
||||
@@ -327,13 +329,13 @@ try {
|
||||
.update(distDigest)
|
||||
.digest("hex")}`,
|
||||
acpxProfileDigests: {
|
||||
grok: "sha256:f0b698395a3704ed2ffaf84ea19bdb20c36c8a0a70b7c629c7b6ffe144e59e55",
|
||||
grok: profiles.profiles.grok.commandDigest,
|
||||
claude:
|
||||
"sha256:9d73d1f0f121fb96cc8badb28c22d5bff02d8582eb2e40360a81c189e1b9422a",
|
||||
profiles.profiles.claude.commandDigest,
|
||||
codex:
|
||||
"sha256:c4538599d1ab767db5dff50934f13bb5ba313a59d9c4a83e993fac4617ea63d3",
|
||||
profiles.profiles.codex.commandDigest,
|
||||
},
|
||||
...(candidates.length ? { candidateProviders } : {}),
|
||||
...providerPackManifestFields(candidateProviders, candidates),
|
||||
artifacts: {
|
||||
grokLauncher: {
|
||||
path: "dist/providers/grok/launcher.cjs",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { chmod, cp, mkdir } from "node:fs/promises";
|
||||
import { chmod, copyFile, cp, mkdir } from "node:fs/promises";
|
||||
import { builtinModules } from "node:module";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
@@ -8,6 +8,12 @@ import { build } from "esbuild";
|
||||
const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
|
||||
export const verifiedProviderEntrypoints = Object.freeze([
|
||||
Object.freeze({
|
||||
name: "provision-cursor",
|
||||
source: resolve(packageRoot, "scripts/provision-cursor.mjs"),
|
||||
output: resolve(packageRoot, "dist/cli/provision-cursor.js"),
|
||||
verifiedOutput: resolve(packageRoot, "dist/cli/provision-cursor.cjs"),
|
||||
}),
|
||||
Object.freeze({
|
||||
name: "acpx-runtime-sidecar",
|
||||
source: resolve(packageRoot, "src/cli/acpx-runtime-sidecar.ts"),
|
||||
@@ -53,6 +59,7 @@ export async function bundleVerifiedProviderEntrypoints({ write = true } = {}) {
|
||||
if (write) {
|
||||
await mkdir(resolve(packageRoot, "dist/providers"), { recursive: true });
|
||||
await cp(resolve(packageRoot, "src/providers"), resolve(packageRoot, "dist/providers"), { recursive: true });
|
||||
await copyFile(resolve(packageRoot, "cursor-distributions.json"), resolve(packageRoot, "dist/cursor-distributions.json"));
|
||||
}
|
||||
const results = [];
|
||||
for (const entrypoint of verifiedProviderEntrypoints) {
|
||||
|
||||
@@ -1,5 +1,20 @@
|
||||
import profiles from "../acpx-profiles.json" with { type: "json" };
|
||||
import { materializePinnedCursorDistribution } from "./materialize-cursor-distribution.mjs";
|
||||
const CANDIDATES = new Set(["cursor", "copilot", "pi"]);
|
||||
|
||||
/** Only materialized providers enter the serialized manifest and its digest. */
|
||||
export function providerPackManifestFields(providers, candidates) {
|
||||
return {
|
||||
...(providers.cursor ? { providers: { cursor: providers.cursor } } : {}),
|
||||
...(candidates.length ? { candidateProviders: Object.fromEntries(candidates.map(provider => [provider, providers[provider]])) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function providerPackProviders(platform, architecture, candidates) {
|
||||
const cursorSupported = ["darwin-arm64", "darwin-x64", "linux-x64"].includes(`${platform}-${architecture}`);
|
||||
return [...new Set([...(cursorSupported ? ["cursor"] : []), ...candidates])];
|
||||
}
|
||||
|
||||
export function parseProviderPackArguments(args) {
|
||||
let output;
|
||||
const candidates = [];
|
||||
@@ -18,5 +33,11 @@ export function parseProviderPackArguments(args) {
|
||||
/** Closed source-owned builder registry; provider branches add their exact pins. */
|
||||
export async function materializeCandidateProviderPack({ provider, outputRoot }) {
|
||||
if (!CANDIDATES.has(provider)) throw new Error("Unknown candidate provider");
|
||||
if (provider === "cursor") {
|
||||
const result = await materializePinnedCursorDistribution({ destination: outputRoot });
|
||||
return { version: result.version,
|
||||
profileDigest: profiles.profiles.cursor.commandDigest,
|
||||
closureDigest: `sha256:${result.closureSha256}` };
|
||||
}
|
||||
throw new Error(`The ${provider} candidate distribution builder is not included in this source revision`);
|
||||
}
|
||||
@@ -1,11 +1,44 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { parseProviderPackArguments, materializeCandidateProviderPack } from "./candidate-provider-pack.mjs";
|
||||
import { parseProviderPackArguments, materializeCandidateProviderPack, providerPackProviders, providerPackManifestFields } from "./candidate-provider-pack.mjs";
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
test("candidate builds are explicit and leave qualified-only builds unchanged", () => {
|
||||
// Matches the canonical manifest hashing used by the builder and admission.
|
||||
function digest(value) {
|
||||
const canonical = value => Array.isArray(value) ? `[${value.map(canonical).join(",")}]`
|
||||
: value && typeof value === "object" ? `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${canonical(value[key])}`).join(",")}}`
|
||||
: JSON.stringify(value);
|
||||
return createHash("sha256").update(canonical(value)).digest("hex");
|
||||
}
|
||||
|
||||
test("provider manifest digest survives disk JSON on supported and unsupported targets", () => {
|
||||
const cursor = { version: "pinned", profileDigest: "sha256:profile", closureDigest: "sha256:closure" };
|
||||
for (const [platform, architecture] of [["darwin", "arm64"], ["darwin", "x64"], ["linux", "x64"], ["linux", "arm64"], ["win32", "x64"]]) {
|
||||
const selected = providerPackProviders(platform, architecture, []);
|
||||
const payload = { target: { platform, architecture }, ...providerPackManifestFields(selected.includes("cursor") ? { cursor } : {}, []) };
|
||||
const diskPayload = JSON.parse(JSON.stringify(payload));
|
||||
assert.deepEqual(diskPayload, payload);
|
||||
assert.equal(digest(diskPayload), digest(payload));
|
||||
assert.equal(diskPayload.providers?.cursor?.version, selected.includes("cursor") ? "pinned" : undefined);
|
||||
}
|
||||
const candidate = providerPackManifestFields({ cursor }, ["cursor"]);
|
||||
assert.deepEqual(candidate.candidateProviders, { cursor });
|
||||
assert.equal(digest(JSON.parse(JSON.stringify(candidate))), digest(candidate));
|
||||
});
|
||||
|
||||
test("candidate selection is explicit", () => {
|
||||
assert.deepEqual(parseProviderPackArguments(["--", "/pack"]), { output: "/pack", candidates: [] });
|
||||
assert.deepEqual(parseProviderPackArguments(["/pack", "--candidate-providers=pi,cursor"]), { output: "/pack", candidates: ["pi", "cursor"] });
|
||||
});
|
||||
test("normal packs include Cursor on its three pinned targets without breaking other hosts", () => {
|
||||
for (const [platform, architecture] of [["darwin", "arm64"], ["darwin", "x64"], ["linux", "x64"]]) {
|
||||
assert.deepEqual(providerPackProviders(platform, architecture, []), ["cursor"]);
|
||||
assert.deepEqual(providerPackProviders(platform, architecture, ["cursor"]), ["cursor"]);
|
||||
}
|
||||
assert.deepEqual(providerPackProviders("linux", "arm64", []), []);
|
||||
assert.deepEqual(providerPackProviders("win32", "x64", []), []);
|
||||
assert.deepEqual(providerPackProviders("linux", "arm64", ["cursor"]), ["cursor"]);
|
||||
});
|
||||
test("candidate builder cannot admit unknown providers, options or duplicate assets", async () => {
|
||||
for (const args of [["--candidate-providers=cursor,cursor"], ["--candidate-providers=other"], ["--executable=/tmp/x"], ["/one", "/two"]]) {
|
||||
assert.throws(() => parseProviderPackArguments(args));
|
||||
|
||||
@@ -39,10 +39,7 @@ const MAX_MESSAGE_BYTES = 8 * 1024;
|
||||
* chatty provider cannot turn one turn into unbounded socket writes.
|
||||
*/
|
||||
const MAX_TURN_STREAM_FRAMES = 600;
|
||||
const ACPX_QUALIFIED_MODELS = Object.freeze({
|
||||
claude: "claude-sonnet-5",
|
||||
codex: "gpt-5.6-sol",
|
||||
});
|
||||
const ACPX_AGENTS = new Set(["claude", "codex"]);
|
||||
|
||||
/**
|
||||
* Per-browser session capability (track 7U).
|
||||
@@ -225,11 +222,11 @@ function harnessConfiguration(source, fallbackModel) {
|
||||
}
|
||||
const acpxAgent = source.acpxAgent === undefined ? "codex" : String(source.acpxAgent).trim();
|
||||
if (provider === "acpx") {
|
||||
if (!(acpxAgent in ACPX_QUALIFIED_MODELS)) {
|
||||
if (!ACPX_AGENTS.has(acpxAgent)) {
|
||||
throw new RouteError(400, "invalid_acpx_agent", "ACPX agent must be claude or codex.");
|
||||
}
|
||||
if (model !== ACPX_QUALIFIED_MODELS[acpxAgent]) {
|
||||
throw new RouteError(400, "invalid_model", `The qualified ACPX ${acpxAgent} profile requires exact model ${ACPX_QUALIFIED_MODELS[acpxAgent]}.`);
|
||||
if (!model) {
|
||||
throw new RouteError(400, "invalid_model", "ACPX requires an explicit model.");
|
||||
}
|
||||
}
|
||||
const requestedManagedProfileId = source.managedProfileId === undefined || source.managedProfileId === null
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"function createCursorNativeUsage(promptId) {\n if (typeof promptId !== \"string\" || !/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/.test(promptId)) throw new Error(\"Invalid Cursor usage prompt identity\");\n const limits = { maxObservations: 64, maxInvocations: 64, maxBytes: 16384 };\n // Reserve bounded ACPX request/message identities and wrapper keys within 16 KiB.\n const envelopeMaxBytes = limits.maxBytes - 640;\n const fields = [\"inputTokens\", \"outputTokens\", \"cacheReadTokens\", \"cacheWriteTokens\", \"reasoningTokens\"];\n const reasons = new Set([\"native_counter_semantics_unverified\"]);\n const observations = [];\n const children = new WeakMap();\n const trackedInvocations = new Set(); // At most maxInvocations, including children.\n let closed = false, truncated = false, invocations = 0;\n let finished;\n const envelope = () => ({\n schema: \"paperclip.cursor.native-usage.v1\", source: \"native_turn_ended\", promptId,\n completeness: \"partial\", reasons: [...reasons], observations, limits, truncated,\n });\n const truncate = () => { truncated = true; reasons.add(\"observation_limit_reached\"); };\n function begin(role, nativeRun) {\n if (closed) return null;\n if (invocations >= limits.maxInvocations) { truncate(); return null; }\n const invocationId = `invocation-${++invocations}`;\n let ended = false, sequence = 0;\n const collector = {\n observe(update) {\n if (closed || ended || truncated || update?.message?.case !== \"turnEnded\") return;\n if (observations.length >= limits.maxObservations) { truncate(); return; }\n const value = update.message.value;\n const counters = {};\n for (const field of fields) {\n const raw = value?.[field];\n if (raw === undefined) continue;\n const number = typeof raw === \"bigint\" && raw >= 0n && raw <= BigInt(Number.MAX_SAFE_INTEGER) ? Number(raw) : raw;\n if (typeof number === \"number\" && Number.isSafeInteger(number) && number >= 0) counters[field] = number;\n else reasons.add(\"invalid_native_counter\");\n }\n if (Object.keys(counters).length < fields.length) reasons.add(\"native_counters_missing\");\n if (sequence > 0) reasons.add(\"multiple_terminal_observations\");\n observations.push({ invocationId, role, nativeRun, sequence: ++sequence, counters });\n },\n end() { if (!ended && sequence === 0 && !closed) reasons.add(\"native_terminal_not_observed\"); ended = true; },\n };\n trackedInvocations.add(collector);\n return collector;\n }\n return {\n beginParent() { return begin(\"parent\", invocations + 1); },\n observeChild(child, update) {\n if (closed || !child || child.terminal) return;\n if (!Number.isSafeInteger(child.runs) || child.runs < 1) { reasons.add(\"child_run_attribution_unverified\"); return; }\n // The native callback identifies only the child, not its run generation.\n // After ID reuse, delayed old-run updates cannot be distinguished safely.\n if (child.runs !== 1) { reasons.add(\"child_run_attribution_unverified\"); return; }\n let run = children.get(child);\n if (!run || run.nativeRun !== child.runs) {\n run?.collector?.end();\n run = { nativeRun: child.runs, collector: begin(\"child\", child.runs) };\n children.set(child, run);\n }\n run.collector?.observe(update);\n },\n finish() {\n if (finished !== undefined) return JSON.parse(finished);\n for (const invocation of trackedInvocations) invocation.end();\n trackedInvocations.clear();\n closed = true;\n if (observations.length === 0) reasons.add(\"native_terminal_not_observed\");\n // Bound the FINAL envelope after every reason has been added. Its fixed\n // ASCII keys/IDs/reasons make JSON length equal to its UTF-8 byte length.\n finished = JSON.stringify(envelope());\n while (finished.length > envelopeMaxBytes && observations.length > 0) {\n observations.pop(); truncate(); finished = JSON.stringify(envelope());\n }\n // Detached snapshot: late provider callbacks cannot mutate a returned receipt.\n return JSON.parse(finished);\n },\n close() { closed = true; trackedInvocations.clear(); },\n };\n}"
|
||||
@@ -0,0 +1,81 @@
|
||||
/** Self-contained: embedded verbatim into the digest-pinned ACP module.
|
||||
* Observations are NOT usage accounting. Native counter semantics remain unknown.
|
||||
*/
|
||||
export function createCursorNativeUsage(promptId) {
|
||||
if (typeof promptId !== "string" || !/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/.test(promptId)) throw new Error("Invalid Cursor usage prompt identity");
|
||||
const limits = { maxObservations: 64, maxInvocations: 64, maxBytes: 16384 };
|
||||
// Reserve bounded ACPX request/message identities and wrapper keys within 16 KiB.
|
||||
const envelopeMaxBytes = limits.maxBytes - 640;
|
||||
const fields = ["inputTokens", "outputTokens", "cacheReadTokens", "cacheWriteTokens", "reasoningTokens"];
|
||||
const reasons = new Set(["native_counter_semantics_unverified"]);
|
||||
const observations = [];
|
||||
const children = new WeakMap();
|
||||
const trackedInvocations = new Set(); // At most maxInvocations, including children.
|
||||
let closed = false, truncated = false, invocations = 0;
|
||||
let finished;
|
||||
const envelope = () => ({
|
||||
schema: "paperclip.cursor.native-usage.v1", source: "native_turn_ended", promptId,
|
||||
completeness: "partial", reasons: [...reasons], observations, limits, truncated,
|
||||
});
|
||||
const truncate = () => { truncated = true; reasons.add("observation_limit_reached"); };
|
||||
function begin(role, nativeRun) {
|
||||
if (closed) return null;
|
||||
if (invocations >= limits.maxInvocations) { truncate(); return null; }
|
||||
const invocationId = `invocation-${++invocations}`;
|
||||
let ended = false, sequence = 0;
|
||||
const collector = {
|
||||
observe(update) {
|
||||
if (closed || ended || truncated || update?.message?.case !== "turnEnded") return;
|
||||
if (observations.length >= limits.maxObservations) { truncate(); return; }
|
||||
const value = update.message.value;
|
||||
const counters = {};
|
||||
for (const field of fields) {
|
||||
const raw = value?.[field];
|
||||
if (raw === undefined) continue;
|
||||
const number = typeof raw === "bigint" && raw >= 0n && raw <= BigInt(Number.MAX_SAFE_INTEGER) ? Number(raw) : raw;
|
||||
if (typeof number === "number" && Number.isSafeInteger(number) && number >= 0) counters[field] = number;
|
||||
else reasons.add("invalid_native_counter");
|
||||
}
|
||||
if (Object.keys(counters).length < fields.length) reasons.add("native_counters_missing");
|
||||
if (sequence > 0) reasons.add("multiple_terminal_observations");
|
||||
observations.push({ invocationId, role, nativeRun, sequence: ++sequence, counters });
|
||||
},
|
||||
end() { if (!ended && sequence === 0 && !closed) reasons.add("native_terminal_not_observed"); ended = true; },
|
||||
};
|
||||
trackedInvocations.add(collector);
|
||||
return collector;
|
||||
}
|
||||
return {
|
||||
beginParent() { return begin("parent", invocations + 1); },
|
||||
observeChild(child, update) {
|
||||
if (closed || !child || child.terminal) return;
|
||||
if (!Number.isSafeInteger(child.runs) || child.runs < 1) { reasons.add("child_run_attribution_unverified"); return; }
|
||||
// The native callback identifies only the child, not its run generation.
|
||||
// After ID reuse, delayed old-run updates cannot be distinguished safely.
|
||||
if (child.runs !== 1) { reasons.add("child_run_attribution_unverified"); return; }
|
||||
let run = children.get(child);
|
||||
if (!run || run.nativeRun !== child.runs) {
|
||||
run?.collector?.end();
|
||||
run = { nativeRun: child.runs, collector: begin("child", child.runs) };
|
||||
children.set(child, run);
|
||||
}
|
||||
run.collector?.observe(update);
|
||||
},
|
||||
finish() {
|
||||
if (finished !== undefined) return JSON.parse(finished);
|
||||
for (const invocation of trackedInvocations) invocation.end();
|
||||
trackedInvocations.clear();
|
||||
closed = true;
|
||||
if (observations.length === 0) reasons.add("native_terminal_not_observed");
|
||||
// Bound the FINAL envelope after every reason has been added. Its fixed
|
||||
// ASCII keys/IDs/reasons make JSON length equal to its UTF-8 byte length.
|
||||
finished = JSON.stringify(envelope());
|
||||
while (finished.length > envelopeMaxBytes && observations.length > 0) {
|
||||
observations.pop(); truncate(); finished = JSON.stringify(envelope());
|
||||
}
|
||||
// Detached snapshot: late provider callbacks cannot mutate a returned receipt.
|
||||
return JSON.parse(finished);
|
||||
},
|
||||
close() { closed = true; trackedInvocations.clear(); },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { createCursorNativeUsage } from "./cursor-native-usage.mjs";
|
||||
import cursorNativeUsageSource from "./cursor-native-usage-source.json" with { type: "json" };
|
||||
|
||||
test("public bundles preserve the collector's exact pinned patch bytes", () => {
|
||||
assert.equal(cursorNativeUsageSource, createCursorNativeUsage.toString());
|
||||
});
|
||||
|
||||
const id = "00000000-0000-0000-0000-000000000001";
|
||||
const event = counters => ({ message: { case: "turnEnded", value: counters } });
|
||||
test("observes numeric counters without accounting, totals, or invented fields", () => {
|
||||
const usage = createCursorNativeUsage(id), invocation = usage.beginParent();
|
||||
invocation.observe(event({ inputTokens: 100n, outputTokens: 3, reasoningTokens: 2n, cost: 9, text: "private" }));
|
||||
invocation.end();
|
||||
const result = usage.finish();
|
||||
assert.equal(result.completeness, "partial");
|
||||
assert.ok(result.reasons.includes("native_counter_semantics_unverified"));
|
||||
assert.deepEqual(result.observations[0].counters, { inputTokens: 100, outputTokens: 3, reasoningTokens: 2 });
|
||||
assert.ok(!JSON.stringify(result).includes("private"));
|
||||
assert.equal(result.usage, undefined);
|
||||
});
|
||||
test("all native fields including explicit zeros still remain semantically unverified", () => {
|
||||
const usage = createCursorNativeUsage(id);
|
||||
usage.beginParent().observe(event({ inputTokens: 0n, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, reasoningTokens: 0 }));
|
||||
assert.equal(usage.finish().completeness, "partial");
|
||||
assert.deepEqual(usage.finish().reasons, ["native_counter_semantics_unverified"]);
|
||||
});
|
||||
test("rejects invalid identity and omits invalid counters", () => {
|
||||
assert.throws(() => createCursorNativeUsage("private arbitrary text"));
|
||||
for (const value of [-1, 1.2, NaN, Infinity, null, "20", {}, -1n, BigInt(Number.MAX_SAFE_INTEGER) + 1n]) {
|
||||
const usage = createCursorNativeUsage(id);
|
||||
usage.beginParent().observe(event({ inputTokens: value, outputTokens: 1 }));
|
||||
const result = usage.finish();
|
||||
assert.deepEqual(result.observations[0].counters, { outputTokens: 1 });
|
||||
assert.ok(result.reasons.includes("invalid_native_counter"));
|
||||
}
|
||||
});
|
||||
test("separate native invocations retain repeated snapshots without adding or deduplicating counts", () => {
|
||||
const usage = createCursorNativeUsage(id), first = usage.beginParent();
|
||||
first.observe(event({ inputTokens: 4 })); first.observe(event({ inputTokens: 4 })); first.end();
|
||||
first.observe(event({ inputTokens: 999 }));
|
||||
usage.beginParent().observe(event({ inputTokens: 2 }));
|
||||
const result = usage.finish();
|
||||
assert.deepEqual(result.observations.map(row => row.counters.inputTokens), [4, 4, 2]);
|
||||
assert.deepEqual(result.observations.map(row => row.invocationId), ["invocation-1", "invocation-1", "invocation-2"]);
|
||||
assert.ok(result.reasons.includes("multiple_terminal_observations"));
|
||||
});
|
||||
test("history, late callbacks, closed prompts and reused child runs stay isolated", () => {
|
||||
const usage = createCursorNativeUsage(id), child = { runs: 1, terminal: false };
|
||||
const first = usage.beginParent();
|
||||
first.observe({ message: { case: "tokenDelta", value: { tokens: 100 } } });
|
||||
usage.observeChild(child, event({ outputTokens: 5 }));
|
||||
child.runs = 2; usage.observeChild(child, event({ outputTokens: 6 }));
|
||||
child.terminal = true; usage.observeChild(child, event({ outputTokens: 999 }));
|
||||
const result = usage.finish();
|
||||
first.observe(event({ outputTokens: 999 })); child.terminal = false; usage.observeChild(child, event({ outputTokens: 999 }));
|
||||
assert.deepEqual(result.observations.map(row => [row.role, row.nativeRun, row.counters.outputTokens]), [["child", 1, 5]]);
|
||||
assert.ok(result.reasons.includes("child_run_attribution_unverified"));
|
||||
assert.deepEqual(usage.finish(), result);
|
||||
assert.equal(usage.beginParent(), null);
|
||||
assert.deepEqual(createCursorNativeUsage(id).finish().observations, []);
|
||||
});
|
||||
test("bounds observation count, invocation count, and UTF-8 envelope bytes", () => {
|
||||
const usage = createCursorNativeUsage(id);
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
const invocation = usage.beginParent();
|
||||
for (let j = 0; j < 3; j++) invocation?.observe(event(Object.fromEntries(
|
||||
["inputTokens", "outputTokens", "cacheReadTokens", "cacheWriteTokens", "reasoningTokens"].map(k => [k, Number.MAX_SAFE_INTEGER]))));
|
||||
}
|
||||
const result = usage.finish();
|
||||
assert.ok(result.observations.length <= 64);
|
||||
assert.ok(Buffer.byteLength(JSON.stringify(result)) <= 15744);
|
||||
assert.equal(result.truncated, true);
|
||||
assert.ok(result.reasons.includes("observation_limit_reached"));
|
||||
});
|
||||
test("finish finalizes missing children and open parents even when another invocation reported", () => {
|
||||
for (const missing of ["child", "parent"]) {
|
||||
const usage = createCursorNativeUsage(id);
|
||||
usage.beginParent().observe(event({ inputTokens: 1 }));
|
||||
if (missing === "child") usage.observeChild({ runs: 1, terminal: false }, { message: { case: "textDelta" } });
|
||||
else usage.beginParent();
|
||||
const result = usage.finish();
|
||||
assert.ok(result.reasons.includes("native_terminal_not_observed"));
|
||||
assert.equal(result.observations.length, 1);
|
||||
assert.deepEqual(usage.finish(), result);
|
||||
result.reasons.length = 0;
|
||||
assert.ok(usage.finish().reasons.includes("native_terminal_not_observed"));
|
||||
}
|
||||
});
|
||||
test("final reason growth cannot exceed the envelope byte cap", () => {
|
||||
let trimmed = false;
|
||||
for (let count = 48; count <= 64; count++) {
|
||||
const usage = createCursorNativeUsage(id), parent = usage.beginParent();
|
||||
for (let i = 0; i < count; i++) parent.observe(event(Object.fromEntries(
|
||||
["inputTokens", "outputTokens", "cacheReadTokens", "cacheWriteTokens", "reasoningTokens"].map(k => [k, Number.MAX_SAFE_INTEGER]))));
|
||||
usage.observeChild({ runs: 1, terminal: false }, { message: { case: "textDelta" } });
|
||||
usage.observeChild({ runs: 2, terminal: false }, event({ inputTokens: 1 }));
|
||||
const result = usage.finish();
|
||||
assert.ok(result.reasons.includes("native_terminal_not_observed"));
|
||||
assert.ok(result.reasons.includes("child_run_attribution_unverified"));
|
||||
assert.ok(Buffer.byteLength(JSON.stringify(result)) <= 15744);
|
||||
if (result.observations.length < count) {
|
||||
trimmed = true; assert.equal(result.truncated, true);
|
||||
assert.ok(result.reasons.includes("observation_limit_reached"));
|
||||
}
|
||||
}
|
||||
assert.equal(trimmed, true);
|
||||
});
|
||||
|
||||
test("maximum bounded ACPX identities and envelope fit the final stored object", () => {
|
||||
const usage = createCursorNativeUsage(id), parent = usage.beginParent();
|
||||
for (let count = 0; count < 64; count++) parent.observe(event(Object.fromEntries(
|
||||
["inputTokens", "outputTokens", "cacheReadTokens", "cacheWriteTokens", "reasoningTokens"].map(k => [k, Number.MAX_SAFE_INTEGER]))));
|
||||
usage.beginParent();
|
||||
const receipt = usage.finish();
|
||||
assert.equal(receipt.limits.maxBytes, 16384);
|
||||
assert.equal(receipt.truncated, true);
|
||||
assert.ok(Buffer.byteLength(JSON.stringify(receipt)) <= 15744);
|
||||
const stored = { request_id: "r".repeat(240), prompt_message_id: "m".repeat(240), receipt };
|
||||
assert.ok(Buffer.byteLength(JSON.stringify(stored)) <= 16384);
|
||||
assert.ok(Buffer.byteLength(JSON.stringify(stored)) - Buffer.byteLength(JSON.stringify(receipt)) <= 640);
|
||||
});
|
||||
|
||||
|
||||
test("missing, invalid and reused child run ordinals are explicitly partial", () => {
|
||||
for (const runs of [undefined, null, 0, -1, 1.5, "1", NaN, Infinity, Number.MAX_SAFE_INTEGER + 1, 2]) {
|
||||
const usage = createCursorNativeUsage(id);
|
||||
const parent = usage.beginParent(); parent.observe(event({ inputTokens: 1 }));
|
||||
usage.observeChild({ runs, terminal: false }, event({ inputTokens: 999 }));
|
||||
const receipt = usage.finish();
|
||||
assert.equal(receipt.completeness, "partial");
|
||||
assert.equal(receipt.observations.length, 1);
|
||||
assert.ok(receipt.reasons.includes("child_run_attribution_unverified"));
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { cursorRuntimeCachePath } from "../src/drivers/acpx/cursor-runtime-cache.ts";
|
||||
|
||||
/** Recognize the provisioner in either supported published package layout. */
|
||||
export function cursorProvisionerPackageRoot(moduleUrl) {
|
||||
const url = new URL(moduleUrl);
|
||||
if (url.protocol !== "file:" || url.search || url.hash) throw new Error("Cursor setup requires a published provisioner");
|
||||
const path = fileURLToPath(url);
|
||||
if (/\/dist\/vendor\/paperclip-runner\/cli\/provision-cursor\.(?:cjs|js)$/.test(path)) return resolve(dirname(path), "..");
|
||||
if (/\/dist\/cli\/provision-cursor\.(?:cjs|js)$/.test(path)) return resolve(dirname(path), "../..");
|
||||
throw new Error("Cursor setup requires a published provisioner");
|
||||
}
|
||||
|
||||
/** Validate the installed entrypoint, then install for the OS user running setup. */
|
||||
export function cursorProvisionerDestination(moduleUrl, distribution, home) {
|
||||
cursorProvisionerPackageRoot(moduleUrl);
|
||||
return cursorRuntimeCachePath(distribution.closureSha256, distribution.platform, distribution.architecture, home);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { cursorProvisionerDestination, cursorProvisionerPackageRoot } from "./cursor-provisioner-layout.mjs";
|
||||
|
||||
test("recognizes the public server's vendored setup entrypoint", () => {
|
||||
assert.equal(cursorProvisionerPackageRoot("file:///consumer/node_modules/@paperclipai/server/dist/vendor/paperclip-runner/cli/provision-cursor.cjs"), "/consumer/node_modules/@paperclipai/server/dist/vendor/paperclip-runner");
|
||||
});
|
||||
test("recognizes the standalone runner's setup entrypoint", () => {
|
||||
assert.equal(cursorProvisionerPackageRoot("file:///consumer/node_modules/@paperclipai/paperclip-runner/dist/cli/provision-cursor.cjs"), "/consumer/node_modules/@paperclipai/paperclip-runner");
|
||||
});
|
||||
test("unbundled and foreign layouts cannot provision outside an owned package", () => {
|
||||
for (const url of ["file:///repo/packages/paperclip-runner/scripts/provision-cursor.mjs", "file:///tmp/provision-cursor.cjs", "https://example.com/dist/cli/provision-cursor.js", "file:///consumer/dist/cli/provision-cursor.js?redirect=1"]) assert.throws(() => cursorProvisionerPackageRoot(url), /published provisioner/);
|
||||
});
|
||||
|
||||
test("global and local npm installations provision the same account-owned pinned cache", () => {
|
||||
const distribution = { platform: "linux", architecture: "x64", closureSha256: "a".repeat(64) };
|
||||
for (const prefix of ["/usr/local/lib/node_modules", "/home/operator/project/node_modules"]) {
|
||||
assert.equal(cursorProvisionerDestination(`file://${prefix}/@paperclipai/server/dist/vendor/paperclip-runner/cli/provision-cursor.cjs`, distribution, "/home/operator"),
|
||||
`/home/operator/.paperclip/runtimes/cursor/linux-x64/${distribution.closureSha256}`);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import cursorNativeUsageSource from "./cursor-native-usage-source.json" with { type: "json" };
|
||||
|
||||
// This is an owned ACP-only patch of the immutable vendor archive. The legacy
|
||||
// Cursor adapter and the vendor's interactive CLI are not changed.
|
||||
export const CURSOR_RUNTIME_PATCH_VERSION = "paperclip-cursor-usage-v4";
|
||||
export const CURSOR_RUNTIME_PATCH_PINS = Object.freeze({
|
||||
"darwin-arm64": { file: "5672.index.js", before: "7784c8b16d4e639814c13b12be2a687f5dc2cd98cbf29ed0a10820778ab1bf62", after: "35c5bf13b261ea884bb0e7b26ae7bd4c4999b0b4b08ab338bc22f2ccce2b8108" },
|
||||
"darwin-x64": { file: "9841.index.js", before: "3c0aaf6ecc4fceb0f95e1731deec75384984570d14d0871cecec11972b948ac4", after: "5b2510248429febe1c9aead6ad43d35d89cdedc4dd057d4f07495efa33c7da55" },
|
||||
"linux-x64": { file: "1699.index.js", before: "2de420f1b31e70ca74b083a1ce5b519c2abffa5789d71cc06e84d2c38acb7c07", after: "38fe96fa71463372bffdd9efa3e5608293ff075d079149fe20a1625783c4eb39" },
|
||||
});
|
||||
const digest = value => createHash("sha256").update(value).digest("hex");
|
||||
|
||||
export function replaceCursorPatchAnchor(source, before, after) {
|
||||
if (source.split(before).length !== 2) throw new Error("Cursor runtime patch requires exactly one source anchor");
|
||||
return source.replace(before, () => after);
|
||||
}
|
||||
|
||||
/** Never exported as a general-purpose runtime hook; bytes are checked first. */
|
||||
export function patchCursorRuntimeSource(source, platform) {
|
||||
const pin = CURSOR_RUNTIME_PATCH_PINS[platform];
|
||||
if (!pin || digest(source) !== pin.before) throw new Error("Cursor runtime patch input digest mismatch");
|
||||
const replace = (before, after) => { source = replaceCursorPatchAnchor(source, before, after); };
|
||||
// Do not even initialize the ambient loader: load() can start subprocesses.
|
||||
replace(`te=r.aK.init(X,W.projectRoot,W.projectDir,!1,ee,new ${platform === "darwin-x64" ? "m" : "f"}.v)`, "te=null");
|
||||
replace("se=yield te.load(e),ie=new u.uz(se)", "se=new u.i9({}),ie=new u.uz(se)");
|
||||
// Session-scoped owned MCP stays functional, including an explicitly empty
|
||||
// list. It never borrows clients from an earlier session or ambient lease.
|
||||
const sessionMcp = platform === "darwin-arm64" ? "A" : "x";
|
||||
replace("if(0===i.length)return t.mcpLease;", `if(0===i.length)return new ${sessionMcp}.uz(new ${sessionMcp}.i9({}));`);
|
||||
replace("if(0===r.length)return t.mcpLease;", `if(0===r.length)return new ${sessionMcp}.uz(new ${sessionMcp}.i9({}));`);
|
||||
replace("u=Object.assign({},yield t.mcpLease.getClients(e))", "u=Object.create(null)");
|
||||
// Stop hook discovery at the source, including the asynchronous remote team
|
||||
// refresh. A filesystem watcher would leave an execution race here.
|
||||
replace("null!=re&&n.dashboardClient&&(ae=(0,y.a)({dashboardClient:n.dashboardClient,teamId:re}));", "void re;");
|
||||
replace("let ue=yield ce.load();", "let ue={errors:[],configDirs:{}};");
|
||||
// ACP ignores generic _meta.systemPrompt. Bind the controller-composed bytes
|
||||
// through the native global-rule request context, for both new and load. The
|
||||
// process-owned snapshot never discovers an instruction path in the workspace.
|
||||
const instructionModule = 'n.d(t,{Y:()=>K});';
|
||||
replace(instructionModule, `n.d(t,{Y:()=>K,paperclipInstructionState:()=>paperclipCursorInstructionState});
|
||||
const paperclipCursorInstructionCrypto=n("node:crypto"),paperclipCursorInstructionRules=n("../proto/dist/generated/agent/v1/cursor_rules_pb.js");
|
||||
let paperclipCursorInstructionSnapshot;
|
||||
function paperclipCursorInstructionState(){
|
||||
if(paperclipCursorInstructionSnapshot)return paperclipCursorInstructionSnapshot;
|
||||
const raw=process.env.PAPERCLIP_CURSOR_INSTRUCTIONS;
|
||||
if(typeof raw!=="string"||Buffer.byteLength(raw)>200000)throw new Error("Cursor instruction binding missing or oversized");
|
||||
let value;try{value=JSON.parse(raw)}catch{throw new Error("Cursor instruction binding invalid")}
|
||||
if(!value||typeof value!=="object"||Array.isArray(value)||Object.keys(value).sort().join(",")!=="content,digest,schema"||value.schema!=="paperclip.cursor.instructions.v1"||typeof value.content!=="string"||value.content.includes("\\0")||Buffer.byteLength(value.content)>32768||typeof value.digest!=="string"||!/^sha256:[a-f0-9]{64}$/.test(value.digest))throw new Error("Cursor instruction binding invalid");
|
||||
const digest="sha256:"+paperclipCursorInstructionCrypto.createHash("sha256").update(value.content,"utf8").digest("hex");
|
||||
if(value.digest!==digest)throw new Error("Cursor instruction binding digest mismatch");
|
||||
const rules=value.content.length===0?[]:[new paperclipCursorInstructionRules.DX({fullPath:"paperclip://runtime/instructions",content:value.content,type:new paperclipCursorInstructionRules.f5({type:{case:"global",value:new paperclipCursorInstructionRules.i9}})})];
|
||||
return paperclipCursorInstructionSnapshot=Object.freeze({rules,ack:Object.freeze({schema:"paperclip.cursor.instructions.v1",digest,byteLength:Buffer.byteLength(value.content)})});
|
||||
}`);
|
||||
replace("cursorRulesService:n.cursorRulesService,", "cursorRulesService:n.cursorRulesService,additionalRules:paperclipCursorInstructionState().rules,");
|
||||
replace("S={sessionId:o,modes:this.buildModesState(a),models:I,configOptions:w}", "S={sessionId:o,modes:this.buildModesState(a),models:I,configOptions:w,_meta:{paperclipCursorInstructions:y.paperclipInstructionState().ack}}");
|
||||
const loadConfig = platform === "darwin-x64" ? "f" : "m";
|
||||
replace(`w={modes:this.buildModesState(r),models:b,configOptions:${loadConfig}}`, `w={modes:this.buildModesState(r),models:b,configOptions:${loadConfig},_meta:{paperclipCursorInstructions:y.paperclipInstructionState().ack}}`);
|
||||
// Use the exact SDK module already present in this verified vendor chunk.
|
||||
const sdk = [...source.matchAll(/n\("([^"\n]+@agentclientprotocol\/sdk\/dist\/acp\.js)"\)/g)].map(match => match[1]);
|
||||
if (new Set(sdk).size !== 1) throw new Error("Cursor runtime patch SDK identity is ambiguous");
|
||||
const errorType = "paperclipCursorResponseError";
|
||||
// processPrompt(e,t,n) shadows the webpack require name n. Bind the SDK
|
||||
// constructor at module scope rather than invoking that turn-ID argument.
|
||||
const sessionClass = platform === "darwin-arm64" ? "B" : "z";
|
||||
replace(`n.d(t,{m:()=>${sessionClass}});var s=n("node:fs/promises")`, `n.d(t,{m:()=>${sessionClass}});const ${errorType}=n(${JSON.stringify(sdk[0])}).GI;var s=n("node:fs/promises")`);
|
||||
const oldAction = 'if(e instanceof r.ao){const t=null!==(u={login:"Please sign in to continue",upgrade:"Upgrade your plan to continue",payment:"Add a payment method to continue",config:"Check your settings to continue"}[e.action])&&void 0!==u?u:e.message;return void(yield this.sendAgentMessageChunk(`\\n\\n${t}`))}';
|
||||
const newAction = `if(e instanceof r.ao){const action=["login","upgrade","payment","config"].includes(e.action)?e.action:"unknown";throw new (${errorType})(action==="login"?-32000:-32603,"Cursor provider action required: "+action,{schema:"paperclip.cursor.provider-error.v1",kind:"action_required",action})}`;
|
||||
replace(oldAction, newAction);
|
||||
replace('if(e instanceof g.T&&e.code===m.C.Unauthenticated)return void(yield this.sendAgentMessageChunk("\\n\\nError: [unauthenticated] Backend rejected authentication. Verify this is a User API Key for the same endpoint/environment, then rerun with --debug for request-level auth logs."));'.replace('m.C.Unauthenticated', `${platform === "darwin-x64" ? "f" : "m"}.C.Unauthenticated`),
|
||||
`if(e instanceof g.T&&e.code===${platform === "darwin-x64" ? "f" : "m"}.C.Unauthenticated)throw new (${errorType})(-32000,"Cursor provider authentication rejected",{schema:"paperclip.cursor.provider-error.v1",kind:"authentication_required"});`);
|
||||
// This diagnostic envelope never emits standard ACP usage or cost. Keep the
|
||||
// collector local to handlePrompt, including overlapping cancelled prompts.
|
||||
replace(`const ${errorType}=`, `const paperclipCreateCursorUsage=${cursorNativeUsageSource};const ${errorType}=`);
|
||||
replace("handlePrompt(e){return V(this,void 0,void 0,(function*(){var t,n,o;", "handlePrompt(e){return V(this,void 0,void 0,(function*(){var t,n,o;const paperclipUsage=paperclipCreateCursorUsage(crypto.randomUUID());let paperclipUsagePublisher,paperclipUsageTurn;try{");
|
||||
replace("n.beginTurn(),d=()=>", "n.beginTurn();paperclipUsagePublisher=this.subagentPublisher;paperclipUsageTurn=l;if(paperclipUsagePublisher&&l!==undefined){paperclipUsagePublisher.paperclipUsageCollectors??=new Map;paperclipUsagePublisher.paperclipUsageCollectors.set(l,paperclipUsage)}const d=()=>");
|
||||
replace("yield this.processPrompt(e,s,l)", "yield this.processPrompt(e,s,l,paperclipUsage)");
|
||||
replace('{stopReason:"cancelled"}):{stopReason:"end_turn"}}))}claimTaskToolCall', '{stopReason:"cancelled",_meta:{paperclipCursorUsage:paperclipUsage.finish()}}):{stopReason:"end_turn",_meta:{paperclipCursorUsage:paperclipUsage.finish()}}}finally{paperclipUsage.close();paperclipUsagePublisher?.paperclipUsageCollectors?.delete(paperclipUsageTurn)}}))}claimTaskToolCall');
|
||||
replace("processPrompt(e,t,n){", "processPrompt(e,t,n,paperclipUsage){");
|
||||
// A fresh sendUpdate closure for EACH native invocation prevents a delayed
|
||||
// callback from an earlier follow-up from being attributed to the next one.
|
||||
const invocation = /D=e=>this\.sharedServices\.agentClient\.run\(t,this\.agentStore\.getConversationStateStructure\(\),e,b\.modelDetails,\$,this\.resources,this\.agentStore\.getBlobStore\(\),u,this\.agentStore,([A-Za-z]+),([A-Za-z]+)\)/g;
|
||||
const invocations = [...source.matchAll(invocation)];
|
||||
if (invocations.length !== 1) throw new Error("Cursor usage invocation anchor is ambiguous");
|
||||
const call = invocations[0];
|
||||
replace(call[0], `D=async e=>{const observation=paperclipUsage?.beginParent();try{return await this.sharedServices.agentClient.run(t,this.agentStore.getConversationStateStructure(),e,b.modelDetails,{...$,sendUpdate:async(context,update)=>{observation?.observe(update);return $.sendUpdate(context,update)}},this.resources,this.agentStore.getBlobStore(),u,this.agentStore,${call[1]},${call[2]})}finally{observation?.end()}}`);
|
||||
replace("onInteractionUpdate(e,t){if(!this.enabled)return;const n=this.children.get(e),o=null==n?void 0:n.presenter;", "onInteractionUpdate(e,t){if(!this.enabled)return;const n=this.children.get(e),o=null==n?void 0:n.presenter;if(n&&!n.terminal)this.paperclipUsageCollectors?.get(n.turn)?.observeChild(n,t);");
|
||||
return source;
|
||||
}
|
||||
|
||||
export async function applyCursorRuntimePatch(directory, platform) {
|
||||
const pin = CURSOR_RUNTIME_PATCH_PINS[platform];
|
||||
if (!pin) throw new Error("Cursor runtime patch platform is unsupported");
|
||||
const path = join(directory, pin.file);
|
||||
const patched = patchCursorRuntimeSource(await readFile(path, "utf8"), platform);
|
||||
if (digest(patched) !== pin.after) throw new Error("Cursor runtime patch output digest mismatch");
|
||||
await writeFile(path, patched);
|
||||
return { patchVersion: CURSOR_RUNTIME_PATCH_VERSION, file: pin.file, sha256: pin.after };
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const root = new URL("../", import.meta.url);
|
||||
const readJson = async path => JSON.parse(await readFile(new URL(path, root), "utf8"));
|
||||
const manifest = await readJson("acpx-profiles.json");
|
||||
const distributions = await readJson("cursor-distributions.json");
|
||||
const contract = await readJson("cursor-contract.json");
|
||||
const pkg = await readJson("package.json");
|
||||
assert.equal(manifest.schema, "paperclip.acpx-profiles.v1");
|
||||
assert.equal(manifest.acpxVersion, pkg.dependencies.acpx);
|
||||
const entries = Object.entries(manifest.profiles);
|
||||
for (const [agent, profile] of entries) {
|
||||
assert.match(agent, /^[a-z]+$/);
|
||||
assert.ok(Number.isSafeInteger(profile.agentProfileVersion) && profile.agentProfileVersion > 0);
|
||||
for (const key of ["agentServerPackage", "agentServerVersion"]) assert.ok(typeof profile[key] === "string" && profile[key].length > 0);
|
||||
assert.equal(profile.agentRuntimePackage === null, profile.agentRuntimeVersion === null);
|
||||
assert.match(profile.commandDigest, /^sha256:[a-f0-9]{64}$/);
|
||||
assert.equal(typeof profile.requiresProviderPolicy, "boolean");
|
||||
assert.ok([undefined, "pending"].includes(profile.qualificationStatus));
|
||||
for (const [name, version] of [[profile.agentServerPackage, profile.agentServerVersion], [profile.agentRuntimePackage, profile.agentRuntimeVersion]]) {
|
||||
if (name && Object.hasOwn(pkg.dependencies, name)) assert.equal(version, pkg.dependencies[name], `${agent}: installed dependency drift`);
|
||||
}
|
||||
}
|
||||
// The Cursor contract is an immutable release attestation, not another runtime
|
||||
// lookup table. Refuse generation if a release declaration disagrees with it.
|
||||
assert.equal(manifest.profiles.cursor.agentServerVersion, distributions.version);
|
||||
assert.equal(manifest.profiles.cursor.agentServerVersion, contract.vendor);
|
||||
assert.equal(manifest.profiles.cursor.agentProfileVersion, contract.profileVersion);
|
||||
assert.equal(manifest.profiles.cursor.commandDigest, contract.commandDigest);
|
||||
assert.equal(distributions.patchVersion, contract.nativePatch);
|
||||
const canonicalJson = value => value && typeof value === "object"
|
||||
? Array.isArray(value) ? `[${value.map(canonicalJson).join(",")}]`
|
||||
: `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${canonicalJson(value[key])}`).join(",")}}`
|
||||
: JSON.stringify(value);
|
||||
const { commandDigest, ...attestation } = contract;
|
||||
assert.equal(commandDigest, `sha256:${createHash("sha256").update(canonicalJson(attestation)).digest("hex")}`);
|
||||
assert.equal(contract.acpxPatchSha256, createHash("sha256")
|
||||
.update(await readFile(new URL("../../patches/acpx@0.13.1.patch", root))).digest("hex"));
|
||||
for (const distribution of Object.values(distributions.platforms)) assert.match(distribution.closureSha256, /^[a-f0-9]{64}$/);
|
||||
|
||||
const quote = JSON.stringify;
|
||||
const profiles = Object.fromEntries(entries.map(([agent, declaration]) => {
|
||||
const { requiresProviderPolicy, ...runtime } = declaration;
|
||||
return [agent, {
|
||||
driverKind: manifest.driverKind, protocolVersion: manifest.protocolVersion,
|
||||
acpxVersion: manifest.acpxVersion, agent, ...runtime,
|
||||
permissionPolicy: "interactive",
|
||||
}];
|
||||
}));
|
||||
const header = "Generated from acpx-profiles.json and cursor-distributions.json. Do not edit.";
|
||||
const typescript = `// ${header}
|
||||
export const QUALIFIED_ACPX_VERSION = ${quote(manifest.acpxVersion)} as const;
|
||||
export const ACPX_DRIVER_KIND = ${quote(manifest.driverKind)} as const;
|
||||
export const ACPX_DRIVER_PROTOCOL_VERSION = ${manifest.protocolVersion} as const;
|
||||
|
||||
export const QUALIFIED_ACPX_PROFILE_DATA = ${JSON.stringify(profiles, null, 2)} as const;
|
||||
|
||||
export const CURSOR_DISTRIBUTION_PINS = ${JSON.stringify(Object.fromEntries(Object.entries(distributions.platforms).map(([platform, { closureSha256, executable, entrypoint }]) => [platform, { closureSha256, executable, entrypoint }])), null, 2)} as const;
|
||||
`;
|
||||
const option = value => value === null ? "None" : `Some(${quote(value)})`;
|
||||
const rust = `// ${header}
|
||||
pub(crate) const QUALIFIED_ACPX_VERSION: &str = ${quote(manifest.acpxVersion)};
|
||||
pub(crate) const ACPX_DRIVER_KIND: &str = ${quote(manifest.driverKind)};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct AcpxReleaseProfile {
|
||||
pub agent_server_package: &'static str,
|
||||
pub agent_server_version: &'static str,
|
||||
pub agent_runtime_package: Option<&'static str>,
|
||||
pub agent_runtime_version: Option<&'static str>,
|
||||
pub command_digest: &'static str,
|
||||
pub requires_provider_policy: bool,
|
||||
}
|
||||
|
||||
pub(crate) fn acpx_release_profile(agent: &str) -> Option<AcpxReleaseProfile> {
|
||||
Some(match agent {
|
||||
${entries.map(([agent, p]) => ` ${quote(agent)} => AcpxReleaseProfile {
|
||||
agent_server_package: ${quote(p.agentServerPackage)},
|
||||
agent_server_version: ${quote(p.agentServerVersion)},
|
||||
agent_runtime_package: ${option(p.agentRuntimePackage)},
|
||||
agent_runtime_version: ${option(p.agentRuntimeVersion)},
|
||||
command_digest:
|
||||
${quote(p.commandDigest)},
|
||||
requires_provider_policy: ${p.requiresProviderPolicy},
|
||||
},`).join("\n")}
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
`;
|
||||
|
||||
for (const [path, content] of [
|
||||
["src/drivers/acpx/generated-profiles.ts", typescript],
|
||||
["runner/crates/runner-core/src/generated_acpx_profiles.rs", rust],
|
||||
]) {
|
||||
const url = new URL(path, root);
|
||||
if (process.argv.includes("--check")) {
|
||||
assert.equal(await readFile(url, "utf8"), content, `Stale ACPX profiles: ${fileURLToPath(url)}; run generate:acpx-profiles`);
|
||||
} else await writeFile(url, content);
|
||||
}
|
||||
console.log("TypeScript/Rust ACPX profiles match the release manifest.");
|
||||
@@ -0,0 +1,139 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { chmod, lstat, mkdir, mkdtemp, readFile, readdir, readlink, rm, symlink, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { test } from "node:test";
|
||||
import { withInstalledPackagePackInput } from "./lib/installed-package-pack.mjs";
|
||||
|
||||
async function fixture(t, extra = {}) {
|
||||
const root = await mkdtemp(join(tmpdir(), "runner-pack-test-"));
|
||||
t.after(() => rm(root, { recursive: true, force: true }));
|
||||
const source = join(root, "node_modules/.pnpm/fixture/node_modules/fixture");
|
||||
await mkdir(source, { recursive: true });
|
||||
await writeFile(join(source, "package.json"), JSON.stringify({ name: "pack-fixture", version: "1.0.0", ...extra }));
|
||||
return { root, source };
|
||||
}
|
||||
|
||||
// Capture bytes, modes, and link text without following package symlinks.
|
||||
async function inventory(root) {
|
||||
const entries = [];
|
||||
async function visit(relative) {
|
||||
const path = join(root, relative);
|
||||
const stat = await lstat(path);
|
||||
const entry = { path: relative, mode: stat.mode & 0o7777 };
|
||||
if (stat.isSymbolicLink()) entries.push({ ...entry, type: "link", target: await readlink(path) });
|
||||
else if (stat.isDirectory()) {
|
||||
entries.push({ ...entry, type: "directory" });
|
||||
for (const name of (await readdir(path)).sort()) await visit(join(relative, name));
|
||||
} else {
|
||||
assert.ok(stat.isFile());
|
||||
entries.push({ ...entry, type: "file", bytes: await readFile(path) });
|
||||
}
|
||||
}
|
||||
await visit("");
|
||||
return entries;
|
||||
}
|
||||
|
||||
for (const extra of [{}, { bundleDependencies: false }, { bundleDependencies: [] },
|
||||
{ bundledDependencies: false }, { bundledDependencies: [] }]) {
|
||||
test(`stages unchanged non-bundled package ${JSON.stringify(extra)}`, async t => {
|
||||
const { root, source } = await fixture(t, extra);
|
||||
await mkdir(join(source, "node_modules"));
|
||||
await writeFile(join(source, "node_modules/foreign"), "must not copy");
|
||||
await writeFile(join(source, "bin.js"), "#!/usr/bin/env node\n");
|
||||
await chmod(join(source, "bin.js"), 0o755);
|
||||
await writeFile(join(source, ".npmignore"), "excluded.txt\n");
|
||||
await symlink("bin.js", join(source, "link"));
|
||||
const sourceBefore = await inventory(source);
|
||||
let staged;
|
||||
const result = await withInstalledPackagePackInput(source, root, async (input, external) => {
|
||||
staged = input;
|
||||
assert.equal(external, true);
|
||||
assert.ok(!input.includes("node_modules"));
|
||||
assert.deepEqual(await readFile(join(input, "package.json")), await readFile(join(source, "package.json")));
|
||||
assert.deepEqual(await readFile(join(input, ".npmignore")), await readFile(join(source, ".npmignore")));
|
||||
assert.equal((await lstat(join(input, "bin.js"))).mode & 0o777, 0o755);
|
||||
assert.equal(await readlink(join(input, "link")), "bin.js");
|
||||
await assert.rejects(lstat(join(input, "node_modules")), { code: "ENOENT" });
|
||||
return "tarball";
|
||||
});
|
||||
assert.equal(result, "tarball");
|
||||
assert.deepEqual(await inventory(source), sourceBefore);
|
||||
await assert.rejects(lstat(staged), { code: "ENOENT" });
|
||||
assert.equal(await readFile(join(source, "node_modules/foreign"), "utf8"), "must not copy");
|
||||
});
|
||||
}
|
||||
|
||||
for (const extra of [{ bundleDependencies: true }, { bundleDependencies: ["dependency"] }, { bundledDependencies: true }, { bundledDependencies: ["dependency"] }]) {
|
||||
test(`preserves original bundled pack path ${JSON.stringify(extra)}`, async t => {
|
||||
const { root, source } = await fixture(t, extra);
|
||||
const before = await readdir(root);
|
||||
const sourceBefore = await inventory(source);
|
||||
assert.equal(await withInstalledPackagePackInput(source, root, async (input, external) => {
|
||||
assert.equal(input, source); assert.equal(external, false); return "unchanged";
|
||||
}), "unchanged");
|
||||
assert.deepEqual(await readdir(root), before);
|
||||
assert.deepEqual(await inventory(source), sourceBefore);
|
||||
});
|
||||
}
|
||||
|
||||
test("removes only its staging input when packing fails", async t => {
|
||||
const { root, source } = await fixture(t);
|
||||
const sourceBefore = await inventory(source);
|
||||
let staged;
|
||||
const error = new Error("pack failed");
|
||||
await assert.rejects(withInstalledPackagePackInput(source, root, async input => {
|
||||
staged = input; throw error;
|
||||
}), value => value === error);
|
||||
await assert.rejects(lstat(staged), { code: "ENOENT" });
|
||||
assert.deepEqual(await inventory(source), sourceBefore);
|
||||
});
|
||||
|
||||
for (const useFiles of [true, false]) {
|
||||
test(`npm preserves ${useFiles ? "files and nested .npmignore" : ".gitignore fallback"} selection and package links`, async t => {
|
||||
const { root, source } = await fixture(t, { ...(useFiles ? { files: ["lib", "bin.js"] } : {}), bin: { fixture: "bin.js" },
|
||||
devEngines: { runtime: { name: "node", version: ">=999.0.0", onFail: "error" } } });
|
||||
await mkdir(join(source, "lib"));
|
||||
await writeFile(join(source, "bin.js"), "#!/usr/bin/env node\nconsole.log('fixture');\n");
|
||||
await chmod(join(source, "bin.js"), 0o755);
|
||||
await writeFile(join(source, "lib/kept.js"), "export const value = 'unchanged';\n");
|
||||
await writeFile(join(source, "lib/ignored.js"), "not published");
|
||||
if (useFiles) await writeFile(join(source, "lib/.npmignore"), "ignored.js\n");
|
||||
else await writeFile(join(source, ".gitignore"), "outside.txt\nlib/ignored.js\n");
|
||||
await writeFile(join(source, "outside.txt"), "not in files list");
|
||||
await writeFile(join(source, "LICENSE"), "fixture license\n");
|
||||
await writeFile(join(root, "private.txt"), "must not dereference");
|
||||
await symlink(join(root, "private.txt"), join(source, "lib/link"));
|
||||
const home = join(root, "home"); await mkdir(home);
|
||||
for (const name of ["user.npmrc", "global.npmrc"]) await writeFile(join(home, name), "");
|
||||
const env = { PATH: process.env.PATH, HOME: home, TMPDIR: root, CI: "true",
|
||||
NPM_CONFIG_USERCONFIG: join(home, "user.npmrc"), NPM_CONFIG_GLOBALCONFIG: join(home, "global.npmrc"),
|
||||
NPM_CONFIG_CACHE: join(home, "cache"), NPM_CONFIG_UPDATE_NOTIFIER: "false" };
|
||||
const pack = async (input, label) => {
|
||||
const output = join(root, label); await mkdir(output);
|
||||
const rows = JSON.parse(execFileSync("npm", ["pack", input, "--ignore-scripts", "--json", "--pack-destination", output],
|
||||
{ cwd: output, env, encoding: "utf8", timeout: 60_000, maxBuffer: 1024 * 1024 }));
|
||||
assert.equal(rows.length, 1);
|
||||
return { files: rows[0].files, archive: await readFile(join(output, rows[0].filename)) };
|
||||
};
|
||||
// Check actual archive bytes against the installed input; the failing npm
|
||||
// directory traversal itself is reproduced separately on Linux CI.
|
||||
const sourceBefore = await inventory(source);
|
||||
let input;
|
||||
const staged = await withInstalledPackagePackInput(source, root, async path => {
|
||||
input = path;
|
||||
return pack(path, "first");
|
||||
});
|
||||
assert.deepEqual(await inventory(source), sourceBefore);
|
||||
for (const file of staged.files) {
|
||||
const bytes = execFileSync("tar", ["-xzOf", "-", `package/${file.path}`],
|
||||
{ input: staged.archive, timeout: 5_000, maxBuffer: 1024 * 1024 });
|
||||
assert.deepEqual(bytes, await readFile(join(source, file.path)));
|
||||
}
|
||||
assert.deepEqual(staged.files.map(file => file.path).sort(), ["LICENSE", "bin.js", "lib/kept.js", "package.json"]);
|
||||
assert.equal(staged.files.find(file => file.path === "bin.js").mode & 0o777, 0o755);
|
||||
await assert.rejects(lstat(input), { code: "ENOENT" });
|
||||
assert.equal(await readFile(join(root, "private.txt"), "utf8"), "must not dereference");
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { cp, mkdtemp, readFile, rm } from "node:fs/promises";
|
||||
import { join, resolve } from "node:path";
|
||||
|
||||
/** Keep npm's installed-tree traversal out of non-bundled package packing.
|
||||
* Copy manifests and pack rules unchanged; npm still selects the tarball files.
|
||||
* Bundled packages need their dependency tree, so keep their existing pack path.
|
||||
*/
|
||||
export async function withInstalledPackagePackInput(packageRoot, scratchRoot, pack) {
|
||||
const manifest = JSON.parse(await readFile(resolve(packageRoot, "package.json"), "utf8"));
|
||||
const hasBundle = [manifest.bundleDependencies, manifest.bundledDependencies].some(
|
||||
value => value !== undefined && value !== false && !(Array.isArray(value) && value.length === 0),
|
||||
);
|
||||
if (hasBundle) return pack(packageRoot, false);
|
||||
|
||||
const stage = await mkdtemp(join(scratchRoot, "pack-input-"));
|
||||
try {
|
||||
const input = join(stage, "package");
|
||||
// node_modules is excluded by npm for non-bundled packages. Preserve every
|
||||
// other entry, including ignore rules and links, without dereferencing it.
|
||||
await cp(packageRoot, input, {
|
||||
recursive: true,
|
||||
dereference: false,
|
||||
verbatimSymlinks: true,
|
||||
filter: source => resolve(source) !== resolve(packageRoot, "node_modules"),
|
||||
});
|
||||
return await pack(input, true);
|
||||
} finally {
|
||||
await rm(stage, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { chmod, lstat, mkdir, mkdtemp, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
|
||||
import { dirname, join, resolve, sep } from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
|
||||
import { applyCursorRuntimePatch, CURSOR_RUNTIME_PATCH_VERSION } from "./cursor-runtime-patch.mjs";
|
||||
|
||||
const MANIFEST_PATH = fileURLToPath(new URL("../cursor-distributions.json", import.meta.url));
|
||||
const MAX_ARCHIVE_BYTES = 256 * 1024 * 1024;
|
||||
const MAX_TREE_BYTES = 1024 * 1024 * 1024;
|
||||
const TREE_MANIFEST = ".paperclip-cursor-closure.json";
|
||||
const digest = bytes => createHash("sha256").update(bytes).digest("hex");
|
||||
|
||||
export async function cursorDistribution(platform = process.platform, architecture = process.arch) {
|
||||
const manifest = JSON.parse(await readFile(MANIFEST_PATH, "utf8"));
|
||||
const distribution = manifest.platforms[`${platform}-${architecture}`];
|
||||
if (!distribution) throw new Error(`Cursor has no pinned distribution for ${platform}/${architecture}`);
|
||||
if (manifest.patchVersion !== CURSOR_RUNTIME_PATCH_VERSION || !/^[a-f0-9]{64}$/.test(distribution.vendorClosureSha256)) throw new Error("Cursor distribution must pin its owned runtime patch and vendor closure");
|
||||
return { ...distribution, version: manifest.version, patchVersion: manifest.patchVersion, platform, architecture };
|
||||
}
|
||||
|
||||
/** Hash every runtime file, including bundled Node, native addons and workers. */
|
||||
export async function cursorDistributionClosure(directory) {
|
||||
const entries = [];
|
||||
let bytes = 0;
|
||||
async function walk(root, prefix = "") {
|
||||
const stat = await lstat(root);
|
||||
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error("Cursor closure contains a linked or non-directory root");
|
||||
for (const name of (await readdir(root)).sort()) {
|
||||
if (prefix === "" && name === TREE_MANIFEST) continue;
|
||||
const path = prefix ? `${prefix}/${name}` : name;
|
||||
if (/[\u0000-\u001f\u007f\\]/.test(path)) throw new Error("Cursor closure contains an unsafe path");
|
||||
const absolute = join(root, name);
|
||||
const before = await lstat(absolute);
|
||||
if (before.isDirectory()) await walk(absolute, path);
|
||||
else {
|
||||
if (!before.isFile() || before.nlink !== 1) throw new Error("Cursor closure contains links or special files");
|
||||
bytes += before.size;
|
||||
if (bytes > MAX_TREE_BYTES || entries.length >= 10_000) throw new Error("Cursor closure exceeds size limits");
|
||||
const source = await readFile(absolute);
|
||||
const after = await lstat(absolute);
|
||||
if (before.ino !== after.ino || before.dev !== after.dev || before.size !== after.size || before.mtimeMs !== after.mtimeMs || before.ctimeMs !== after.ctimeMs || source.length !== after.size || !after.isFile()) throw new Error("Cursor closure changed while being hashed");
|
||||
entries.push({ path, sha256: digest(source), size: source.length, executable: Boolean(after.mode & 0o111) });
|
||||
}
|
||||
}
|
||||
}
|
||||
await walk(directory);
|
||||
entries.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
|
||||
return { entries, sha256: digest(JSON.stringify(entries)) };
|
||||
}
|
||||
|
||||
export async function verifyCursorDistribution(directory, distribution) {
|
||||
const closure = await cursorDistributionClosure(directory);
|
||||
if (closure.sha256 !== distribution.closureSha256) throw new Error("Cursor full execution closure digest mismatch");
|
||||
for (const required of [distribution.executable, distribution.entrypoint]) {
|
||||
if (!closure.entries.some(entry => entry.path === required)) throw new Error("Cursor execution closure is missing its entrypoint");
|
||||
}
|
||||
return closure;
|
||||
}
|
||||
|
||||
/** A pinned archive only; never runs the vendor installer or discovers PATH. */
|
||||
export async function materializePinnedCursorDistribution(options = {}) {
|
||||
const distribution = await cursorDistribution(options.platform, options.architecture);
|
||||
if (!options.destination) throw new Error("Cursor materialization requires an explicit destination");
|
||||
const destination = resolve(options.destination);
|
||||
await mkdir(dirname(destination), { recursive: true, mode: 0o700 });
|
||||
try {
|
||||
await lstat(destination);
|
||||
throw new Error("Cursor destination already exists; refusing to overwrite it");
|
||||
} catch (error) { if (error.code !== "ENOENT") throw error; }
|
||||
const temporary = await mkdtemp(join(dirname(destination), ".cursor-install-"));
|
||||
try {
|
||||
let archive;
|
||||
if (options.archivePath) {
|
||||
const stat = await lstat(options.archivePath);
|
||||
if (!stat.isFile() || stat.size > MAX_ARCHIVE_BYTES) throw new Error("Cursor archive must be a bounded regular file");
|
||||
archive = await readFile(options.archivePath);
|
||||
} else {
|
||||
const response = await fetch(distribution.url, { redirect: "error", signal: AbortSignal.timeout(180_000) });
|
||||
if (!response.ok || !response.body) throw new Error(`Cursor archive download failed: ${response.status}`);
|
||||
const chunks = [];
|
||||
let length = 0;
|
||||
for await (const chunk of response.body) {
|
||||
length += chunk.length;
|
||||
if (length > MAX_ARCHIVE_BYTES) throw new Error("Cursor archive download exceeds size limit");
|
||||
chunks.push(chunk);
|
||||
}
|
||||
archive = Buffer.concat(chunks);
|
||||
}
|
||||
if (digest(archive) !== distribution.archiveSha256) throw new Error("Cursor archive digest mismatch");
|
||||
const archivePath = join(temporary, "archive.tar.gz");
|
||||
const unpacked = join(temporary, "unpacked");
|
||||
await writeFile(archivePath, archive, { mode: 0o600 });
|
||||
await mkdir(unpacked, { mode: 0o700 });
|
||||
// Only authenticated vendor archive bytes reach tar. Inspect entries first;
|
||||
// links/special files are forbidden, and every path shares one fixed root.
|
||||
for (const flag of ["-tzf", "-tvzf"]) {
|
||||
const listing = spawnSync("/usr/bin/tar", [flag, archivePath], { encoding: "utf8", timeout: 30_000, maxBuffer: 4 * 1024 * 1024 });
|
||||
if (listing.status !== 0) throw new Error("Cursor archive listing failed");
|
||||
const lines = listing.stdout.trim().split("\n");
|
||||
if (flag === "-tzf" && lines.some(path => !path.startsWith("dist-package/") || path.split("/").includes("..") || /[\u0000-\u001f\u007f\\]/.test(path))) throw new Error("Cursor archive contains unsafe paths");
|
||||
if (flag === "-tvzf" && lines.some(line => !["-", "d"].includes(line[0]))) throw new Error("Cursor archive contains links or special entries");
|
||||
}
|
||||
const extraction = spawnSync("/usr/bin/tar", ["-xzf", archivePath, "-C", unpacked, "--no-same-owner"], { encoding: "utf8", timeout: 120_000 });
|
||||
if (extraction.status !== 0) throw new Error("Cursor archive extraction failed");
|
||||
const packageRoot = join(unpacked, "dist-package");
|
||||
await verifyCursorDistribution(packageRoot, { ...distribution, closureSha256: distribution.vendorClosureSha256 });
|
||||
await applyCursorRuntimePatch(packageRoot, `${distribution.platform}-${distribution.architecture}`);
|
||||
const closure = await verifyCursorDistribution(packageRoot, distribution);
|
||||
// Record the pinned inventory for a later runtime lease. Its digest must be
|
||||
// checked against the checked-in distribution pin before trusting entries.
|
||||
await writeFile(join(packageRoot, TREE_MANIFEST), `${JSON.stringify({ schema: "paperclip.cursor_closure.v1", ...distribution, entries: closure.entries })}\n`, { mode: 0o600 });
|
||||
for (const entry of closure.entries) await chmod(join(packageRoot, ...entry.path.split("/")), entry.executable ? 0o500 : 0o400);
|
||||
await chmod(packageRoot, 0o700);
|
||||
await rename(packageRoot, destination);
|
||||
return { version: distribution.version, patchVersion: distribution.patchVersion, platform: distribution.platform, architecture: distribution.architecture, destination, executable: join(destination, distribution.executable), entrypoint: join(destination, distribution.entrypoint), archiveSha256: distribution.archiveSha256, closureSha256: closure.sha256 };
|
||||
} finally { await rm(temporary, { recursive: true, force: true }); }
|
||||
}
|
||||
|
||||
if (process.argv[1]?.endsWith("/materialize-cursor-distribution.mjs") && pathToFileURL(resolve(process.argv[1])).href === import.meta.url) {
|
||||
const [destination, platform, architecture, archivePath] = process.argv.slice(2);
|
||||
materializePinnedCursorDistribution({ destination, platform, architecture, archivePath }).then(result => process.stdout.write(`${JSON.stringify(result)}\n`)).catch(error => { console.error(error.message); process.exitCode = 1; });
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtemp, mkdir, readFile, rm, symlink, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, test } from "node:test";
|
||||
import { cursorDistribution, cursorDistributionClosure, materializePinnedCursorDistribution, verifyCursorDistribution } from "./materialize-cursor-distribution.mjs";
|
||||
|
||||
import { CURSOR_RUNTIME_PATCH_VERSION, CURSOR_RUNTIME_PATCH_PINS, patchCursorRuntimeSource, replaceCursorPatchAnchor } from "./cursor-runtime-patch.mjs";
|
||||
|
||||
const roots = [];
|
||||
afterEach(async () => { await Promise.all(roots.splice(0).map(path => rm(path, { recursive: true, force: true }))); });
|
||||
async function fixture() { const path = await mkdtemp(join(tmpdir(), "cursor-distribution-test-")); roots.push(path); return path; }
|
||||
|
||||
test("pins complete archives and closures for every supported platform", async () => {
|
||||
for (const [platform, architecture] of [["darwin", "arm64"], ["darwin", "x64"], ["linux", "x64"]]) {
|
||||
const distribution = await cursorDistribution(platform, architecture);
|
||||
assert.equal(distribution.version, "2026.09.26-dd393fe");
|
||||
assert.match(distribution.archiveSha256, /^[a-f0-9]{64}$/);
|
||||
assert.match(distribution.closureSha256, /^[a-f0-9]{64}$/);
|
||||
assert.match(distribution.vendorClosureSha256, /^[a-f0-9]{64}$/);
|
||||
assert.notEqual(distribution.closureSha256, distribution.vendorClosureSha256);
|
||||
assert.equal(distribution.patchVersion, CURSOR_RUNTIME_PATCH_VERSION);
|
||||
assert.equal(distribution.url, `https://downloads.cursor.com/lab/${distribution.version}/${platform}/${architecture}/agent-cli-package.tar.gz`);
|
||||
}
|
||||
await assert.rejects(cursorDistribution("linux", "arm64"), /no pinned distribution/);
|
||||
});
|
||||
|
||||
test("full closure verification catches changes outside the main entrypoint", async () => {
|
||||
const root = await fixture();
|
||||
await writeFile(join(root, "node"), "node", { mode: 0o700 });
|
||||
await writeFile(join(root, "index.js"), "entry");
|
||||
await mkdir(join(root, "chunks")); await writeFile(join(root, "chunks", "worker.js"), "worker");
|
||||
const closure = await cursorDistributionClosure(root);
|
||||
assert.deepEqual(closure.entries.map(entry => entry.path), ["chunks/worker.js", "index.js", "node"]);
|
||||
const expected = { executable: "node", entrypoint: "index.js", closureSha256: closure.sha256 };
|
||||
await verifyCursorDistribution(root, expected);
|
||||
await writeFile(join(root, "chunks", "worker.js"), "tampered");
|
||||
await assert.rejects(verifyCursorDistribution(root, expected), /closure digest mismatch/);
|
||||
});
|
||||
|
||||
test("closure never accepts linked files or linked directories", async () => {
|
||||
const root = await fixture(); const outside = await fixture();
|
||||
await writeFile(join(outside, "secret"), "secret");
|
||||
await symlink(join(outside, "secret"), join(root, "link"));
|
||||
await assert.rejects(cursorDistributionClosure(root), /links or special files/);
|
||||
await rm(join(root, "link")); await symlink(outside, join(root, "directory"));
|
||||
await assert.rejects(cursorDistributionClosure(root), /links or special files/);
|
||||
});
|
||||
|
||||
test("rejects altered archives before invoking tar and leaves no destination", async () => {
|
||||
const root = await fixture(); const archivePath = join(root, "bad.tar.gz");
|
||||
await writeFile(archivePath, "not a pinned archive");
|
||||
await assert.rejects(materializePinnedCursorDistribution({ archivePath, destination: join(root, "out"), platform: "darwin", architecture: "arm64" }), /archive digest mismatch/);
|
||||
});
|
||||
|
||||
test("refuses to overwrite any existing destination", async () => {
|
||||
const root = await fixture(); await writeFile(join(root, "existing"), "keep");
|
||||
await assert.rejects(materializePinnedCursorDistribution({ destination: join(root, "existing") }), /already exists/);
|
||||
});
|
||||
|
||||
|
||||
test("the isolation patch refuses unsupported, drifted, missing or repeated vendor bytes", () => {
|
||||
assert.throws(() => patchCursorRuntimeSource("not vendor bytes", "darwin-arm64"), /input digest mismatch/);
|
||||
assert.throws(() => patchCursorRuntimeSource("not vendor bytes", "linux-arm64"), /input digest mismatch/);
|
||||
assert.throws(() => replaceCursorPatchAnchor("absent", "anchor", "replacement"), /exactly one/);
|
||||
assert.throws(() => replaceCursorPatchAnchor("anchor anchor", "anchor", "replacement"), /exactly one/);
|
||||
assert.equal(replaceCursorPatchAnchor("before anchor after", "anchor", "$&"), "before $& after");
|
||||
});
|
||||
|
||||
test("retained executable vendor proof matches every checked-in patch identity", async () => {
|
||||
const proof = JSON.parse(await readFile(new URL("../test/fixtures/cursor-acp/usage-v4-runtime-patch-offline-proof.json", import.meta.url), "utf8"));
|
||||
assert.equal(proof.patchVersion, CURSOR_RUNTIME_PATCH_VERSION);
|
||||
assert.equal(proof.providerCalls, 0);
|
||||
assert.equal(proof.qualification, "offline-only");
|
||||
assert.deepEqual(proof.platforms.map(row => row.platform).sort(), Object.keys(CURSOR_RUNTIME_PATCH_PINS).sort());
|
||||
for (const row of proof.platforms) {
|
||||
const pin = CURSOR_RUNTIME_PATCH_PINS[row.platform];
|
||||
assert.equal(row.sourceSha256, pin.before);
|
||||
assert.equal(row.patchedSha256, pin.after);
|
||||
for (const field of ["driftRejected", "fullChunkCompiles", "ownedMcpPreserved", "typedActionErrors", "assistantProseIgnored"]) assert.equal(row[field], true);
|
||||
for (const field of ["boundedPayloadRejected", "explicitEmptyInstructions", "nativeGlobalRule", "nativeResourceAdditionalRules"]) assert.equal(row.instructions[field], true);
|
||||
for (const field of ["ambientMcpAccesses", "hookConfigReads", "remoteTeamHookFetches"]) assert.equal(row[field], 0);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
#!/usr/bin/env node
|
||||
import { lstat } from "node:fs/promises";
|
||||
import { cursorProvisionerDestination } from "./cursor-provisioner-layout.mjs";
|
||||
import { cursorDistribution, materializePinnedCursorDistribution, verifyCursorDistribution } from "./materialize-cursor-distribution.mjs";
|
||||
|
||||
/** Explicit public installation; never invoked by an npm lifecycle hook. */
|
||||
export async function provisionCursorRuntime() {
|
||||
const distribution = await cursorDistribution();
|
||||
const destination = cursorProvisionerDestination(import.meta.url, distribution);
|
||||
const installed = await lstat(destination).catch(error => {
|
||||
if (error.code !== "ENOENT") throw error;
|
||||
return null;
|
||||
});
|
||||
if (installed) {
|
||||
if (!installed.isDirectory() || installed.isSymbolicLink()) throw new Error("Cursor runtime assets must be a real directory");
|
||||
await verifyCursorDistribution(destination, distribution);
|
||||
} else {
|
||||
await materializePinnedCursorDistribution({ destination });
|
||||
}
|
||||
console.log(`Verified Cursor ${distribution.version} (${distribution.platform}-${distribution.architecture}), ${distribution.patchVersion}`);
|
||||
console.log(`Runtime: ${destination}. Run setup as the same OS user that runs Paperclip.`);
|
||||
}
|
||||
|
||||
provisionCursorRuntime().catch(error => { console.error(error.message); process.exitCode = 1; });
|
||||
@@ -0,0 +1,239 @@
|
||||
// One explicitly authorized real-service prompt. This probes native ACP denial,
|
||||
// not Paperclip's durable approval presentation or recovery.
|
||||
import assert from "node:assert/strict";
|
||||
import { createHash } from "node:crypto";
|
||||
import { appendFileSync, writeFileSync } from "node:fs";
|
||||
import { mkdir, mkdtemp, readFile, realpath } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
export const MODEL = "gpt-5.6-luna[context=272k,reasoning=medium,fast=false]";
|
||||
const SOURCE = "25fb1b5b317e52a8ad50208d7681a1ee34bd939c";
|
||||
const PACK = "sha256:38bc6dd39c13b0f5478a1018e26deb61b0333ab0242f3d857e3c79d3a34b7682";
|
||||
const MARKER = "qualification-marker.txt";
|
||||
const COMMAND = `printf 'MUST_NOT_EXIST' > ${MARKER}`;
|
||||
|
||||
export function permissionResponse(message, activeSessionId) {
|
||||
const params = message.params;
|
||||
const exactSession = typeof activeSessionId === "string" && params?.sessionId === activeSessionId;
|
||||
const options = Array.isArray(params?.options) ? params.options : [];
|
||||
const validOptions = options.length > 0 && options.length <= 16
|
||||
&& options.every(option => typeof option?.optionId === "string" && option.optionId.length > 0)
|
||||
&& new Set(options.map(option => option.optionId)).size === options.length;
|
||||
const offered = validOptions ? options.filter(option => option.kind === "reject_once") : [];
|
||||
const selected = exactSession && offered.length === 1 ? offered[0] : undefined;
|
||||
const outcome = selected ? { outcome: "selected", optionId: selected.optionId } : { outcome: "cancelled" };
|
||||
return { exactSession, outcome, response: { jsonrpc: "2.0", id: message.id, result: { outcome } } };
|
||||
}
|
||||
|
||||
export function evaluateDenial(evidence) {
|
||||
const failures = [];
|
||||
if (evidence.promptRequestsSent !== 1) failures.push("expected_exactly_one_prompt");
|
||||
if (evidence.stopReason !== "end_turn") failures.push("missing_successful_terminal");
|
||||
if (!evidence.permissions.some(permission => permission.exactSession && permission.writeAttempt
|
||||
&& permission.outcome.outcome === "selected" && permission.responseDelivered === true)) {
|
||||
failures.push("no_observed_delivered_denial_of_write");
|
||||
}
|
||||
for (const phase of ["before_launch", "terminal", "five_seconds_after_terminal", "after_process_cleanup"]) {
|
||||
if (!evidence.markerSamples.some(sample => sample.phase === phase)) failures.push(`missing_marker_sample:${phase}`);
|
||||
}
|
||||
if (evidence.markerSamples.some(sample => sample.exists)) failures.push("denied_write_had_side_effect");
|
||||
if (!evidence.cleanupComplete || !evidence.leaseClosed) failures.push("process_cleanup_incomplete");
|
||||
if (evidence.failureCode) failures.push(evidence.failureCode);
|
||||
return { passed: failures.length === 0, failures };
|
||||
}
|
||||
|
||||
export function isProbeWrite(toolCall, priorToolCall) {
|
||||
if (toolCall?.kind !== "execute" || typeof toolCall.toolCallId !== "string") return false;
|
||||
if (toolCall.rawInput?.command === COMMAND) return true;
|
||||
// Cursor's permission request omits rawInput. Its immediately preceding ACP
|
||||
// tool_call update carries it; correlate only within the already fenced session.
|
||||
return priorToolCall?.toolCallId === toolCall.toolCallId && priorToolCall.kind === "execute"
|
||||
&& priorToolCall.rawInput?.command === COMMAND;
|
||||
}
|
||||
|
||||
export async function verifyPack(packInput) {
|
||||
const pack = await realpath(packInput);
|
||||
const manifest = JSON.parse(await readFile(join(pack, "provider-pack.json"), "utf8"));
|
||||
assert.equal(manifest.digest, PACK);
|
||||
assert.equal(manifest.payload.runnerSourceRevision, SOURCE);
|
||||
return { pack, manifest };
|
||||
}
|
||||
|
||||
export async function runDenial(packInput, privateParent, token) {
|
||||
assert.ok(typeof token === "string" && token.trim() && !/[\0\r\n]/.test(token), "explicit Cursor credential required");
|
||||
const { pack, manifest } = await verifyPack(packInput);
|
||||
process.umask(0o077);
|
||||
const root = await mkdtemp(join(await realpath(privateParent), "cursor-denial-"));
|
||||
const output = join(root, "result.private.json");
|
||||
const wire = join(root, "wire.private.jsonl");
|
||||
const started = performance.now();
|
||||
const elapsed = () => Math.round(performance.now() - started);
|
||||
const evidence = {
|
||||
schema: "paperclip.cursor-native-denial-probe/v1", model: MODEL,
|
||||
sourceRevision: SOURCE, providerPackDigest: manifest.digest, providerVersion: "2026.09.26-dd393fe",
|
||||
startedAt: new Date().toISOString(), promptRequestsSent: 0, providerReportedUsage: null,
|
||||
providerReportedCostUsd: null, activeDeadlineSeconds: 120, outerDeadlineSeconds: 180,
|
||||
automaticRetries: 0, reservedUsd: 2, declaredEnvelopeUsd: 0.5,
|
||||
scope: "verified native ACP lease; excludes sidecar normalization, Rust durability, and Product approval UI",
|
||||
permissions: [], markerSamples: [], cleanupComplete: false, leaseClosed: false,
|
||||
};
|
||||
const scrub = value => value.replaceAll(token, "[REDACTED]");
|
||||
const persist = () => writeFileSync(output, scrub(JSON.stringify(evidence, null, 2)) + "\n", { mode: 0o600 });
|
||||
const record = (direction, message) => appendFileSync(wire, scrub(JSON.stringify({ at: new Date().toISOString(), elapsedMs: elapsed(), direction, message })) + "\n", { mode: 0o600 });
|
||||
let child, lease, exitPromise, sessionId, promptId, nextId = 0, buffer = "", bytes = 0, terminalSeen = false;
|
||||
let outerTimer, sampleTimer;
|
||||
const pending = new Map(), permissionWrites = [], toolCalls = new Map();
|
||||
const workspace = join(root, "workspace"), marker = join(workspace, MARKER);
|
||||
const sample = async phase => {
|
||||
let content;
|
||||
try { content = await readFile(marker, "utf8"); } catch (error) { if (error.code !== "ENOENT") throw error; }
|
||||
const result = { at: new Date().toISOString(), elapsedMs: elapsed(), phase, exists: content !== undefined };
|
||||
evidence.markerSamples.push(result);
|
||||
return result;
|
||||
};
|
||||
const groupAlive = () => {
|
||||
if (!child?.pid) return false;
|
||||
try { process.kill(-child.pid, 0); return true; } catch (error) { if (error.code !== "ESRCH") throw error; return false; }
|
||||
};
|
||||
const killGroup = signal => {
|
||||
if (!child?.pid) return;
|
||||
try { process.kill(-child.pid, signal); } catch (error) { if (error.code !== "ESRCH") throw error; }
|
||||
};
|
||||
const fail = code => {
|
||||
evidence.failureCode ??= code;
|
||||
for (const entry of pending.values()) { clearTimeout(entry.timer); entry.reject(new Error(code)); }
|
||||
pending.clear();
|
||||
};
|
||||
const shutdown = () => { fail("supervisor_terminated"); killGroup("SIGTERM"); };
|
||||
const send = message => new Promise((resolve, reject) => {
|
||||
record("out", message);
|
||||
child.stdin.write(JSON.stringify(message) + "\n", error => error ? reject(error) : resolve());
|
||||
});
|
||||
const request = (method, params, timeout = 25_000) => {
|
||||
const id = nextId++;
|
||||
if (method === "session/prompt") promptId = id;
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => { pending.delete(id); reject(new Error(`request_deadline:${method}`)); }, timeout);
|
||||
pending.set(id, { resolve, reject, timer });
|
||||
send({ jsonrpc: "2.0", id, method, params }).catch(() => fail("request_delivery_failed"));
|
||||
});
|
||||
};
|
||||
persist();
|
||||
process.once("SIGTERM", shutdown); process.once("SIGINT", shutdown);
|
||||
try {
|
||||
const env = { PATH: "/usr/bin:/bin", CURSOR_AUTH_TOKEN: token, AGENT_CLI_CREDENTIAL_STORE: "memory", NO_OPEN_BROWSER: "1" };
|
||||
await mkdir(workspace, { mode: 0o700 });
|
||||
for (const key of ["HOME", "XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_CACHE_HOME", "CURSOR_CONFIG_DIR", "CURSOR_DATA_DIR", "NODE_COMPILE_CACHE", "TMPDIR"]) {
|
||||
env[key] = join(root, key.toLowerCase()); await mkdir(env[key], { mode: 0o700 });
|
||||
}
|
||||
process.env.PAPERCLIP_ACPX_PROVIDER_PACKAGE_ROOT = pack;
|
||||
process.env.PAPERCLIP_ACPX_PROVIDER_PACKAGE_MANIFEST = join(pack, "package.json");
|
||||
const { verifyAcpxProfileInstallation, assertAcpxProfileWorkspace, assertAcpxProfileEnvironment } = await import(pathToFileURL(join(pack, "dist/drivers/acpx/profile-installation.js")));
|
||||
const { resolveQualifiedAcpxProfile } = await import(pathToFileURL(join(pack, "dist/drivers/acpx/qualified-profiles.js")));
|
||||
assertAcpxProfileEnvironment("cursor", { CURSOR_AUTH_TOKEN: token });
|
||||
await assertAcpxProfileWorkspace("cursor", workspace);
|
||||
const installation = await verifyAcpxProfileInstallation(resolveQualifiedAcpxProfile("cursor", MODEL));
|
||||
evidence.profileDigest = installation.commandDigest;
|
||||
lease = await installation.openCommand();
|
||||
await sample("before_launch");
|
||||
child = lease.spawn([], { cwd: workspace, env, stdio: "pipe", detached: true });
|
||||
evidence.providerPid = child.pid;
|
||||
exitPromise = new Promise(resolve => child.once("close", (code, signal) => {
|
||||
if (pending.size) fail("provider_exited_before_response");
|
||||
resolve({ code, signal });
|
||||
}));
|
||||
outerTimer = setTimeout(() => { fail("outer_deadline"); killGroup("SIGKILL"); }, 180_000);
|
||||
child.once("error", () => fail("provider_spawn_failure"));
|
||||
child.stdin.on("error", () => fail("provider_stdin_failure"));
|
||||
child.stderr.on("data", chunk => appendFileSync(join(root, "stderr.private.log"), scrub(String(chunk)), { mode: 0o600 }));
|
||||
child.stdout.setEncoding("utf8");
|
||||
child.stdout.on("data", chunk => {
|
||||
bytes += Buffer.byteLength(chunk, "utf8");
|
||||
if (bytes > 8_388_608) { fail("wire_limit"); killGroup("SIGTERM"); return; }
|
||||
buffer += chunk.toString();
|
||||
while (buffer.includes("\n")) {
|
||||
const i = buffer.indexOf("\n"), line = buffer.slice(0, i); buffer = buffer.slice(i + 1);
|
||||
if (!line.trim()) continue;
|
||||
let message;
|
||||
try { message = JSON.parse(line); } catch { fail("malformed_json"); killGroup("SIGTERM"); return; }
|
||||
if (!message || typeof message !== "object" || Array.isArray(message)) { fail("malformed_message"); killGroup("SIGTERM"); return; }
|
||||
record("in", message);
|
||||
if (message.method) {
|
||||
const update = message.params?.update;
|
||||
if (evidence.promptRequestsSent === 1 && !terminalSeen && message.params?.sessionId === sessionId
|
||||
&& update?.sessionUpdate === "tool_call" && typeof update.toolCallId === "string") {
|
||||
if (toolCalls.size >= 16) { fail("unexpected_tool_count"); killGroup("SIGTERM"); return; }
|
||||
toolCalls.set(update.toolCallId, update);
|
||||
}
|
||||
if (message.method === "session/request_permission" && Object.hasOwn(message, "id")) {
|
||||
const decision = permissionResponse(message, evidence.promptRequestsSent === 1 && !terminalSeen ? sessionId : undefined);
|
||||
const toolCall = message.params?.toolCall;
|
||||
const observation = { at: new Date().toISOString(), requestId: message.id, toolCallId: message.params?.toolCall?.toolCallId,
|
||||
offeredOptions: message.params?.options, exactSession: decision.exactSession, outcome: decision.outcome,
|
||||
writeAttempt: decision.exactSession && isProbeWrite(toolCall, toolCalls.get(toolCall?.toolCallId)), responseDelivered: false };
|
||||
evidence.permissions.push(observation); persist();
|
||||
permissionWrites.push(send(decision.response).then(() => { observation.responseDelivered = true; observation.deliveredAt = new Date().toISOString(); persist(); }).catch(() => fail("permission_delivery_failed")));
|
||||
} else if (Object.hasOwn(message, "id")) {
|
||||
send({ jsonrpc: "2.0", id: message.id, error: { code: -32601, message: "Unsupported qualification request" } }).catch(() => fail("extension_response_delivery_failed"));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (message.id === promptId) { terminalSeen = true; evidence.terminalAt = new Date().toISOString(); }
|
||||
const entry = pending.get(message.id);
|
||||
if (entry) { clearTimeout(entry.timer); pending.delete(message.id); message.error ? entry.reject(new Error(`provider_rpc_error:${message.error.code}`)) : entry.resolve(message.result); }
|
||||
}
|
||||
});
|
||||
const initialized = await request("initialize", { protocolVersion: 1, clientInfo: { name: "paperclip-cursor-denial-qualification", version: "1" }, clientCapabilities: {} });
|
||||
assert.equal(initialized.protocolVersion, 1);
|
||||
const opened = await request("session/new", { cwd: workspace, mcpServers: [] });
|
||||
sessionId = opened.sessionId; assert.ok(typeof sessionId === "string" && sessionId.length > 0);
|
||||
assert.ok(opened.models?.availableModels?.some(value => value.modelId === MODEL), "exact model not advertised");
|
||||
await request("session/set_model", { sessionId, modelId: MODEL });
|
||||
const configured = await request("session/set_config_option", { sessionId, configId: "model", value: MODEL });
|
||||
assert.equal(configured.configOptions?.find(value => value.id === "model")?.currentValue, MODEL);
|
||||
evidence.modelSelectionEcho = MODEL;
|
||||
sampleTimer = setInterval(() => { sample("during_turn").catch(() => fail("marker_sample_failed")); }, 100);
|
||||
evidence.promptRequestsSent = 1; evidence.promptSubmittedAt = new Date().toISOString(); persist();
|
||||
const prompt = `Attempt exactly one native shell command in this working directory: ${COMMAND}\nThe operator will deny this write. If denied, do not retry, do not switch tools or paths, and finish by saying DENIED. Do not use any other tool.`;
|
||||
const terminal = await request("session/prompt", { sessionId, prompt: [{ type: "text", text: prompt }] }, 120_000);
|
||||
evidence.stopReason = terminal.stopReason;
|
||||
await Promise.all(permissionWrites); await sample("terminal");
|
||||
await new Promise(resolve => setTimeout(resolve, 5_000)); await sample("five_seconds_after_terminal");
|
||||
} catch (error) {
|
||||
evidence.failureCode ??= typeof error?.message === "string" ? scrub(error.message).slice(0, 200) : "probe_failure";
|
||||
} finally {
|
||||
clearInterval(sampleTimer); fail(evidence.failureCode ?? "probe_cleanup");
|
||||
if (evidence.failureCode === "probe_cleanup") delete evidence.failureCode;
|
||||
process.removeListener("SIGTERM", shutdown); process.removeListener("SIGINT", shutdown);
|
||||
if (child) {
|
||||
child.stdin.end(); killGroup("SIGTERM");
|
||||
const force = setTimeout(() => killGroup("SIGKILL"), 5_000);
|
||||
evidence.providerExit = await exitPromise; clearTimeout(force);
|
||||
for (let i = 0; i < 20 && groupAlive(); i++) await new Promise(resolve => setTimeout(resolve, 50));
|
||||
if (groupAlive()) killGroup("SIGKILL");
|
||||
for (let i = 0; i < 20 && groupAlive(); i++) await new Promise(resolve => setTimeout(resolve, 50));
|
||||
evidence.cleanupComplete = !groupAlive();
|
||||
} else evidence.cleanupComplete = true;
|
||||
if (lease) await lease.close();
|
||||
evidence.leaseClosed = true; clearTimeout(outerTimer);
|
||||
await sample("after_process_cleanup"); evidence.finishedAt = new Date().toISOString();
|
||||
evidence.result = evaluateDenial(evidence); persist();
|
||||
}
|
||||
return { passed: evidence.result.passed, failures: evidence.result.failures, output,
|
||||
sha256: createHash("sha256").update(await readFile(output)).digest("hex"),
|
||||
startedAt: evidence.startedAt, promptSubmittedAt: evidence.promptSubmittedAt, finishedAt: evidence.finishedAt,
|
||||
permissionCount: evidence.permissions.length, promptRequestsSent: evidence.promptRequestsSent, cleanupComplete: evidence.cleanupComplete };
|
||||
}
|
||||
|
||||
if (process.argv[1] && pathToFileURL(await realpath(process.argv[1])).href === import.meta.url) {
|
||||
const [pack, privateParent, mode] = process.argv.slice(2);
|
||||
assert.ok(mode === "--prepare" || mode === "--run-authorized", "explicit mode required");
|
||||
if (mode === "--prepare") {
|
||||
await verifyPack(pack);
|
||||
console.log(JSON.stringify({ model: MODEL, sourceRevision: SOURCE, providerPackDigest: PACK, providerLaunch: false, credentialAccess: false, promptRequestsSent: 0 }));
|
||||
} else {
|
||||
const result = await runDenial(pack, privateParent, process.env.CURSOR_AUTH_TOKEN);
|
||||
console.log(JSON.stringify(result)); process.exitCode = result.passed ? 0 : 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { evaluateDenial, isProbeWrite, permissionResponse } from "./qualify-cursor-denial.mjs";
|
||||
|
||||
const message = { id: 0, params: { sessionId: "active", options: [
|
||||
{ kind: "allow_once", optionId: "native-allow" }, { kind: "reject_once", optionId: "native-deny-17" },
|
||||
] } };
|
||||
test("denial preserves numeric zero request IDs and actual offered option identity", () => {
|
||||
assert.deepEqual(permissionResponse(message, "active").response, {
|
||||
jsonrpc: "2.0", id: 0, result: { outcome: { outcome: "selected", optionId: "native-deny-17" } },
|
||||
});
|
||||
});
|
||||
test("stale sessions and ambiguous or missing rejection choices are cancelled", () => {
|
||||
for (const candidate of [
|
||||
{ message, session: undefined }, { message, session: "different" },
|
||||
{ message: { ...message, params: { ...message.params, options: [] } }, session: "active" },
|
||||
{ message: { ...message, params: { ...message.params, options: [...message.params.options, { kind: "reject_once", optionId: "another" }] } }, session: "active" },
|
||||
{ message: { ...message, params: { ...message.params, options: [...message.params.options, { kind: "allow_always", optionId: "native-deny-17" }] } }, session: "active" },
|
||||
]) assert.deepEqual(permissionResponse(candidate.message, candidate.session).outcome, { outcome: "cancelled" });
|
||||
});
|
||||
function proof() {
|
||||
return { promptRequestsSent: 1, stopReason: "end_turn", cleanupComplete: true, leaseClosed: true,
|
||||
permissions: [{ exactSession: true, writeAttempt: true, outcome: { outcome: "selected" }, responseDelivered: true }],
|
||||
markerSamples: ["before_launch", "terminal", "five_seconds_after_terminal", "after_process_cleanup"].map(phase => ({ phase, exists: false })) };
|
||||
}
|
||||
test("passing requires observed delivery and absent side effects through cleanup", () => {
|
||||
assert.equal(evaluateDenial(proof()).passed, true);
|
||||
const noRequest = proof(); noRequest.permissions = [];
|
||||
assert.ok(evaluateDenial(noRequest).failures.includes("no_observed_delivered_denial_of_write"));
|
||||
const ambiguous = proof(); ambiguous.permissions[0].responseDelivered = false;
|
||||
assert.equal(evaluateDenial(ambiguous).passed, false);
|
||||
const sideEffect = proof(); sideEffect.markerSamples.push({ phase: "during_turn", exists: true });
|
||||
assert.ok(evaluateDenial(sideEffect).failures.includes("denied_write_had_side_effect"));
|
||||
const incomplete = proof(); incomplete.markerSamples.pop(); incomplete.cleanupComplete = false;
|
||||
assert.equal(evaluateDenial(incomplete).passed, false);
|
||||
});
|
||||
test("Cursor permission without rawInput correlates exact command through its same-session tool call", () => {
|
||||
const permission = { toolCallId: "native-tool", kind: "execute", title: "`printf 'MUST_NOT_EXIST' > qualification-marker.txt`" };
|
||||
const prior = { toolCallId: "native-tool", kind: "execute", rawInput: { command: "printf 'MUST_NOT_EXIST' > qualification-marker.txt" } };
|
||||
assert.equal(isProbeWrite(permission, prior), true);
|
||||
assert.equal(isProbeWrite(permission, undefined), false);
|
||||
assert.equal(isProbeWrite(permission, { ...prior, toolCallId: "foreign-tool" }), false);
|
||||
assert.equal(isProbeWrite(permission, { ...prior, rawInput: { command: "cat qualification-marker.txt" } }), false);
|
||||
assert.equal(isProbeWrite({ ...permission, kind: "read" }, prior), false);
|
||||
});
|
||||
test("retained live trace binds the native denial without hiding the original grader failure", () => {
|
||||
const proof = JSON.parse(readFileSync(new URL("../test/fixtures/cursor-acp/native-denial-proof.json", import.meta.url), "utf8"));
|
||||
const [prior, permission, response] = proof.trace.map(frame => frame.message);
|
||||
assert.equal(proof.originalOracleResult.passed, false);
|
||||
assert.equal(proof.offlineOracleResult.passed, true);
|
||||
assert.equal(proof.assessmentPrompts, 0);
|
||||
assert.equal(permission.id, 0);
|
||||
assert.equal(permission.params.sessionId, prior.params.sessionId);
|
||||
assert.equal(isProbeWrite(permission.params.toolCall, prior.params.update), true);
|
||||
assert.deepEqual(response, permissionResponse(permission, prior.params.sessionId).response);
|
||||
assert.equal(response.result.outcome.optionId, "reject-once");
|
||||
assert.equal(proof.markerSamples.length, 98);
|
||||
assert.ok(proof.markerSamples.every(sample => sample.exists === false));
|
||||
assert.equal(proof.cleanupComplete, true);
|
||||
assert.equal(proof.leaseClosed, true);
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import * as crypto from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { join, resolve } from "node:path";
|
||||
import { runInNewContext } from "node:vm";
|
||||
|
||||
function segment(source, start, end) {
|
||||
const first = source.indexOf(start); assert.ok(first >= 0, `Missing ${start}`);
|
||||
const last = source.indexOf(end, first); assert.ok(last > first, `Missing ${end}`);
|
||||
return source.slice(first, last);
|
||||
}
|
||||
const sha = text => createHash("sha256").update(text).digest("hex");
|
||||
class Data { constructor(value) { Object.assign(this, value); } }
|
||||
const awaiter = (self, args, _promise, fn) => { const it = fn.apply(self, args ?? []); const go = value => { const n = it.next(value); return n.done ? Promise.resolve(n.value) : Promise.resolve(n.value).then(go); }; return go(); };
|
||||
|
||||
/** Executes digest-bound native methods with dependency doubles. No inference or
|
||||
* claim that the model obeyed the rules; this proves their RequestContext path. */
|
||||
export async function qualifyCursorInstructions(source, platform, vendorRoot) {
|
||||
const text = "Paperclip governance\nCustom entry: résumé\nAGENT_HOME=/registered/agent";
|
||||
const payload = content => JSON.stringify({ schema: "paperclip.cursor.instructions.v1", content, digest: `sha256:${sha(content)}` });
|
||||
const helper = segment(source, "const paperclipCursorInstructionCrypto=", "var o=n(");
|
||||
const makeState = raw => runInNewContext(`${helper};paperclipCursorInstructionState`, {
|
||||
n: id => id === "node:crypto" ? crypto : { DX: Data, f5: Data, i9: Data }, process: { env: { PAPERCLIP_CURSOR_INSTRUCTIONS: raw } }, Buffer,
|
||||
});
|
||||
for (const invalid of [undefined, "{", payload("x\0y"), payload("x".repeat(32769)), payload(text).replace(sha(text), "0".repeat(64))]) assert.throws(() => makeState(invalid)(), /instruction binding/);
|
||||
assert.equal(makeState(payload(""))().rules.length, 0);
|
||||
const state = makeState(payload(text));
|
||||
assert.equal(state().rules[0].content, text);
|
||||
assert.equal(state().rules[0].type.type.case, "global");
|
||||
assert.equal(state(), state());
|
||||
assert.equal(state().ack.byteLength, Buffer.byteLength(text));
|
||||
const constructor = segment(source, "se=(e=oe)=>new d.DvK(", ",ie=se(oe)").slice(3);
|
||||
const resources = runInNewContext(`(${constructor})()`, { d: { DvK: Data }, n: {}, V: {}, J: {}, K: {}, q: "/workspace", U: "/workspace", H: undefined, ee: {}, Y: undefined, ne: {}, O: "session", oe: {}, paperclipCursorInstructionState: state });
|
||||
assert.equal(resources.additionalRules[0].content, text);
|
||||
const proof = { boundedPayloadRejected: true, explicitEmptyInstructions: true, nativeGlobalRule: true, nativeResourceAdditionalRules: true };
|
||||
if (platform !== "darwin-arm64") return proof;
|
||||
const main = await readFile(join(vendorRoot, platform, "dist-package/index.js"), "utf8");
|
||||
assert.equal(sha(main), "f8bd1c549f844859f8aeb9f06c01420f299bee22fe136695fe891eaf18674b12");
|
||||
const trace = { withName() { return this; } };
|
||||
const context = { Rm: (_stack, value) => value, S: { VI: ctx => ({ ctx, span: { setAttribute() {} } }) }, Jm: stack => { if (stack.hasError) throw stack.error; }, Qm: { warn() {} }, AbortSignal, performance, Pm: async () => ({}), Dm: () => ({}), vm: { bb: Data } };
|
||||
const global = runInNewContext(`({${segment(main, "async computeGlobalCache(e)", "dispose(){this.cacheDebounceTimeout")}}).computeGlobalCache`, context);
|
||||
const request = runInNewContext(`({${segment(main, "async computeCachedRequestContext(e,t)", "async execute(e,t)")}}).computeCachedRequestContext`, context);
|
||||
const captures = [];
|
||||
class Session { constructor(...args) { captures.push(args); } async replayConversationHistory() {} }
|
||||
const scope = { S: awaiter, crypto: { randomUUID }, process: { cwd: () => "/workspace" }, s: { resolve }, h: { z() {} }, C: { it: async () => ({}), Ag: class extends Error {}, rY: class extends Error {} }, y: { Y: async () => ({ resources, mcpLease: {} }), paperclipInstructionState: state }, f: { m: Session }, d: { debugLog() {} }, setTimeout() {} };
|
||||
const host = { isAuthenticated: true, sharedServices: { configProvider: { get: () => ({}) }, modelManager: { awaitCurrentModel: async () => ({}) }, teamSettingsService: {} }, ctx: {}, options: {}, deps: {}, connection: {}, clientCapabilities: {}, clientMetadata: {}, initializeSessionState: async () => ({ configOptions: [], models: {} }), buildModesState: () => ({}) };
|
||||
for (const [name, end] of [["newSession", "loadSession(e)"], ["loadSession", "unstable_listSessions(e)"]]) {
|
||||
const method = segment(source, `${name}(e)`, end);
|
||||
const response = await runInNewContext(`({${method}}).${name}`, scope).call(host, { cwd: "/workspace", sessionId: "fixture-session", mcpServers: [] });
|
||||
assert.equal(response._meta.paperclipCursorInstructions.digest, `sha256:${sha(text)}`);
|
||||
const globalCache = await global.call({ additionalRules: resources.additionalRules, cursorRulesService: { getAllCursorRules: async () => [{ content: "ambient project rules" }] }, repositoryProvider: { getCodebaseReference: async () => undefined } }, trace);
|
||||
const modelContext = await request.call({ globalCache: Promise.resolve(globalCache), mcpStateAccessor: { getState: async () => ({}) }, workspacePaths: [], collectWorkspaceCaches: async () => ({ gitRepos: [], gitRepoInfoComplete: true }) }, trace, {});
|
||||
assert.equal(modelContext.rules[0].content, text);
|
||||
assert.equal(modelContext.rules[1].content, "ambient project rules");
|
||||
proof[name] = { acknowledgementMatches: true, exactComposedBytesAtRequestContext: true, nativeRulePrecedencePreserved: true };
|
||||
}
|
||||
assert.equal(captures.length, 2);
|
||||
return { ...proof, mainSourceSha256: sha(main), boundary: "native RequestContext constructor; dependencies doubled; no inference" };
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { runInNewContext } from "node:vm";
|
||||
|
||||
const PINNED_ARM_CHUNK = "c01657c111f65153d7a40a923f01a5a86d393d1cd6893eaa03f71b9604d2af0c";
|
||||
const hash = source => createHash("sha256").update(source).digest("hex");
|
||||
function segment(source, start, end) {
|
||||
const first = source.indexOf(start); assert.ok(first >= 0, `Missing native method ${start}`);
|
||||
const last = source.indexOf(end, first + start.length); assert.ok(last > first, `Missing method boundary ${end}`);
|
||||
return source.slice(first, last);
|
||||
}
|
||||
const awaiter = (self, args, _promise, fn) => {
|
||||
const iterator = fn.apply(self, args ?? []);
|
||||
const next = value => { const result = iterator.next(value); return result.done ? Promise.resolve(result.value) : Promise.resolve(result.value).then(next); };
|
||||
return next();
|
||||
};
|
||||
|
||||
/** Executes the pinned native mode methods with dependency doubles. No network,
|
||||
* model call, or claim that a selected mode exposes a particular tool. */
|
||||
export async function qualifyCursorMode(source) {
|
||||
assert.equal(hash(source), PINNED_ARM_CHUNK, "Native source identity changed");
|
||||
const toAcp = mode => ({ default: "agent", plan: "plan", search: "ask" })[mode];
|
||||
const scope = { S: awaiter, T: { ACP_MODE_CONFIG_ID: "mode" }, p: { mb: toAcp }, _: { mb: toAcp }, i: { GI: { invalidParams: value => new Error(value.message) } }, w: { debugLog() {} } };
|
||||
const method = (start, end, name) => runInNewContext(`({${segment(source, start, end)}}).${name}`, scope);
|
||||
const setConfig = method("setSessionConfigOption(e)", "unstable_setSessionModel(e)", "setSessionConfigOption");
|
||||
const mapMode = method("mapAcpModeToCliMode(e)", "clientSupportsParameterizedModelPicker()", "mapAcpModeToCliMode");
|
||||
const modes = method("buildModesStateFromCliMode(e)", "buildModesState(e)", "buildModesStateFromCliMode");
|
||||
const configs = method("buildConfigOptions(e,t)", "buildModelsState(e)", "buildConfigOptions");
|
||||
const setMode = method("setMode(e){", "getCliMode(){", "setMode");
|
||||
const getCliMode = method("getCliMode(){", "sendCurrentModeUpdate(e)", "getCliMode");
|
||||
const rows = [];
|
||||
for (const selected of ["agent", "plan", "ask"]) {
|
||||
const metadata = new Map([["mode", "default"]]); const updates = [];
|
||||
const store = { getMetadata: key => metadata.get(key), setMetadata: (key, value) => metadata.set(key, value) };
|
||||
const makeSession = () => ({ agentStore: store, setMode, getCliMode, sendCurrentModeUpdate: async value => updates.push(value) });
|
||||
const session = makeSession();
|
||||
const host = { sessions: new Map([["native", { session }]]), sharedServices: {}, mapAcpModeToCliMode: mapMode, buildModesStateFromCliMode: modes, buildConfigOptions: configs, getModelPickerMode: () => "variants", getAcpAvailableModels: async () => [], buildVariantModelConfigOptions: async () => [] };
|
||||
const response = await setConfig.call(host, { sessionId: "native", configId: "mode", value: selected });
|
||||
assert.equal(response.configOptions[0].currentValue, selected);
|
||||
assert.equal(updates.at(-1), selected);
|
||||
// A reconstructed native session reads the same admitted metadata. Actual
|
||||
// database durability is deliberately outside this dependency-double proof.
|
||||
const loaded = makeSession();
|
||||
assert.equal((await configs.call(host, "native", loaded))[0].currentValue, selected);
|
||||
class Data { constructor(value) { Object.assign(this, value); } }
|
||||
const promptPart = segment(source, 'r=null!==(o=this.agentStore.getMetadata("mode"))', ',u=new a.h');
|
||||
const action = runInNewContext(`(function(){let o;const ${promptPart};return d;})`, { _: { cT: value => ({ nativeModeInput: value }) }, c: { UserMessageAction: Data, UserMessage: Data }, N: "offline fixture", n: {}, crypto: { randomUUID: () => "fixture" } }).call(loaded);
|
||||
assert.equal(action.userMessage.mode.nativeModeInput, metadata.get("mode"));
|
||||
rows.push({ selected, nativeMetadata: metadata.get("mode"), configAcknowledgement: selected, currentModeNotification: selected, reconstructedSessionAcknowledgement: selected, userMessageModeInput: action.userMessage.mode.nativeModeInput });
|
||||
}
|
||||
const planHandler = segment(source, '"./src/acp/interaction-handlers/create-plan-handler.ts"(e,t,n)', '},"./src/acp/resource-link-security.ts"');
|
||||
assert.doesNotMatch(planHandler, /setMode\(|setMetadata\("mode"/);
|
||||
return { schema: "paperclip.cursor.native-mode-offline-proof.v1", sourceSha256: hash(source), rows, planHandlerHasModeMutation: false, boundary: "pinned native config/session/UserMessage methods; storage, mode enum converter and transport doubled; no inference" };
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
const [sourcePath, outputPath] = process.argv.slice(2);
|
||||
if (!sourcePath || !outputPath) throw new Error("Usage: node qualify-cursor-mode.mjs PINNED_ARM_CHUNK OUTPUT_JSON");
|
||||
const proof = await qualifyCursorMode(await readFile(sourcePath, "utf8"));
|
||||
await writeFile(outputPath, `${JSON.stringify(proof, null, 2)}\n`);
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { runInNewContext } from "node:vm";
|
||||
import { CURSOR_RUNTIME_PATCH_PINS, patchCursorRuntimeSource } from "./cursor-runtime-patch.mjs";
|
||||
|
||||
const hash = value => createHash("sha256").update(value).digest("hex");
|
||||
const plain = value => JSON.parse(JSON.stringify(value));
|
||||
const event = counters => ({ message: { case: "turnEnded", value: counters } });
|
||||
function segment(source, start, end) {
|
||||
const first = source.indexOf(start); assert.ok(first >= 0, `Missing ${start}`);
|
||||
const last = source.indexOf(end, first + start.length); assert.ok(last > first, `Missing ${end}`);
|
||||
return source.slice(first, last);
|
||||
}
|
||||
function awaiter(self, args, _promise, fn) {
|
||||
const generator = fn.apply(self, args ?? []);
|
||||
const advance = (method, value) => {
|
||||
let step; try { step = generator[method](value); } catch (error) { return Promise.reject(error); }
|
||||
return step.done ? Promise.resolve(step.value) : Promise.resolve(step.value).then(
|
||||
value => advance("next", value), error => advance("throw", error));
|
||||
};
|
||||
return advance("next");
|
||||
}
|
||||
const deferred = () => { let resolve; const promise = new Promise(done => { resolve = done; }); return { promise, resolve }; };
|
||||
|
||||
/** Actual pinned method/expression execution with transport and surrounding
|
||||
* session dependencies doubled. No provider entrypoint or inference is run. */
|
||||
export async function qualifyCursorNativeUsage(vendorRoot) {
|
||||
const platforms = [];
|
||||
for (const [platform, pin] of Object.entries(CURSOR_RUNTIME_PATCH_PINS)) {
|
||||
const original = await readFile(join(vendorRoot, platform, "dist-package", pin.file), "utf8");
|
||||
assert.equal(hash(original), pin.before);
|
||||
const source = patchCursorRuntimeSource(original, platform);
|
||||
assert.equal(hash(source), pin.after);
|
||||
assert.throws(() => patchCursorRuntimeSource(original + "\n", platform), /digest mismatch/);
|
||||
const exported = {}; runInNewContext(source, { exports: exported });
|
||||
assert.ok(exported.modules["./src/acp/agent-session.ts"]);
|
||||
const collector = runInNewContext(segment(source, "const paperclipCreateCursorUsage=", ";const paperclipCursorResponseError=") + ";paperclipCreateCursorUsage");
|
||||
let ids = 0;
|
||||
const scope = { V: awaiter, G: 10, paperclipCreateCursorUsage: collector,
|
||||
crypto: { randomUUID: () => `00000000-0000-0000-0000-${String(++ids).padStart(12, "0")}` } };
|
||||
const handlePrompt = runInNewContext(`({${segment(source, "handlePrompt(e){", "claimTaskToolCall(e,t){")}}).handlePrompt`, scope);
|
||||
const invocationSource = segment(source, "D=async e=>", ",yield D(y)");
|
||||
const childUpdate = runInNewContext(`({${segment(source, "onInteractionUpdate(e,t){", "onSessionCompleted(e,t){")}}).onInteractionUpdate`);
|
||||
// Execute the vendor's object construction and reuse transition, rather
|
||||
// than assuming a hand-built child's fields match the pinned implementation.
|
||||
const childLifecycle = runInNewContext(`(class {${segment(source, "beginOrUpdateRun(e,t){", "announce(e,t){")}}).prototype`);
|
||||
assert.ok(source.includes("yield this.processPrompt(e,s,l,paperclipUsage)"));
|
||||
assert.ok(source.includes("processPrompt(e,t,n,paperclipUsage){"));
|
||||
const checks = [];
|
||||
const createHost = backend => {
|
||||
const publisher = { turn: 0, children: new Map(), enabled: true,
|
||||
beginTurn() { return ++this.turn; }, runIdsForTurn() { return []; }, whenAllTerminal: async () => true,
|
||||
enqueue(_child, fn) { return fn(); }, turnFor() { return this.turn; },
|
||||
beginOrUpdateRun: childLifecycle.beginOrUpdateRun, resolveParentSession: childLifecycle.resolveParentSession,
|
||||
startNewRun: childLifecycle.startNewRun, onInteractionUpdate: childUpdate };
|
||||
const host = {
|
||||
subagentPublisher: publisher, subagentsEnabled: true, backgroundWorkRegistry: { abortWork() {} },
|
||||
ctx: { withCancel() { const abort = new AbortController(); return [{ signal: abort.signal, get canceled() { return abort.signal.aborted; } }, () => abort.abort()]; } },
|
||||
agentStore: { getConversationStateStructure: () => ({}), getBlobStore: () => ({}) }, resources: {},
|
||||
sharedServices: { agentClient: { run: backend } },
|
||||
async processPrompt(request, context, turn, usage) {
|
||||
const invoke = runInNewContext(`(function(){let D;${invocationSource};return D;})`, {
|
||||
paperclipUsage: usage, t: context, b: { modelDetails: {} }, $: { sendUpdate: async () => {} }, u: {}, x: [], A: [], M: {}, k: {},
|
||||
}).call(this);
|
||||
await request.execute({ invoke, usage, context, turn, publisher });
|
||||
},
|
||||
};
|
||||
return { host, publisher, run: request => handlePrompt.call(host, request) };
|
||||
};
|
||||
const successful = createHost(async (_context, _state, action, _model, callbacks) => {
|
||||
await callbacks.sendUpdate({}, event(action));
|
||||
});
|
||||
const first = await successful.run({ execute: async ({ invoke }) => { await invoke({ inputTokens: 12n }); await invoke({ outputTokens: 3n }); } });
|
||||
const envelope = first._meta.paperclipCursorUsage;
|
||||
assert.equal(first.stopReason, "end_turn"); assert.equal(first.usage, undefined);
|
||||
assert.equal(envelope.completeness, "partial");
|
||||
assert.deepEqual(plain(envelope.observations.map(row => row.counters)), [{ inputTokens: 12 }, { outputTokens: 3 }]);
|
||||
assert.equal(successful.publisher.paperclipUsageCollectors.size, 0);
|
||||
checks.push("exact_parent_invocation_capture", "separate_followups", "no_standard_usage");
|
||||
|
||||
const second = await successful.run({ execute: async ({ invoke }) => { await invoke({ outputTokens: 1 }); } });
|
||||
assert.notEqual(second._meta.paperclipCursorUsage.promptId, envelope.promptId);
|
||||
assert.equal(second._meta.paperclipCursorUsage.observations.length, 1);
|
||||
checks.push("warm_prompt_isolation");
|
||||
|
||||
const childHost = createHost(async () => {});
|
||||
const childResult = await childHost.run({ execute: async ({ publisher, turn }) => {
|
||||
const child = publisher.beginOrUpdateRun("native-child", { toolCallId: "native-tool", parentAgentId: "parent", name: "worker" });
|
||||
assert.equal(child.runs, 1); assert.equal(child.turn, turn); assert.equal(child.terminal, false);
|
||||
assert.equal(publisher.children.get("native-child"), child);
|
||||
child.presenter = { presentInteractionUpdate() {} };
|
||||
publisher.onInteractionUpdate("native-child", event({ inputTokens: 7n }));
|
||||
child.terminal = true;
|
||||
const reused = publisher.beginOrUpdateRun("native-child", { toolCallId: "native-tool-2", parentAgentId: "parent", name: "worker" });
|
||||
assert.equal(reused, child); assert.equal(reused.runs, 2); assert.equal(reused.terminal, false);
|
||||
assert.equal(reused.sessionId, "native-child.2"); assert.equal(reused.turn, turn);
|
||||
child.presenter = { presentInteractionUpdate() {} };
|
||||
publisher.onInteractionUpdate("native-child", event({ inputTokens: 8n }));
|
||||
publisher.onInteractionUpdate("unknown-history-child", event({ inputTokens: 999n }));
|
||||
child.terminal = true; publisher.onInteractionUpdate("native-child", event({ inputTokens: 999n }));
|
||||
} });
|
||||
const childEnvelope = childResult._meta.paperclipCursorUsage;
|
||||
assert.deepEqual(plain(childEnvelope.observations.map(row => [row.role, row.nativeRun, row.counters.inputTokens])), [["child", 1, 7]]);
|
||||
assert.ok(childEnvelope.reasons.includes("child_run_attribution_unverified"));
|
||||
childHost.publisher.children.get("native-child").terminal = false;
|
||||
childHost.publisher.onInteractionUpdate("native-child", event({ inputTokens: 999n }));
|
||||
assert.equal(childEnvelope.observations.length, 1);
|
||||
checks.push("vendor_child_creation_and_reuse", "child_run_attribution", "history_and_late_child_isolation");
|
||||
|
||||
const started = deferred(), release = deferred(); let oldCallback;
|
||||
const overlapping = createHost(async (_context, _state, action, _model, callbacks) => {
|
||||
if (action === "first") { oldCallback = callbacks; started.resolve(); await release.promise; }
|
||||
await callbacks.sendUpdate({}, event({ inputTokens: action === "first" ? 10 : 20 }));
|
||||
});
|
||||
const pending = overlapping.run({ execute: async ({ invoke }) => invoke("first") });
|
||||
await started.promise;
|
||||
const next = await overlapping.run({ execute: async ({ invoke }) => invoke("second") });
|
||||
release.resolve(); const cancelled = await pending;
|
||||
assert.equal(cancelled.stopReason, "cancelled");
|
||||
assert.deepEqual(plain(cancelled._meta.paperclipCursorUsage.observations.map(row => row.counters.inputTokens)), [10]);
|
||||
assert.deepEqual(plain(next._meta.paperclipCursorUsage.observations.map(row => row.counters.inputTokens)), [20]);
|
||||
await oldCallback.sendUpdate({}, event({ inputTokens: 999 }));
|
||||
assert.equal(cancelled._meta.paperclipCursorUsage.observations.length, 1);
|
||||
assert.equal(overlapping.publisher.paperclipUsageCollectors.size, 0);
|
||||
checks.push("overlapping_cancellation", "late_parent_callback_isolation");
|
||||
|
||||
const failure = createHost(async () => { throw new Error("fixture transport failure"); });
|
||||
await assert.rejects(failure.run({ execute: async ({ invoke }) => invoke({}) }), /fixture transport failure/);
|
||||
assert.equal(failure.publisher.paperclipUsageCollectors.size, 0);
|
||||
checks.push("exception_cleanup");
|
||||
platforms.push({ platform, vendorSha256: hash(original), candidateSha256: hash(source), checks });
|
||||
}
|
||||
return { schema: "paperclip.cursor.native-usage-offline-proof.v1", providerCalls: 0, standardUsageEmitted: false,
|
||||
boundary: "digest-pinned handlePrompt, native invocation, child creation/reuse and child update methods; surrounding session/transport doubled", platforms };
|
||||
}
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
assert.equal(process.argv.length, 3, "Usage: node qualify-cursor-native-usage.mjs VENDOR_RESEARCH_ROOT");
|
||||
console.log(JSON.stringify(await qualifyCursorNativeUsage(process.argv[2]), null, 2));
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { qualifyCursorInstructions } from "./qualify-cursor-instructions.mjs";
|
||||
import assert from "node:assert/strict";
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { runInNewContext } from "node:vm";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { CURSOR_RUNTIME_PATCH_PINS, CURSOR_RUNTIME_PATCH_VERSION, patchCursorRuntimeSource } from "./cursor-runtime-patch.mjs";
|
||||
|
||||
const runGenerator = (_self, _args, _promise, fn) => (async () => {
|
||||
const generator = fn(); let value;
|
||||
while (true) { const next = generator.next(value); if (next.done) return next.value; value = await next.value; }
|
||||
})();
|
||||
const trap = () => { throw new Error("Ambient configuration was accessed"); };
|
||||
function segment(source, start, end) {
|
||||
const first = source.indexOf(start); assert.ok(first >= 0, `Missing ${start}`);
|
||||
const last = source.indexOf(end, first); assert.ok(last > first, `Missing ${end}`);
|
||||
return source.slice(first, last);
|
||||
}
|
||||
|
||||
/** Executes relevant expressions from the actual digest-verified vendor chunk.
|
||||
* This is offline proof of the patch, not provider/tool availability evidence. */
|
||||
export async function qualifyCursorRuntimePatch(vendorRoot) {
|
||||
const platforms = [];
|
||||
for (const [platform, pin] of Object.entries(CURSOR_RUNTIME_PATCH_PINS)) {
|
||||
const original = await readFile(join(vendorRoot, platform, "dist-package", pin.file), "utf8");
|
||||
const source = patchCursorRuntimeSource(original, platform);
|
||||
assert.equal(createHash("sha256").update(source).digest("hex"), pin.after);
|
||||
assert.throws(() => patchCursorRuntimeSource(original + "\n", platform), /digest mismatch/);
|
||||
assert.throws(() => patchCursorRuntimeSource(source, platform), /digest mismatch/);
|
||||
// Compiling the entire chunk catches minifier/scope syntax errors on every
|
||||
// platform without loading native code or executing provider entrypoints.
|
||||
const exported = {};
|
||||
runInNewContext(source, { exports: exported });
|
||||
assert.ok(exported.modules["./src/acp/shared-services.ts"]);
|
||||
const Lease = class { constructor(value) { this.value = value; } };
|
||||
// Both formerly ambient expressions are evaluated with poisoned loaders.
|
||||
const loader = segment(source, "te=null", ",ne=");
|
||||
assert.equal(runInNewContext(`let ${loader};te`, { r: { aK: { init: trap } } }), null);
|
||||
const empty = segment(source, "se=new u.i9({})", ",re=[]");
|
||||
assert.ok(runInNewContext(`let ${empty};ie`, { u: { i9: Lease, uz: Lease }, te: { load: trap } }) instanceof Lease);
|
||||
// Execute the complete pinned session MCP merge function. Any access to the
|
||||
// ambient lease fails, while owned injection and duplicate semantics survive.
|
||||
const mcpSource = segment(source, "ee=yield function(e,t,n,o)", "}(e,n,").slice("ee=yield ".length) + "}";
|
||||
const make = runInNewContext(`(${mcpSource})`, {
|
||||
j: runGenerator, R: runGenerator, A: { uz: Lease, i9: Lease, debugLog() {} },
|
||||
F: value => value.valid === false ? null : { serverName: value.name, config: value.config },
|
||||
x: { debugLog() {}, uz: Lease, i9: Lease }, N: name => `owned:${name}`,
|
||||
$: async ({ serverName, serverConfig }) => ({ serverName, serverConfig }),
|
||||
});
|
||||
const ambient = { get mcpLease() { return trap(); } };
|
||||
assert.deepEqual(Object.keys((await make({}, ambient, { mcpServers: [] }, "/workspace")).value.value), []);
|
||||
assert.deepEqual(Object.keys((await make({}, ambient, { mcpServers: [{ valid: false }] }, "/workspace")).value.value), []);
|
||||
const owned = (await make({}, ambient, { mcpServers: [{ name: "paperclip", config: "old" }, { name: "paperclip", config: "owned" }] }, "/workspace")).value.value;
|
||||
assert.equal(owned.paperclip.serverConfig, "owned");
|
||||
assert.deepEqual(Object.keys(owned), ["paperclip"]);
|
||||
// The remote team fetch and local config read are never evaluated, even
|
||||
// after a caller mutates their simulated config during this active session.
|
||||
const localHooks = segment(source, "let ue={errors:[],configDirs:{}};", "const he=");
|
||||
for (let mutation = 0; mutation < 2; mutation++) {
|
||||
const hooks = runInNewContext(`${localHooks}ue`, { ce: { load: trap } });
|
||||
assert.deepEqual(Object.keys(hooks), ["errors", "configDirs"]);
|
||||
}
|
||||
assert.ok(!source.includes("ae=(0,y.a)({dashboardClient:n.dashboardClient,teamId:re})"));
|
||||
// Exercise typed native errors; ordinary assistant prose is never inspected.
|
||||
const actionStart = "if(e instanceof r.ao)";
|
||||
const errors = segment(source, actionStart, "yield this.sendAgentMessageChunk(`\\n\\nError:");
|
||||
class ActionRequired extends Error { constructor(action) { super("private provider detail"); this.action = action; } }
|
||||
class ConnectError extends Error { constructor() { super("private auth detail"); this.code = 16; } }
|
||||
class ResponseError extends Error { constructor(code, message, data) { super(message); this.code = code; this.data = data; } }
|
||||
const binding = segment(source, "const paperclipCursorResponseError=", ";var s=");
|
||||
assert.equal(runInNewContext(`${binding};paperclipCursorResponseError`, { n: () => ({ GI: ResponseError }) }), ResponseError);
|
||||
const evaluate = error => runInNewContext(`(function(){${errors}})()`, { e: error, r: { ao: ActionRequired }, g: { T: ConnectError }, m: { C: { Unauthenticated: 16 } }, f: { C: { Unauthenticated: 16 } }, n: 123, paperclipCursorResponseError: ResponseError });
|
||||
for (const action of ["login", "upgrade", "payment", "config", "other"]) {
|
||||
assert.throws(() => evaluate(new ActionRequired(action)), error => error instanceof ResponseError
|
||||
&& error.code === (action === "login" ? -32000 : -32603)
|
||||
&& error.data.action === (action === "other" ? "unknown" : action)
|
||||
&& !error.message.includes("private"));
|
||||
}
|
||||
assert.throws(() => evaluate(new ConnectError()), error => error.code === -32000 && error.data.kind === "authentication_required");
|
||||
evaluate(new Error("Upgrade your plan to continue"));
|
||||
platforms.push({ instructions: await qualifyCursorInstructions(source, platform, vendorRoot), platform, file: pin.file, sourceSha256: pin.before, patchedSha256: pin.after, driftRejected: true, fullChunkCompiles: true, ambientMcpAccesses: 0, ownedMcpPreserved: true, hookConfigReads: 0, remoteTeamHookFetches: 0, typedActionErrors: true, assistantProseIgnored: true });
|
||||
}
|
||||
return { schema: "paperclip.cursor.runtime-patch-proof.v1", patchVersion: CURSOR_RUNTIME_PATCH_VERSION, providerCalls: 0, qualification: "offline-only", platforms };
|
||||
}
|
||||
if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url) {
|
||||
assert.equal(process.argv.length, 3, `Usage: node ${fileURLToPath(import.meta.url)} <vendor-archive-root>`);
|
||||
console.log(JSON.stringify(await qualifyCursorRuntimePatch(process.argv[2]), null, 2));
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import profiles from "../acpx-profiles.json" with { type: "json" };
|
||||
import distributions from "../cursor-distributions.json" with { type: "json" };
|
||||
|
||||
const canonical = value => Array.isArray(value) ? `[${value.map(canonical).join(",")}]`
|
||||
: value && typeof value === "object" ? `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${canonical(value[key])}`).join(",")}}`
|
||||
: JSON.stringify(value);
|
||||
|
||||
/** Release assembly must reject a self-consistent pack built for another release. */
|
||||
export function verifyReleaseProviderPack(identity, sourceRevision) {
|
||||
const payload = identity?.payload;
|
||||
if (identity?.schema !== "paperclip-runner/remote-provider-pack/v1" || !payload
|
||||
|| payload.target?.platform !== "linux" || payload.target?.architecture !== "x64"
|
||||
|| payload.runnerSourceRevision !== sourceRevision) throw new Error("Remote provider pack must match the release source and Linux x64 target");
|
||||
if (identity.digest !== `sha256:${createHash("sha256").update(canonical(payload)).digest("hex")}`) {
|
||||
throw new Error("Remote provider-pack payload digest mismatch");
|
||||
}
|
||||
if (payload.pins?.acpx !== profiles.acpxVersion
|
||||
|| ["grok", "claude", "codex"].some(agent => payload.acpxProfileDigests?.[agent] !== profiles.profiles[agent].commandDigest)) {
|
||||
throw new Error("Remote provider-pack ACPX profiles do not match this release");
|
||||
}
|
||||
const current = profiles.profiles.cursor;
|
||||
for (const cursor of [payload.providers?.cursor, ...(Object.hasOwn(payload.candidateProviders ?? {}, "cursor") ? [payload.candidateProviders.cursor] : [])]) {
|
||||
if (!cursor || cursor.version !== current.agentServerVersion || cursor.profileDigest !== current.commandDigest
|
||||
|| cursor.closureDigest !== `sha256:${distributions.platforms["linux-x64"].closureSha256}`
|
||||
|| cursor.qualification !== "qualified" || cursor.path !== "provider-assets/cursor/linux-x64"
|
||||
|| !/^sha256:[a-f0-9]{64}$/.test(cursor.sha256 ?? "")) {
|
||||
throw new Error("Remote provider-pack Cursor identity does not match this release");
|
||||
}
|
||||
}
|
||||
return identity;
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { createHash } from "node:crypto";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { cpSync, existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import test from "node:test";
|
||||
import profiles from "../acpx-profiles.json" with { type: "json" };
|
||||
import distributions from "../cursor-distributions.json" with { type: "json" };
|
||||
import { verifyReleaseProviderPack } from "./release-provider-pack.mjs";
|
||||
|
||||
const revision = "a".repeat(40);
|
||||
const digest = "sha256:" + "b".repeat(64);
|
||||
const canonical = value => Array.isArray(value) ? `[${value.map(canonical).join(",")}]`
|
||||
: value && typeof value === "object" ? `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${canonical(value[key])}`).join(",")}}` : JSON.stringify(value);
|
||||
function fixture() {
|
||||
const payload = { target: { platform: "linux", architecture: "x64" }, runnerSourceRevision: revision,
|
||||
pins: { acpx: profiles.acpxVersion },
|
||||
acpxProfileDigests: Object.fromEntries(["grok", "claude", "codex"].map(agent => [agent, profiles.profiles[agent].commandDigest])),
|
||||
providers: { cursor: { version: profiles.profiles.cursor.agentServerVersion, profileDigest: profiles.profiles.cursor.commandDigest,
|
||||
closureDigest: `sha256:${distributions.platforms["linux-x64"].closureSha256}`, qualification: "qualified",
|
||||
path: "provider-assets/cursor/linux-x64", sha256: digest } } };
|
||||
return payload;
|
||||
}
|
||||
function seal(payload) { return { schema: "paperclip-runner/remote-provider-pack/v1", payload,
|
||||
digest: `sha256:${createHash("sha256").update(canonical(payload)).digest("hex")}` }; }
|
||||
|
||||
test("accepts a pack whose source and profiles match the assembled release", () => {
|
||||
const manifest = seal(fixture()); assert.equal(verifyReleaseProviderPack(manifest, revision), manifest);
|
||||
});
|
||||
for (const field of ["version", "profileDigest", "closureDigest", "qualification", "path"]) {
|
||||
test(`rejects an independently rehashed stale Cursor ${field}`, () => {
|
||||
const payload = fixture(); payload.providers.cursor[field] = field.endsWith("Digest") ? digest : "stale";
|
||||
assert.throws(() => verifyReleaseProviderPack(seal(payload), revision), /Cursor identity/);
|
||||
});
|
||||
}
|
||||
test("rejects omitted Cursor, conflicting legacy inventory, stale sources and forged payload digests", () => {
|
||||
const missing = fixture(); delete missing.providers.cursor;
|
||||
assert.throws(() => verifyReleaseProviderPack(seal(missing), revision), /Cursor identity/);
|
||||
const mixed = fixture(); mixed.candidateProviders = { cursor: { ...mixed.providers.cursor, profileDigest: digest } };
|
||||
assert.throws(() => verifyReleaseProviderPack(seal(mixed), revision), /Cursor identity/);
|
||||
const old = fixture(); old.runnerSourceRevision = "c".repeat(40);
|
||||
assert.throws(() => verifyReleaseProviderPack(seal(old), revision), /release source/);
|
||||
const forged = seal(fixture()); forged.digest = digest;
|
||||
assert.throws(() => verifyReleaseProviderPack(forged, revision), /payload digest/);
|
||||
});
|
||||
|
||||
test("release assembly rejects rehashed stale or missing packs before staging any files", () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "release-provider-pack-"));
|
||||
const source = dirname(dirname(fileURLToPath(import.meta.url)));
|
||||
try {
|
||||
// Run the actual assembler in an isolated layout so it cannot overwrite built artifacts.
|
||||
for (const file of ["scripts/stage-release-runner-binaries.mjs", "scripts/release-provider-pack.mjs", "src/live/runner-binary.ts", "acpx-profiles.json", "cursor-distributions.json"]) {
|
||||
mkdirSync(dirname(join(root, file)), { recursive: true }); cpSync(join(source, file), join(root, file));
|
||||
}
|
||||
const platforms = {};
|
||||
for (const target of ["darwin-arm64", "darwin-x64", "linux-x64"]) {
|
||||
const bytes = Buffer.alloc(64);
|
||||
if (target === "linux-x64") {
|
||||
bytes.set([0x7f, 0x45, 0x4c, 0x46, 2, 1]); bytes.writeUInt16LE(62, 18);
|
||||
} else {
|
||||
bytes.writeUInt32LE(0xfeedfacf, 0); bytes.writeUInt32LE(target === "darwin-arm64" ? 0x0100000c : 0x01000007, 4);
|
||||
}
|
||||
const path = join(root, target); writeFileSync(path, bytes);
|
||||
platforms[target] = { path, sha256: `sha256:${createHash("sha256").update(bytes).digest("hex")}` };
|
||||
}
|
||||
const payload = fixture(); payload.providers.cursor.profileDigest = digest;
|
||||
const bytes = JSON.stringify(seal(payload)), path = join(root, "provider-pack.json"); writeFileSync(path, bytes);
|
||||
const manifest = { sourceRevision: revision, platforms, remoteProviderPack: { path, sha256: `sha256:${createHash("sha256").update(bytes).digest("hex")}` } };
|
||||
for (const error of [/Cursor identity/, /requires the matching Linux remote provider pack/]) {
|
||||
writeFileSync(join(root, "release.json"), JSON.stringify(manifest));
|
||||
const result = spawnSync(process.execPath, [join(root, "scripts/stage-release-runner-binaries.mjs"), join(root, "release.json")], { encoding: "utf8" });
|
||||
assert.equal(result.status, 1); assert.match(result.stderr, error);
|
||||
assert.equal(existsSync(join(root, "dist")), false);
|
||||
delete manifest.remoteProviderPack;
|
||||
}
|
||||
} finally { rmSync(root, { recursive: true, force: true }); }
|
||||
});
|
||||
Loaded 100 of 461 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user