diff --git a/AGENTS.md b/AGENTS.md index 2c05167..e07bdf1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -50,7 +50,7 @@ This repository is a pi provider extension that registers Cursor SDK-backed mode - `shared/cursor-sensitive-text.mjs` owns canonical secret scrubbing; `src/cursor-sensitive-text.ts` and `scripts/lib/cursor-sensitive-text.mjs` consume it for provider errors, native replay display, and maintainer scripts. - `shared/cursor-setting-sources.mjs` owns canonical `PI_CURSOR_SETTING_SOURCES` parsing/serialization; `src/cursor-setting-sources.ts` and `scripts/lib/cursor-setting-sources.mjs` consume it for provider runtime and maintainer scripts. - `scripts/lib/cursor-smoke-env.mjs`, `scripts/lib/cursor-smoke-shell.sh`, and `scripts/lib/cursor-visual-render.mjs` own maintainer smoke PATH/env isolation and browser-rendered visual artifacts; smoke runners should consume these helpers instead of duplicating debug env names, sealed Node PATH logic, or xterm/Playwright rendering. -- `scripts/lib/cloud-smoke-github.mjs` owns throwaway GitHub repository identity, provisioning, and deletion proof; `scripts/lib/cloud-smoke-cleanup-evidence.mjs` owns Cloud agent cleanup, retained evidence/provenance, and release-gate resource coordination; `scripts/lib/cloud-smoke-shutdown.mjs` owns signal-safe detached-child shutdown. `scripts/cloud-runtime-smoke.mjs` keeps concrete lane orchestration. +- `scripts/lib/cloud-smoke-github.mjs` owns throwaway GitHub repository identity, provisioning, and deletion proof; `scripts/lib/cloud-smoke-cleanup-evidence.mjs` owns Cloud agent cleanup, retained evidence/provenance, and release-gate resource coordination; `scripts/lib/cloud-smoke-shutdown.mjs` owns signal-safe detached-child shutdown; `scripts/lib/cloud-smoke-pi-runner.mjs` owns print/RPC child transport; `scripts/lib/cloud-smoke-artifacts.mjs` owns metadata and lifecycle artifact readers. `scripts/cloud-runtime-smoke.mjs` keeps concrete lane orchestration. - `scripts/platform-smoke/artifact-bundle-contract.mjs` owns the canonical platform artifact bundle path/size/shape contract; `scripts/platform-smoke/artifact-fs-safety.mjs` owns no-follow traversal, bounded reads, extraction preflight, and spill writes; `scripts/platform-smoke/artifact-anchored-extract.mjs` plus `artifact-openat-extract.c` own descriptor-relative POSIX extraction/rollback and fail-closed Windows-controller handling; `scripts/platform-smoke/artifact-secrets.mjs` owns bundle secret-scan/redaction; `scripts/platform-smoke/wrapped-line-match.mjs` owns terminal-wrap-aware line matching. Platform smoke scripts should consume these instead of duplicating fs-safety or redaction logic. - `src/cursor-tool-presentation-registry.ts` is the canonical typed registry for Cursor tool names, labels, visibility, lifecycle, replay metadata (legacy wrapper names, wrapper labels, side-effect policy, call-summary policy), web remapping, alias normalization, and bridge exclusions for internal replay wrappers only (`cursor`, `cursor_*`); sibling modules derive from it. - `src/cursor-transcript-tool-specs.ts` owns per-tool transcript formatters and pi display builders keyed by normalized tool name; its display implementation keys must match registry entries exactly (`CURSOR_TOOL_DISPLAY_SPEC_KEYS`). diff --git a/CHANGELOG.md b/CHANGELOG.md index 0be5138..b433464 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## Unreleased +## 0.1.61 - 2026-07-22 ### Added @@ -13,10 +13,19 @@ - Give each persisted pi session its own Cursor SDK SQLite store under the workspace SDK state root and thread that exact store through local create/resume, message reads, checkpoint lookup, delete, and explicit cleanup paths, preventing parallel pi sessions from contending on one workspace `index.db`. Fileless acquisitions use unique OS-temporary stores with guarded graceful removal and start a fresh agent after in-process invalidation instead of reopening a disposed temporary store. Resume entries now version their store identity; legacy entries keep the default workspace store for resume and migrate to the per-session store after fallback or replacement. Older extension versions ignore the new version-2 resume entries after a downgrade. - Initialize `CURSOR_RIPGREP_PATH` from the installed Cursor SDK platform package before local agent creation, including nested npm dependency layouts, so Cursor-native Grep/Glob can use the bundled executable. - Bound pending pi bridge `CallTool` waits to the effective MCP tool timeout, with a lower-only `PI_CURSOR_PI_BRIDGE_CALL_TIMEOUT_MS` override; expiry and cancellation remove stale calls and abort active pi execution when available. +- Fail closed before Cursor Cloud agent creation when an explicit repository/ref cannot be matched to one unambiguous local remote-tracking target, when local state is dirty/unpushed/unverifiable, or when Git metadata, URL rewriting, refspec ownership, replacement/graft ancestry, sparse-index state, or ambient Git redirection makes the target uncertain. `--cursor-cloud-allow-local-state` remains the explicit override. +- Require project-trust provenance from Pi's `project_trust` event or explicit `--approve` before reading or writing `.pi/cursor-sdk.json`; project-local package installs loaded after trust resolution must use `--approve`, and concurrent config writers preserve unrecognized fields through a serialized read-modify-write update. +- Preserve scrubbed Cursor Cloud authentication and GitHub integration remediation without exposing credentials, URL userinfo, bearer values, or unsafe help URLs. +- Suppress raw Cursor SDK `AbortError` DOMException/Error process failures only while an active provider turn has declared abort suppression and the stack has Cursor SDK provenance; inactive, undeclared, and non-Cursor abort errors remain visible. ### Changed - Expand the maintainer-only `npm run smoke:cloud` release gate to create, seed, and delete a private UUID-named GitHub repository while proving cancel, starting-ref branch, direct-push, missing-branch, lifecycle-delete, exact agent cleanup, and authenticated repository-deletion contracts. Add fail-closed `SIGINT`/`SIGTERM` handling, including a real event-loop checkpoint before the atomic evidence commit and handlers retained through process teardown, plus account-conditional artifact/raw-usage observations. The gate now requires `gh` authorization to create/push/delete private repositories; product runtime behavior and defaults are unchanged. +- Add a required packed-install `cursor-http1-live` platform lane on macOS, Ubuntu, and native Windows that completes a real HTTP/1.1/SSE local provider turn and asserts the visible `http1` status. + +### Security + +- Refresh lock-resolved `hono`, `fast-uri`, and `body-parser` to patched versions. `npm audit --omit=dev` still reports five vulnerable package entries covering ten public transitive advisories: the pinned Cursor SDK's ConnectRPC path has no compatible fixed `undici` release, while the MCP SDK's remaining Hono `serve-static` advisory is fixed only in a major `@hono/node-server` version outside the SDK's declared range and is not exercised by this extension's loopback `StreamableHTTPServerTransport`; no compatible non-breaking upstream update is currently available for those remaining paths. ## 0.1.60 - 2026-07-17 diff --git a/README.md b/README.md index e6500e0..5221501 100644 --- a/README.md +++ b/README.md @@ -503,7 +503,7 @@ On bootstrap sends, a compact **callable tool surfaces** block is injected into ### Maintainer platform smoke release gate -For Cursor provider/runtime changes, the canonical local release and pre-commit gate is the local platform smoke gate in [Platform smoke](docs/platform-smoke.md): run `npm run smoke:platform:all`, which runs doctor before the target matrix. Cloud-runtime changes must also run `npm run smoke:cloud`. The platform gate validates macOS, Ubuntu, and Windows native through Crabbox using packed installs, PTY/ConPTY ANSI capture, host-rendered xterm/PNG evidence, JSONL assertions, bridge diagnostics, usage/cache checks, abort cleanup, artifact manifests, and redaction scans. After each platform run, `.artifacts/platform-smoke/latest.json` points to the latest useful evidence paths. Do not mark a release ready with optional, deferred, mostly-passing, or unobserved platform smoke checks outstanding. +For Cursor provider/runtime changes, the canonical local release and pre-commit gate is the local platform smoke gate in [Platform smoke](docs/platform-smoke.md): run `npm run smoke:platform:all`, which runs doctor before the target matrix. Cloud-runtime changes must also run `npm run smoke:cloud`. The platform gate validates macOS, Ubuntu, and Windows native through Crabbox using packed installs, a required HTTP/1.1/SSE provider-turn lane, PTY/ConPTY ANSI capture, host-rendered xterm/PNG evidence, JSONL assertions, bridge diagnostics, usage/cache checks, abort cleanup, artifact manifests, and redaction scans. After each platform run, `.artifacts/platform-smoke/latest.json` points to the latest useful evidence paths. Do not mark a release ready with optional, deferred, mostly-passing, or unobserved platform smoke checks outstanding. The older live smoke helpers remain useful for inner-loop debugging and focused visual audits, not as the release gate. Use [Cursor live smoke checklist](docs/cursor-live-smoke-checklist.md), `npm run smoke:visual`, `npm run smoke:live`, or direct `pi --approve -e . --cursor-no-fast --model cursor/composer-2-5` runs when iterating on a specific TUI/card/runtime issue before the full platform gate. `npm run smoke:visual` captures an offscreen PTY rendered through browser/xterm and saved as PNG screenshots with Playwright, or with `agent_browser` from the generated HTML when available. Its default matrix is native replay only: native replay registration is forced on, Cursor setting sources are disabled, the pi bridge is off, overlapping built-in pi tools are not exposed, and inherited Cursor SDK event-debug artifact env is cleared; `--event-debug` writes to a deterministic debug directory under the visual output directory. The visible TUI/output, rendered screenshots, scrubbed diagnostics, and persisted JSONL must agree. See [Cursor testing lessons](docs/cursor-testing-lessons.md) for auth.json seeding, isolated `/tmp` harness layout, JSONL replay-error scans, and other regression traps. diff --git a/docs/cursor-model-ux-spec.md b/docs/cursor-model-ux-spec.md index 4a8d252..91c75c8 100644 --- a/docs/cursor-model-ux-spec.md +++ b/docs/cursor-model-ux-spec.md @@ -722,7 +722,7 @@ Before calling done: - verify default pi footer remains unchanged - verify Cursor status appears only for Cursor models - verify Cursor fast-capable local models show `cursor:local · fast:on` or `cursor:local · fast:off` - - verify enabled local HTTP/1.1 shows `http1`, while default/local-disabled and all cloud statuses omit it + - required `cursor-http1-live` platform lane proves an enabled local HTTP/1.1/SSE turn completes and shows `http1`; unit/default live lanes prove local-disabled and cloud statuses omit it - verify Cursor `plan` status appears only in non-default mode and combines with status as `cursor:local · fast:n/a · plan`, `cursor:local · fast:on · plan`, `cursor:local · fast:off · plan`, or `cursor:cloud · fast:n/a · plan` - verify non-cursor footer/status unchanged - verify `shift+tab` uses pi native thinking diff --git a/docs/evidence/cursor-cloud-smoke-matrix-latest.json b/docs/evidence/cursor-cloud-smoke-matrix-latest.json index 28ec088..e0cbbdc 100644 --- a/docs/evidence/cursor-cloud-smoke-matrix-latest.json +++ b/docs/evidence/cursor-cloud-smoke-matrix-latest.json @@ -1,19 +1,19 @@ { "schemaVersion": 1, - "timestamp": "2026-07-20T14:36:23.788Z", + "timestamp": "2026-07-23T03:19:15.166Z", "model": "cursor/composer-2-5", "provenance": { - "extensionVersion": "0.1.60", + "extensionVersion": "0.1.61", "cursorSdkVersion": "1.0.23", - "gitRevision": "81f05e309c0a2a93217aa63e98407f80f1444fae", - "packageSourceSha256": "274e087fdc49681df0b3e7d69565c64f85c99e313f794290bb24a21856739516" + "gitRevision": "e047c332d5e278e6f0e1128f10815c439ab96b06", + "packageSourceSha256": "ca2d87207de0a9dc4803a590e4eacd632af5e6009fc19915f23e3f8c6edc823a" }, "lanes": [ { "name": "cancel", "status": "passed", - "agentId": "bc-f9f1ce84-7e45-4153-bce0-7010280ef13a", - "runId": "run-a596abf1-c1ca-4e82-91d1-230d0119acd4", + "agentId": "bc-1260a755-c3dd-4db3-a99a-b313d5a7a8c0", + "runId": "run-2d45ecbc-6607-497a-ba1b-2f90e4f47a2a", "runIdSource": "metadata", "terminalStatus": "cancelled", "idsCapturedBeforeAbort": true @@ -21,8 +21,8 @@ { "name": "explicit-https-repo-starting-ref-branch-pr-reporting", "status": "passed", - "agentId": "bc-b6ac579a-a85a-4ede-985d-cec86258fbed", - "runId": "run-d6ac0d91-cb5b-4acf-9a8b-1545e37c1349", + "agentId": "bc-e2645fe4-18e8-4879-aa77-a8acbe5ae023", + "runId": "run-a9667a91-4b9f-4f47-af9c-649929e1152f", "branchReportObserved": false, "startingRefAncestryVerified": true, "remoteContentVerified": true, @@ -34,15 +34,15 @@ { "name": "lifecycle-delete", "status": "passed", - "agentId": "bc-b6ac579a-a85a-4ede-985d-cec86258fbed", - "runId": "run-d6ac0d91-cb5b-4acf-9a8b-1545e37c1349", + "agentId": "bc-e2645fe4-18e8-4879-aa77-a8acbe5ae023", + "runId": "run-a9667a91-4b9f-4f47-af9c-649929e1152f", "lifecycleDeleteVerified": true }, { "name": "direct-push-opt-in", "status": "passed", - "agentId": "bc-d965ad48-d581-4e3d-a861-14795ec5962d", - "runId": "run-6d8b2502-2266-4a88-abd5-8da4b5b5ebcc", + "agentId": "bc-f23c6b5e-3ad8-49ea-bc4a-40f8e1b87e11", + "runId": "run-391217c8-1e6d-4d8e-8f48-e70589ec8b2e", "remoteContentChanged": true, "branches": [ { @@ -58,7 +58,7 @@ "status": "passed", "expectedFailureObserved": true, "agentIds": [ - "bc-3de8fc6b-af53-47dc-b189-764e0b0a085d" + "bc-55d540e6-9787-4563-8932-9a6cee7aabf7" ] }, { @@ -71,26 +71,26 @@ ], "cleanup": [ { - "agentId": "bc-f9f1ce84-7e45-4153-bce0-7010280ef13a", + "agentId": "bc-1260a755-c3dd-4db3-a99a-b313d5a7a8c0", "archived": true, "deleted": true, "listExcluded": true }, { - "agentId": "bc-b6ac579a-a85a-4ede-985d-cec86258fbed", + "agentId": "bc-e2645fe4-18e8-4879-aa77-a8acbe5ae023", "alreadyDeleted": true, "archiveRequired": false, "deleted": true, "listExcluded": true }, { - "agentId": "bc-d965ad48-d581-4e3d-a861-14795ec5962d", + "agentId": "bc-f23c6b5e-3ad8-49ea-bc4a-40f8e1b87e11", "archived": true, "deleted": true, "listExcluded": true }, { - "agentId": "bc-3de8fc6b-af53-47dc-b189-764e0b0a085d", + "agentId": "bc-55d540e6-9787-4563-8932-9a6cee7aabf7", "alreadyDeleted": true, "archiveRequired": false, "deleted": true, @@ -98,7 +98,7 @@ } ], "throwawayRepository": { - "name": "fitchmultz/pi-cursor-cloud-smoke-a6cb8561-19b5-4c45-acd8-9739536b7739", + "name": "fitchmultz/pi-cursor-cloud-smoke-e66655d6-2d64-48a7-9076-82455541f81e", "deleted": true, "httpStatus": 404 } diff --git a/docs/plans/cursor-sdk-capability-roadmap-2026-07-04.md b/docs/plans/cursor-sdk-capability-roadmap-2026-07-04.md index 47fafe4..95861df 100644 --- a/docs/plans/cursor-sdk-capability-roadmap-2026-07-04.md +++ b/docs/plans/cursor-sdk-capability-roadmap-2026-07-04.md @@ -1,6 +1,6 @@ # Cursor SDK capability roadmap -Status: **Active current-state capability ledger and remaining-work plan**. Historical reconciliation baseline: `a2d574b` on 2026-07-18. Implementation status refreshed 2026-07-19 against current source and installed `@cursor/sdk@1.0.23`. +Status: **Active current-state capability ledger and remaining-work plan**. Historical reconciliation baseline: `a2d574b` on 2026-07-18. Implementation status refreshed 2026-07-22 against current source and installed `@cursor/sdk@1.0.23`. This document separates landed behavior, open work, deliberate product exclusions, and SDK/API contract gaps. Historical probes are context only; current source, tests, installed SDK types/source, and retained smoke evidence are the acceptance authorities. @@ -33,11 +33,12 @@ Field-specific effective source precedence: - Runtime: CLI > environment > session > trusted project > user > built-in. - Cloud acknowledgement: CLI > environment > session > user > built-in. -- Other cloud fields (repo/ref, context handoff, direct push, local-state allow, env names/file forwarding, and Cursor-managed environment): CLI > environment > user > built-in, subject to field-specific validation and safety caps. +- Other cloud fields (repo/ref, context handoff, direct push, PR controls, local-state allow, env names/file forwarding, and Cursor-managed environment): CLI > environment > user > built-in, subject to field-specific validation and safety caps. - Local `autoReview`, `sandbox`, and `resume`: CLI > environment > trusted project > user > built-in. +- Local HTTP/1.1/SSE transport: session > environment > user > built-in; project config is excluded. - Local `force`: CLI > environment > built-in. -Safety-sensitive cloud fields preserve explicit one-shot CLI intent. Below CLI, a stricter user setting may cap a riskier environment choice; field-specific validation still applies. The effective session layer currently populates only runtime and cloud acknowledgement, so it cannot supply other cloud fields. Project config is excluded from acknowledgement and all other cloud fields; trusted project config may select runtime only. It cannot acknowledge cloud or provide repo/ref, bootstrap, env names, direct-push, local-state, environment, or cleanup choices. Local `autoReview`, `sandbox`, and `resume` may load from trusted project config; local `force` cannot. +Safety-sensitive cloud fields preserve explicit one-shot CLI intent. Below CLI, a stricter user setting may cap a riskier environment choice; field-specific validation still applies. Persisted/public session state currently supplies runtime, cloud acknowledgement, and the local HTTP/1.1 preference; it supplies no other cloud fields. Project config is excluded from acknowledgement and all other cloud fields; trusted project config may select runtime only. It cannot acknowledge cloud or provide repo/ref, bootstrap, env names, direct-push, local-state, environment, or cleanup choices. Local `autoReview`, `sandbox`, and `resume` may load from trusted project config; local `force` cannot. | Setting | Current behavior | Status | | --- | --- | --- | @@ -45,12 +46,14 @@ Safety-sensitive cloud fields preserve explicit one-shot CLI intent. Below CLI, | First cloud acknowledgement | `--cursor-cloud-ack`, `PI_CURSOR_CLOUD_ACK`, session/user acknowledgement; project config excluded. | **Implemented** | | Explicit repo/ref | HTTPS repo override; branch/ref requires repo and maps to `repos[].startingRef`. | **Implemented** | | Direct push | Explicit CLI/environment/user choice maps to `workOnCurrentBranch`; default false. | **Implemented** | +| Cloud PR controls | Strictly opt-in CLI/environment/user `autoCreatePR` and `skipReviewerRequest`; unset SDK fields stay omitted and project config is excluded. | **Implemented** | +| Local HTTP/1.1/SSE | Opt-in environment/session/user choice configures the SDK before local agent creation, splits pool identity, and shows `http1`; project config and cloud are excluded. | **Implemented** | | Local-only state allow | Explicit CLI/environment/user escape hatch; default is fail closed. | **Implemented** | | Context handoff | Fresh by default; bootstrap requires explicit CLI/environment/user consent because it sends prior Pi context to cloud, which may include file contents, tool outputs, paths or skill references, environment values, or secrets. | **Implemented** | | Cursor-managed environment | Explicit `cloud` / `pool` / `machine` selection; Pi does not automatically forward process environment values. | **Implemented** | | Pi env forwarding and `.env` reads | Parsed reserved shapes fail preflight with Cursor-native environment guidance. | **Intentionally deferred/rejected** — avoids a parallel secret-management path. | | Inline cloud MCP | No create/send path supplies it. | **Intentionally deferred/rejected** — live historical probes showed unsafe first-run/replacement persistence behavior. | -| Local resume | Default-on branch-scoped resume with strict session/tree/compaction/tool-surface identity and explicit cleanup. | **Implemented** | +| Local resume | Default-on branch-scoped resume with strict session/tree/compaction/tool-surface/store identity and explicit cleanup. Persisted pi sessions use per-session SQLite stores; fileless acquisitions use removable temporary stores. | **Implemented** | | Cloud resume | Every cloud turn creates a new cloud agent. | **Intentionally deferred/rejected** — lifecycle, privacy, compaction, and broader live evidence decisions remain unresolved. | Automatic provider startup in print/JSON/RPC does not prompt and fails closed when acknowledgement or required Pi-owned safety choices are absent. The `/cursor-runtime cloud` command is separate: it may request confirmation whenever the host exposes UI (`ctx.hasUI`), including an RPC host with UI. The trust gate, project-local post-trust loading exception, and explicit `--approve` / `--no-approve` CLI-to-provider contract are covered in `test/cursor-project-trust-contract.test.ts`. @@ -74,7 +77,7 @@ Automatic provider startup in print/JSON/RPC does not prompt and fails closed wh | No automatic injection or forwarding of the local Pi bridge, local MCP, `local.customTools`, local settings or skill metadata, or process environment values to cloud | **Implemented** | This excludes automatic metadata forwarding, not content explicitly included in consented bootstrap context. Anchors: `src/cursor-cloud-options.ts`, `src/cursor-provider-turn-prepare.ts`, `src/cursor-skill-tool.ts`; `test/cursor-provider-stream-config.test.ts`, `test/cursor-skill-tool.test.ts`. | | Inline cloud MCP | **Intentionally deferred/rejected** | SDK types expose `mcpServers`, but the current product omits them because historical first-run/replacement probes showed hidden persistence and replacement failure. Revisit only with a deterministic SDK contract and retained passing evidence. | | Cursor-native SDK `agents` / Pi-subagent mapping | **Intentionally deferred/rejected** | SDK exposes `AgentOptions.agents`; no automatic Pi mapping is approved. Cursor may load native `.cursor/agents/*.md` from its own settings. | -| Runtime-aware footer; cloud fast state is `n/a` | **Implemented** | `src/cursor-state.ts`; `test/cursor-runtime-state.test.ts`. | +| Runtime-aware footer; cloud fast state is `n/a`; local HTTP/1.1 shows `http1` | **Implemented** | `src/cursor-state.ts`, `src/cursor-http1.ts`; `test/cursor-runtime-state.test.ts`, `test/cursor-http1-state.test.ts`, and required `cursor-http1-live` platform smoke. | | Best-effort catalog ID/alias preflight through public `Agent.create()` | **Implemented** | `src/cursor-provider-turn-prepare.ts` calls `Agent.create()`. Installed SDK code checks the base model ID/alias against `Cursor.models.list()`, but does not validate variant parameters or cloud runtime availability, and catalog lookup failures can fall through; backend create/send errors are authoritative. The mocked provider coverage in `test/cursor-provider-stream-config.test.ts` proves only that Pi calls the public `Agent.create()` path. | | `/model` cloud annotations, compatibility warning, and cloud-aware `--list-models` | **Blocked on SDK/API** | Missing contract: installed `ModelListItem` has no local/cloud availability field, and no maintained account-scoped availability/preflight source exists. Preserve best-effort catalog ID/alias preflight through `Agent.create()` while backend create/send errors remain authoritative; P1.5 states the acceptance trigger. | | Cloud streaming through the shared coordinator | **Implemented** | `src/cursor-provider-turn-prepare.ts` and `src/cursor-provider-turn-send.ts` use `CursorSdkTurnCoordinator`; `test/cursor-provider-stream-config.test.ts` covers direct cloud mode. P2.7 adds retained cloud activity-card evidence. | @@ -90,12 +93,16 @@ Automatic provider startup in print/JSON/RPC does not prompt and fails closed wh | Agent/run URL display | **Intentionally deferred/rejected** | Public `SDKAgentInfo` exposes no URL. Current reporting shows IDs and PR URL; do not depend on private raw shapes. | | Remote Pi bridge | **Intentionally deferred/rejected** | No approved public endpoint, per-run auth, tool allowlist, trust model, cancellation, redaction, or cleanup contract exists. Cloud must not depend on it. | | Cloud-specific auth/integration remediation | **Implemented** | `src/cursor-provider-errors.ts` preserves scrubbed HTTPS integration remediation and distinguishes Cloud API authentication while retaining local handling; runtime provenance is threaded by the turn runner/finalization path. Coverage: `test/cursor-provider-errors.test.ts`, `test/cursor-provider-stream-auth.test.ts`, `test/cursor-provider-run-outcome.test.ts`. | -| PR controls beyond direct push (`autoCreatePR`, `skipReviewerRequest`) | **Implemented** | Strictly opt-in CLI/environment/user inputs resolve through the CLI/environment/session/user source order and map to SDK cloud options only when explicitly set; no PR-control session command is exposed, project config is excluded, and unset options remain omitted. Anchors: `src/cursor-config.ts`, `src/cursor-runtime-state.ts`, `src/cursor-cloud-options.ts`, focused config/option/provider/registration tests, and `docs/evidence/cursor-cloud-pr-controls-2026-07-19.md`. | -| Required cloud runtime matrix and optional fresh/bootstrap context proof | **Implemented** | `scripts/cloud-runtime-smoke.mjs`, `package.json`, and `docs/platform-smoke.md` keep no-flag `smoke:cloud` as the release gate and `smoke:cloud:context` as optional context proof. Both use persisted sessions and fail-closed verified agent cleanup. | +| PR controls beyond direct push (`autoCreatePR`, `skipReviewerRequest`) | **Implemented** | Strictly opt-in CLI/environment/user inputs map to SDK cloud options only when explicitly set; the generic resolver can accept a session-layer value for tests/embedding, but current persisted/public session state exposes no PR-control command or value. Project config is excluded, and unset options remain omitted. Anchors: `src/cursor-config.ts`, `src/cursor-runtime-state.ts`, `src/cursor-cloud-options.ts`, focused config/option/provider/registration tests, and `docs/evidence/cursor-cloud-pr-controls-2026-07-19.md`. | +| Required cloud runtime matrix and optional fresh/bootstrap context proof | **Implemented** | `scripts/cloud-runtime-smoke.mjs`, dedicated child/artifact/GitHub/cleanup/shutdown helpers under `scripts/lib/cloud-smoke-*`, `package.json`, and `docs/platform-smoke.md` keep no-flag `smoke:cloud` as the release gate and `smoke:cloud:context` as optional context proof. Both use persisted sessions and fail-closed verified agent cleanup. | | Expanded repo/ref/direct-push/missing-branch/cancel/delete/artifact/usage smoke matrix | **Implemented** | `scripts/cloud-runtime-smoke.mjs` plus focused GitHub/cleanup helpers self-create/seed/delete a private GitHub repository, run the named required lanes, verify exact metadata/lifecycle IDs (including retained cancel run-ID source and installed-SDK `Agent.listRuns()` recovery), remote/API outcomes, archive+delete cleanup, and atomically write sanitized provenance-bearing `docs/evidence/cursor-cloud-smoke-matrix-latest.json` only after a successful no-flag run. Offline contracts: `test/cloud-smoke-helpers.test.ts`, `test/cursor-sdk-cloud-list-runs-contract.test.ts`, `test/smoke-tooling.test.ts`, `test/smoke-cli-package-contracts.test.ts`, `test/maintainer-scripts-declarations.test.ts`. | | Cloud activity-card fixture and visual contract | **Implemented** | Normalized installed-SDK capture: `test/fixtures/cursor-cloud-activity-callbacks-2026-07-19.json`; actual cloud prepare/send/coordinator coverage plus installed Pi `AssistantMessageComponent` fixed-width render assertion: `test/cursor-provider-cloud-activity.test.ts`; retained provenance and cleanup proof: `docs/evidence/cursor-cloud-activity-callbacks-2026-07-19.md`. | | Default Pi-tool transport via SDK `local.customTools` | **Blocked on SDK/API** | Missing contract: `SDKCustomToolContext` provides no `AbortSignal`, deadline, or cancellation channel. Revisit only if the SDK adds them or Pi accepts an adapter owning aborts, timeouts, child cleanup, diagnostics, permissions, and platform-smoke parity. The loopback MCP bridge remains canonical. | -| Guarded branch-scoped local `Agent.resume()` and recorded-ID cleanup | **Implemented** | `src/cursor-session-agent.ts`, `src/cursor-session-agent-resume.ts`, `src/cursor-session-agent-cleanup.ts`; session-agent resume/cleanup tests and `npm run smoke:platform:all` lanes cover restart, tree, compaction, copy/fork, tool-surface, abort, fallback, opt-out, and cleanup. | +| Guarded branch-scoped local `Agent.resume()`, per-session stores, and recorded-ID cleanup | **Implemented** | `src/cursor-session-agent.ts`, `src/cursor-session-store.ts`, `src/cursor-session-agent-resume.ts`, `src/cursor-session-agent-cleanup.ts`; store/resume/cleanup tests and `npm run smoke:platform:all` lanes cover isolation, restart, tree, compaction, copy/fork, tool-surface, abort, fallback, opt-out, and cleanup. | +| Cursor bundled ripgrep initialization | **Implemented** | `src/cursor-ripgrep-path.ts` resolves the installed SDK platform package before local agent creation; `test/cursor-ripgrep-path.test.ts` covers direct and nested npm layouts plus override preservation. | +| Raw Cursor SDK `AbortError` containment | **Implemented** | `src/cursor-sdk-process-error-guard.ts` suppresses only active-turn abort errors with Cursor SDK stack provenance; focused process-guard tests cover DOMException/Error, uncaught/rejection, inactive, and provenance-negative paths. | +| Bounded bridge `CallTool` waits | **Implemented** | `src/cursor-pi-tool-bridge-run.ts` caps pending calls at the effective MCP tool timeout with a lower-only override, removes expired requests, and aborts active Pi execution; `test/cursor-pi-tool-bridge-call-timeout.test.ts` covers timeout/cancel lifecycle. | +| Optional Cursor question prompt | **Implemented** | `PI_CURSOR_ASK_QUESTION=0` removes only `cursor_ask_question` while preserving the rest of the bridge; `src/cursor-question-tool.ts` and registration/bridge tests own the contract. | | Automatic local force recovery | **Intentionally deferred/rejected** | Manual `--cursor-local-force` is implemented. Automatic recovery lacks Pi ownership, heartbeat/staleness proof, active-run status, stable idempotency, and cross-handle cleanup guarantees. | | Feeding `RunResult.usage` into Pi message/context accounting | **Intentionally deferred/rejected** | Real local evidence showed full-agent-context values that poison compaction. Pi uses valid `turn-ended` usage or bounded estimates; coverage is in usage/provider tests and retained local compaction evidence. | diff --git a/docs/platform-smoke.md b/docs/platform-smoke.md index c01bf63..4a01ee6 100644 --- a/docs/platform-smoke.md +++ b/docs/platform-smoke.md @@ -116,6 +116,7 @@ start target session sync checkout once into extensionSourceRoot run platform-build run cursor-native-visual-matrix + run cursor-http1-live run cursor-bridge-visual-matrix run cursor-abort-cleanup run cursor-local-resume-restart @@ -142,7 +143,7 @@ Runtime budget is part of the contract: - `platform-build` runs once per target and is the only suite that performs the full local CI/build/typecheck/package gate. - Live suites reuse the target checkout and prepared `node_modules` when run after `platform-build`; they do not repeat `npm ci` in a target-session release run. - Live and local-resume suites share one target-local packed-install prep directory per target-session release run. The first such suite runs `npm pack` and `npm install --no-save ` once. Visual/abort suites install that packed path with `pi install --approve -l`; local-resume lanes pass the same packed package path to their source-tree smoke harness instead of loading the checkout extension. -- Visual coverage is batched into one native prompt, one bridge prompt, and one abort/cleanup prompt per target. Do not split these into one prompt per card. +- Visual coverage is batched into one native prompt, one focused HTTP/1.1 transport prompt, one bridge prompt, and one abort/cleanup prompt per target. Do not split the card matrices into one prompt per card. - The gate is fail-fast by target to avoid burning Cursor calls after a platform has already failed. ## Required targets @@ -298,6 +299,7 @@ export default { requiredSuites: [ "platform-build", "cursor-native-visual-matrix", + "cursor-http1-live", "cursor-bridge-visual-matrix", "cursor-abort-cleanup", ...LOCAL_RESUME_SUITE_NAMES, @@ -617,6 +619,32 @@ Required JSONL evidence: - final assistant message's last non-empty `text` part contains `NATIVE_MATRIX_OK`; - assistant usage fields are non-negative. +### `cursor-http1-live` + +Cursor calls: `1`. + +Required environment: + +```text +PI_CURSOR_SETTING_SOURCES=none +PI_CURSOR_HTTP_1_1=1 +PI_CURSOR_NATIVE_TOOL_DISPLAY=1 +PI_CURSOR_REGISTER_NATIVE_TOOLS=1 +PI_CURSOR_PI_TOOL_BRIDGE=0 +PI_CURSOR_EXPOSE_BUILTIN_TOOLS=0 +PI_CURSOR_SDK_EVENT_DEBUG=1 +``` + +Purpose: + +- prove the packed extension completes a real local provider turn through the SDK's opt-in HTTP/1.1/SSE transport on every required OS; +- prove the final TUI status visibly includes `cursor:local ... http1`; +- keep the default transport covered by the other required live suites. + +Required final marker: `HTTP1_LIVE_OK`. + +Required visual evidence: `http1-status`. + ### `cursor-bridge-visual-matrix` Cursor calls: `1`. @@ -751,6 +779,7 @@ Per target maximum live Cursor invocations: ```text cursor-native-visual-matrix: 1 +cursor-http1-live: 1 cursor-bridge-visual-matrix: 1 cursor-abort-cleanup: 1 cursor-local-resume-restart: 2 @@ -765,9 +794,9 @@ cursor-local-resume-default-dry-run: 3 cursor-local-resume-cleanup: 4 ``` -Maximum per target: `36` Cursor invocations. +Maximum per target: `37` Cursor invocations. -Maximum full gate: `108` Cursor invocations. +Maximum full gate: `111` Cursor invocations. The merge gate is `npm run smoke:platform:all`; that script runs doctor first and then the matrix to preserve this budget. No suite adds a new Cursor invocation without updating this plan and the scenario source of truth (`scripts/platform-smoke/scenarios.mjs`, plus `scripts/platform-smoke/local-resume-suites.mjs` for local-resume lanes). diff --git a/package-lock.json b/package-lock.json index 5f4815b..f1925dc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "pi-cursor-sdk", - "version": "0.1.60", + "version": "0.1.61", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pi-cursor-sdk", - "version": "0.1.60", + "version": "0.1.61", "license": "MIT", "dependencies": { "@cursor/sdk": "1.0.23", @@ -3680,20 +3680,20 @@ } }, "node_modules/body-parser": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", - "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", "license": "MIT", "dependencies": { "bytes": "^3.1.2", - "content-type": "^1.0.5", + "content-type": "^2.0.0", "debug": "^4.4.3", - "http-errors": "^2.0.0", - "iconv-lite": "^0.7.0", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", - "qs": "^6.14.1", - "raw-body": "^3.0.1", - "type-is": "^2.0.1" + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" }, "engines": { "node": ">=18" @@ -3703,6 +3703,19 @@ "url": "https://opencollective.com/express" } }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/bowser": { "version": "2.14.1", "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", @@ -4117,9 +4130,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", - "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", + "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", "funding": [ { "type": "github", @@ -4434,9 +4447,9 @@ } }, "node_modules/hono": { - "version": "4.12.26", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.26.tgz", - "integrity": "sha512-uyZtpnYxM9CmQ7QsQknM4zN8EftNqhON1qYeIKM0Se67CCEe2c44xyGURwB0axX2fBDu1dqHrHAc1hmNT8ITkw==", + "version": "4.12.31", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.31.tgz", + "integrity": "sha512-zJIHFrl6bq3RDd2YusFNCDlM8qUprxKswyi/OPzPyzKDdyBXDqWx8bZlZ7R+saTdSTatUmb3O7K4SspGPaEOQg==", "license": "MIT", "engines": { "node": ">=16.9.0" diff --git a/package.json b/package.json index 05a9b18..9afb555 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pi-cursor-sdk", - "version": "0.1.60", + "version": "0.1.61", "description": "pi provider extension backed by @cursor/sdk local and cloud agents", "author": "Mitch Fultz (https://github.com/fitchmultz)", "license": "MIT", @@ -65,12 +65,17 @@ "scripts/platform-smoke/pty-capture.mjs", "scripts/platform-smoke/render-ansi.mjs", "scripts/platform-smoke/scenarios.mjs", + "scripts/platform-smoke/scenarios.d.mts", "scripts/platform-smoke/target-runtime.mjs", "scripts/platform-smoke/targets.mjs", "scripts/platform-smoke/visual-evidence.mjs", "scripts/platform-smoke/wrapped-line-match.mjs", "scripts/lib/cloud-smoke-cleanup-evidence.mjs", "scripts/lib/cloud-smoke-cleanup-evidence.d.mts", + "scripts/lib/cloud-smoke-artifacts.mjs", + "scripts/lib/cloud-smoke-artifacts.d.mts", + "scripts/lib/cloud-smoke-pi-runner.mjs", + "scripts/lib/cloud-smoke-pi-runner.d.mts", "scripts/lib/cloud-smoke-github.mjs", "scripts/lib/cloud-smoke-github.d.mts", "scripts/lib/cloud-smoke-shutdown.mjs", @@ -138,7 +143,7 @@ "debug:sdk-events": "node scripts/debug-sdk-events.mjs", "debug:provider-events": "node scripts/debug-provider-events.mjs", "debug:mcp-coldstart": "node scripts/probe-mcp-coldstart.mjs", - "check:platform-smoke": "node --check platform-smoke.config.mjs && node --check scripts/cloud-runtime-smoke.mjs && node --check scripts/lib/cloud-smoke-cleanup-evidence.mjs && node --check scripts/lib/cloud-smoke-github.mjs && node --check scripts/lib/cloud-smoke-shutdown.mjs && node --check scripts/local-resume-cleanup-smoke.mjs && node --check scripts/local-resume-smoke.mjs && node --check scripts/lib/local-resume-smoke-harness.mjs && node --check scripts/platform-smoke.mjs && node --check scripts/platform-smoke/artifact-bundle-chunk.mjs && node --check scripts/platform-smoke/artifact-bundle-contract.mjs && node --check scripts/platform-smoke/artifact-fs-safety.mjs && node --check scripts/platform-smoke/artifact-anchored-extract.mjs && node --check scripts/platform-smoke/artifact-secrets.mjs && node --check scripts/platform-smoke/assertions.mjs && node --check scripts/platform-smoke/artifacts.mjs && node --check scripts/platform-smoke/card-detect.mjs && node --check scripts/platform-smoke/crabbox-runner.mjs && node --check scripts/platform-smoke/doctor.mjs && node --check scripts/platform-smoke/jsonl-text.mjs && node --check scripts/platform-smoke/live-suite-runner.mjs && node --check scripts/platform-smoke/local-resume-runner.mjs && node --check scripts/platform-smoke/pty-capture.mjs && node --check scripts/platform-smoke/render-ansi.mjs && node --check scripts/platform-smoke/scenarios.mjs && node --check scripts/platform-smoke/target-runtime.mjs && node --check scripts/platform-smoke/targets.mjs && node --check scripts/platform-smoke/visual-evidence.mjs && node --check scripts/platform-smoke/wrapped-line-match.mjs && vitest run test/platform-artifact-boundaries.test.ts test/platform-smoke-artifact-transport.test.ts test/cloud-smoke-helpers.test.ts test/cursor-sdk-cloud-list-runs-contract.test.ts test/smoke-cli-package-contracts.test.ts test/smoke-tooling.test.ts", + "check:platform-smoke": "node --check platform-smoke.config.mjs && node --check scripts/cloud-runtime-smoke.mjs && node --check scripts/lib/cloud-smoke-cleanup-evidence.mjs && node --check scripts/lib/cloud-smoke-artifacts.mjs && node --check scripts/lib/cloud-smoke-pi-runner.mjs && node --check scripts/lib/cloud-smoke-github.mjs && node --check scripts/lib/cloud-smoke-shutdown.mjs && node --check scripts/local-resume-cleanup-smoke.mjs && node --check scripts/local-resume-smoke.mjs && node --check scripts/lib/local-resume-smoke-harness.mjs && node --check scripts/platform-smoke.mjs && node --check scripts/platform-smoke/artifact-bundle-chunk.mjs && node --check scripts/platform-smoke/artifact-bundle-contract.mjs && node --check scripts/platform-smoke/artifact-fs-safety.mjs && node --check scripts/platform-smoke/artifact-anchored-extract.mjs && node --check scripts/platform-smoke/artifact-secrets.mjs && node --check scripts/platform-smoke/assertions.mjs && node --check scripts/platform-smoke/artifacts.mjs && node --check scripts/platform-smoke/card-detect.mjs && node --check scripts/platform-smoke/crabbox-runner.mjs && node --check scripts/platform-smoke/doctor.mjs && node --check scripts/platform-smoke/jsonl-text.mjs && node --check scripts/platform-smoke/live-suite-runner.mjs && node --check scripts/platform-smoke/local-resume-runner.mjs && node --check scripts/platform-smoke/pty-capture.mjs && node --check scripts/platform-smoke/render-ansi.mjs && node --check scripts/platform-smoke/scenarios.mjs && node --check scripts/platform-smoke/target-runtime.mjs && node --check scripts/platform-smoke/targets.mjs && node --check scripts/platform-smoke/visual-evidence.mjs && node --check scripts/platform-smoke/wrapped-line-match.mjs && vitest run test/platform-artifact-boundaries.test.ts test/platform-smoke-artifact-transport.test.ts test/cloud-smoke-helpers.test.ts test/cursor-sdk-cloud-list-runs-contract.test.ts test/smoke-cli-package-contracts.test.ts test/smoke-tooling.test.ts", "smoke:platform": "node scripts/platform-smoke.mjs", "smoke:platform:doctor": "node scripts/platform-smoke.mjs doctor", "smoke:platform:macos": "node scripts/platform-smoke.mjs run --target macos", diff --git a/platform-smoke.config.mjs b/platform-smoke.config.mjs index b0b5ac4..cd47e3c 100644 --- a/platform-smoke.config.mjs +++ b/platform-smoke.config.mjs @@ -16,6 +16,7 @@ export default { requiredSuites: [ "platform-build", "cursor-native-visual-matrix", + "cursor-http1-live", "cursor-bridge-visual-matrix", "cursor-abort-cleanup", ...LOCAL_RESUME_SUITE_NAMES, diff --git a/scripts/cloud-runtime-smoke.mjs b/scripts/cloud-runtime-smoke.mjs index ef65c37..4e74b76 100755 --- a/scripts/cloud-runtime-smoke.mjs +++ b/scripts/cloud-runtime-smoke.mjs @@ -1,33 +1,20 @@ #!/usr/bin/env node -import { spawn } from "node:child_process"; import { - existsSync, mkdirSync, mkdtempSync, - readFileSync, - readdirSync, renameSync, rmSync, - statSync, writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; -import { - CHILD_PROCESS_TREE_SPAWN_OPTIONS, - terminateChild, -} from "./lib/cursor-child-process.mjs"; +import { terminateChild } from "./lib/cursor-child-process.mjs"; import { buildCursorSmokeEnv } from "./lib/cursor-smoke-env.mjs"; import { - awaitCloudSmokeShutdown, checkpointCloudSmokeShutdown, createCloudSmokeShutdownController, - createCloudSmokeTerminalFailureState, - installCloudSmokeChildErrorHandlers, installCloudSmokeSignalHandlers, - routeCloudSmokeChildClose, - stopCloudSmokeTrackedChild, } from "./lib/cloud-smoke-shutdown.mjs"; import { assertAgentDeleted, @@ -49,11 +36,16 @@ import { runTimedCommand, validatePrUrl, } from "./lib/cloud-smoke-github.mjs"; +import { CLOUD_AGENT_ID_PATTERN } from "../shared/cursor-cloud-lifecycle-constants.mjs"; import { - CLOUD_AGENT_ID_PATTERN, - CLOUD_LIFECYCLE_ENTRY_TYPE, - CLOUD_LIFECYCLE_JOURNAL_PREFIX, -} from "../shared/cursor-cloud-lifecycle-constants.mjs"; + cloudAgentIdsFromLifecycleArtifacts, + cloudAgentIdsFromMetadata, + cloudLifecycleRecords, + readCloudRunReport, + readCloudSmokeMetadata, + readLatestCloudSmokeMetadata, +} from "./lib/cloud-smoke-artifacts.mjs"; +import { createCloudSmokePiRunner } from "./lib/cloud-smoke-pi-runner.mjs"; import { scrubSensitiveText } from "../shared/cursor-sensitive-text.mjs"; const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); @@ -141,11 +133,6 @@ function reportFailure(error) { else if (error?.details) console.error(scrubSmokeText(error.details)); } -function findPiBin() { - const local = join(root, "node_modules", ".bin", process.platform === "win32" ? "pi.cmd" : "pi"); - return existsSync(local) ? local : process.platform === "win32" ? "pi.cmd" : "pi"; -} - function optionalSmokeValue(name) { const value = process.env[name]?.trim(); return value || undefined; @@ -185,6 +172,7 @@ export { assertOwnedThrowawayRepositoryHandle, buildCloudSmokeEvidenceProvenance, cloudSmokeRepositoryDescription, + cloudAgentIdsFromLifecycleArtifacts, coordinateCloudSmokeReleaseGate, listCloudSmokePackageSourcePaths, normalizeCloudSmokeGitHubRepo, @@ -192,85 +180,13 @@ export { validateCloudSmokeMatrixEvidence, }; -function runPi({ artifactDir, envOptions = {}, message, sessionId, timeoutMs }) { - cloudSmokeShutdown.throwIfRequested(); - const sessionDir = join(artifactDir, "sessions"); - mkdirSync(sessionDir, { recursive: true }); - const child = spawn( - findPiBin(), - ["-e", root, "--model", MODEL, "--approve", "--session-dir", sessionDir, "--session-id", sessionId, "-p", message], - { - cwd: buildCloudSmokeWorkspace(artifactDir), - env: buildCloudSmokeEnv(artifactDir, envOptions), - stdio: ["ignore", "pipe", "pipe"], - ...CHILD_PROCESS_TREE_SPAWN_OPTIONS, - }, - ); - const tracking = cloudSmokeShutdown.track(child); - let stdout = ""; - let stderr = ""; - child.stdout.setEncoding("utf8"); - child.stderr.setEncoding("utf8"); - child.stdout.on("data", (chunk) => { stdout += chunk; }); - child.stderr.on("data", (chunk) => { stderr += chunk; }); - return new Promise((resolveRun, rejectRun) => { - let settled = false; - let timeoutStarted = false; - let timeoutTermination = Promise.resolve(); - const settle = (callback, value) => { - if (settled) return; - settled = true; - clearTimeout(timer); - cloudSmokeShutdown.signal.removeEventListener("abort", onShutdown); - callback(value); - }; - const onShutdown = () => { - const termination = Promise.allSettled([tracking, timeoutTermination]).then((results) => { - const failed = results.find((result) => result.status === "rejected"); - if (failed) throw failed.reason; - }); - void awaitCloudSmokeShutdown(cloudSmokeShutdown, termination).then((error) => settle(rejectRun, error)); - }; - const timer = setTimeout(() => { - if (cloudSmokeShutdown.signal.aborted) { - onShutdown(); - return; - } - timeoutStarted = true; - timeoutTermination = terminateChild(child); - void timeoutTermination.then( - () => { - if (cloudSmokeShutdown.signal.aborted) onShutdown(); - else settle(rejectRun, new Error(`pi cloud smoke timed out after ${timeoutMs}ms`)); - }, - (error) => { - if (cloudSmokeShutdown.signal.aborted) onShutdown(); - else settle(rejectRun, new Error(`pi cloud smoke timed out and cleanup failed: ${error instanceof Error ? error.message : String(error)}`, { cause: error })); - }, - ); - }, timeoutMs); - cloudSmokeShutdown.signal.addEventListener("abort", onShutdown, { once: true }); - if (cloudSmokeShutdown.signal.aborted) onShutdown(); - void tracking.catch((error) => { - if (cloudSmokeShutdown.signal.aborted) onShutdown(); - else settle(rejectRun, error); - }); - child.once("error", (error) => routeCloudSmokeChildClose( - cloudSmokeShutdown, - timeoutStarted, - onShutdown, - (failure) => settle(rejectRun, failure), - error, - )); - child.once("close", (code, signal) => routeCloudSmokeChildClose( - cloudSmokeShutdown, - timeoutStarted, - onShutdown, - (result) => settle(resolveRun, result), - { code, signal, stdout, stderr }, - )); - }); -} +const { runPi, startRpc } = createCloudSmokePiRunner({ + root, + model: MODEL, + shutdown: cloudSmokeShutdown, + buildEnv: buildCloudSmokeEnv, + buildWorkspace: buildCloudSmokeWorkspace, +}); function command(commandName, commandArgs, options = {}) { try { @@ -280,106 +196,11 @@ function command(commandName, commandArgs, options = {}) { } } -function walkFiles(dir, predicate) { - const files = []; - const stack = [dir]; - while (stack.length > 0) { - const current = stack.pop(); - try { - for (const entry of readdirSync(current, { withFileTypes: true })) { - const path = join(current, entry.name); - if (entry.isDirectory()) stack.push(path); - else if (predicate(entry.name)) files.push(path); - } - } catch {} - } - return files.sort((left, right) => statSync(left).mtimeMs - statSync(right).mtimeMs); -} - -function metadataFiles(dir) { - return walkFiles(dir, (name) => name === "metadata.json"); -} - -function readMetadata(artifactDir) { - return metadataFiles(artifactDir).flatMap((metadataPath) => { - try { - return [{ metadataPath, metadata: JSON.parse(readFileSync(metadataPath, "utf8")) }]; - } catch { - return []; - } - }); -} - -function readLatestMetadataIfPresent(artifactDir) { - return readMetadata(artifactDir).at(-1); -} - -function addExactCloudAgentId(ids, value) { - if (typeof value === "string" && CLOUD_AGENT_ID_PATTERN.test(value)) ids.add(value); -} - -function cloudAgentIdsFromMetadata(artifactDir) { - const ids = new Set(); - for (const { metadata } of readMetadata(artifactDir)) { - addExactCloudAgentId(ids, metadata.run?.agentId ?? metadata.providerMeta?.cloudAgentId); - } - return [...ids]; -} - -function lifecycleArtifactFiles(dir) { - return walkFiles(dir, (name) => name.endsWith(".jsonl") || (name.startsWith(`${CLOUD_LIFECYCLE_JOURNAL_PREFIX}-`) && name.endsWith(".journal"))); -} - -function cloudLifecycleRecords(artifactDir) { - const records = []; - for (const path of lifecycleArtifactFiles(artifactDir)) { - const journal = path.split(/[\\/]/).at(-1)?.startsWith(`${CLOUD_LIFECYCLE_JOURNAL_PREFIX}-`) === true; - let lines; - try { - lines = readFileSync(path, "utf8").split(/\r?\n/); - } catch { - continue; - } - for (const line of lines) { - if (!line) continue; - try { - const entry = JSON.parse(line); - const data = journal - ? entry - : entry?.type === "custom" && entry.customType === CLOUD_LIFECYCLE_ENTRY_TYPE - ? entry.data - : undefined; - if (data && CLOUD_AGENT_ID_PATTERN.test(data.agentId)) records.push({ path, data }); - } catch {} - } - } - return records; -} - -export function cloudAgentIdsFromLifecycleArtifacts(artifactDir) { - return [...new Set(cloudLifecycleRecords(artifactDir).map(({ data }) => data.agentId))]; -} - -function resolveMetadataArtifactPath(metadataPath, artifactPath) { - if (!artifactPath) return undefined; - return resolve(artifactPath) === artifactPath ? artifactPath : join(dirname(metadataPath), artifactPath); -} - -function readJsonlIfPresent(path) { - if (!path || !existsSync(path)) return []; - return readFileSync(path, "utf8").split(/\n+/).filter(Boolean).map((line) => JSON.parse(line)); -} - -function cloudReport({ metadataPath, metadata }) { - const providerEventsPath = resolveMetadataArtifactPath(metadataPath, metadata.artifacts?.providerEvents); - return readJsonlIfPresent(providerEventsPath).find((event) => event.phase === "cloud_run_report")?.payload; -} - async function runSuccessfulPrintLane({ artifactDir, envOptions, message, marker, sessionId, timeoutMs }) { const run = await runPi({ artifactDir, envOptions, message, sessionId, timeoutMs }); if (run.code !== 0) fail(`pi cloud lane exited ${run.code}${run.signal ? ` (${run.signal})` : ""}`, `${run.stderr}\n${run.stdout}`.trim()); if (!run.stdout.includes(marker)) fail(`cloud lane output missing ${marker}`, `${run.stderr}\n${run.stdout}`.trim()); - const latest = readLatestMetadataIfPresent(artifactDir); + const latest = readLatestCloudSmokeMetadata(artifactDir); if (!latest) fail("cloud lane metadata missing", artifactDir); const report = assertCloudMetadata(latest.metadata, latest.metadataPath); const agentId = latest.metadata.run.agentId; @@ -395,7 +216,7 @@ function assertCloudMetadata(metadata, metadataPath, options = {}) { if (metadata.send?.agentMode !== "agent") fail("cloud send did not use agent mode", metadataPath); if (!CLOUD_AGENT_ID_PATTERN.test(metadata.run?.agentId ?? "")) fail("cloud run did not return an exact cloud agent id", metadataPath); if (!CLOUD_RUN_ID_PATTERN.test(metadata.run?.runId ?? "")) fail("cloud run did not return an exact run id", metadataPath); - const report = cloudReport({ metadataPath, metadata }); + const report = readCloudRunReport({ metadataPath, metadata }); if (!report && options.requireReport === false) return undefined; if (!report) fail("cloud report was not retained after the print-mode lane exited", metadataPath); if (report.agentId !== metadata.run.agentId || report.runId !== metadata.run.runId) fail("cloud report IDs did not match run metadata", metadataPath); @@ -429,129 +250,6 @@ function assertLaneEvidence(artifactDir, expectedAgentId, expectedRunId) { } } -async function startRpc({ artifactDir, contextHandoff = "fresh", sessionId, envOptions = {} }) { - cloudSmokeShutdown.throwIfRequested(); - const sessionDir = join(artifactDir, "sessions"); - mkdirSync(sessionDir, { recursive: true }); - const child = spawn( - findPiBin(), - ["--mode", "rpc", "-e", root, "--model", MODEL, "--approve", "--session-dir", sessionDir, "--session-id", sessionId], - { - cwd: buildCloudSmokeWorkspace(artifactDir), - env: buildCloudSmokeEnv(artifactDir, { contextHandoff, ...envOptions }), - stdio: ["pipe", "pipe", "pipe"], - ...CHILD_PROCESS_TREE_SPAWN_OPTIONS, - }, - ); - const tracking = cloudSmokeShutdown.track(child); - let stderr = ""; - const events = []; - const pending = new Map(); - let requestId = 0; - let stdoutBuffer = ""; - child.stdout.setEncoding("utf8"); - child.stderr.setEncoding("utf8"); - child.stderr.on("data", (chunk) => { stderr += chunk; }); - child.stdout.on("data", (chunk) => { - stdoutBuffer += chunk; - let newlineIndex; - while ((newlineIndex = stdoutBuffer.indexOf("\n")) >= 0) { - const line = stdoutBuffer.slice(0, newlineIndex); - stdoutBuffer = stdoutBuffer.slice(newlineIndex + 1); - if (!line.trim()) continue; - let message; - try { message = JSON.parse(line); } catch { continue; } - if (message.type === "response" && pending.has(message.id)) { - if (cloudSmokeShutdown.signal.aborted) { - rejectAfterShutdown(); - continue; - } - const request = pending.get(message.id); - pending.delete(message.id); - clearTimeout(request.timer); - request.resolve(message); - } else events.push(message); - } - }); - const rejectPending = (error) => { - for (const request of pending.values()) { - clearTimeout(request.timer); - request.reject(error); - } - pending.clear(); - }; - const terminalState = createCloudSmokeTerminalFailureState(rejectPending); - const rejectAfterShutdown = () => { - void awaitCloudSmokeShutdown(cloudSmokeShutdown, tracking).then(rejectPending); - }; - cloudSmokeShutdown.signal.addEventListener("abort", rejectAfterShutdown, { once: true }); - if (cloudSmokeShutdown.signal.aborted) rejectAfterShutdown(); - const routeRpcError = installCloudSmokeChildErrorHandlers( - child, - cloudSmokeShutdown, - rejectAfterShutdown, - terminalState.record, - ); - child.once("close", () => { - cloudSmokeShutdown.signal.removeEventListener("abort", rejectAfterShutdown); - if (cloudSmokeShutdown.signal.aborted) rejectAfterShutdown(); - else terminalState.record(new Error(`cloud smoke RPC exited. Stderr: ${stderr}`)); - }); - const send = (type, extra = {}, timeoutMs = 120000) => new Promise((resolveRequest, rejectRequest) => { - try { - cloudSmokeShutdown.throwIfRequested(); - terminalState.throwIfFailed(); - } catch (error) { - rejectRequest(error); - return; - } - const id = `cloud_smoke_${++requestId}`; - const timer = setTimeout(() => { - if (cloudSmokeShutdown.signal.aborted) { - rejectAfterShutdown(); - return; - } - pending.delete(id); - rejectRequest(new Error(`timeout waiting for ${type}. Stderr: ${stderr}`)); - }, timeoutMs); - pending.set(id, { resolve: resolveRequest, reject: rejectRequest, timer }); - try { - child.stdin.write(`${JSON.stringify({ id, type, ...extra })}\n`); - } catch (error) { - routeRpcError(error); - } - }); - const stop = async () => { - cloudSmokeShutdown.signal.removeEventListener("abort", rejectAfterShutdown); - if (!cloudSmokeShutdown.signal.aborted) terminalState.record(new Error("cloud smoke RPC stopped")); - try { - const shutdownReason = await stopCloudSmokeTrackedChild( - cloudSmokeShutdown, - tracking, - () => terminateChild(child, { graceMs: 15_000 }), - ); - if (shutdownReason) rejectPending(shutdownReason); - } catch (error) { - rejectPending(error); - throw error; - } - }; - try { - await tracking; - if (cloudSmokeShutdown.signal.aborted) throw await awaitCloudSmokeShutdown(cloudSmokeShutdown, tracking); - } catch (error) { - rejectPending(error); - throw error; - } - return { - events, - send, - stop, - throwIfFailed: terminalState.throwIfFailed, - get stderr() { return stderr; }, - }; -} - async function waitFor(predicate, timeoutMs, errorMessage) { const started = Date.now(); while (Date.now() - started < timeoutMs) { @@ -586,7 +284,7 @@ async function promptAndRead({ rpc, artifactDir, message, timeoutMs, expectedCon if (!response.success) fail("cloud prompt was rejected", response.error); await waitForAgentSettled(rpc, fromIndex, timeoutMs); const text = await readLastAssistantText(rpc); - const latest = readLatestMetadataIfPresent(artifactDir); + const latest = readLatestCloudSmokeMetadata(artifactDir); if (!latest) fail("cloud smoke metadata missing", artifactDir); const report = assertCloudMetadata(latest.metadata, latest.metadataPath, { requireReport: !expectedContextHandoff }); if (expectedContextHandoff && latest.metadata.providerMeta?.contextHandoff !== expectedContextHandoff) { @@ -726,7 +424,7 @@ async function runCancelLane({ artifactRoot, repo, timeoutMs, Agent }) { let nextRunProbeAt = 0; const identity = await waitFor(async () => { rpc.throwIfFailed(); - const latest = readLatestMetadataIfPresent(artifactDir); + const latest = readLatestCloudSmokeMetadata(artifactDir); const agentId = latest?.metadata.run?.agentId; if (!CLOUD_AGENT_ID_PATTERN.test(agentId ?? "")) return undefined; if (CLOUD_RUN_ID_PATTERN.test(latest.metadata.run?.runId ?? "")) { @@ -782,7 +480,7 @@ async function runMissingBranchLane({ artifactRoot, repo, timeoutMs }) { fail("missing branch did not fail closed with branch/ref evidence", failureOutput); } const agentIds = new Set(); - for (const { metadataPath, metadata } of readMetadata(artifactDir)) { + for (const { metadataPath, metadata } of readCloudSmokeMetadata(artifactDir)) { const agentId = metadata.run?.agentId ?? metadata.providerMeta?.cloudAgentId; const runId = metadata.run?.runId; if (agentId) { diff --git a/scripts/lib/cloud-smoke-artifacts.d.mts b/scripts/lib/cloud-smoke-artifacts.d.mts new file mode 100644 index 0000000..e3b8820 --- /dev/null +++ b/scripts/lib/cloud-smoke-artifacts.d.mts @@ -0,0 +1,16 @@ +export interface CloudSmokeMetadataRecord { + metadataPath: string; + metadata: Record; +} + +export interface CloudSmokeLifecycleRecord { + path: string; + data: Record & { agentId: string }; +} + +export function readCloudSmokeMetadata(artifactDir: string): CloudSmokeMetadataRecord[]; +export function readLatestCloudSmokeMetadata(artifactDir: string): CloudSmokeMetadataRecord | undefined; +export function cloudAgentIdsFromMetadata(artifactDir: string): string[]; +export function cloudLifecycleRecords(artifactDir: string): CloudSmokeLifecycleRecord[]; +export function cloudAgentIdsFromLifecycleArtifacts(artifactDir: string): string[]; +export function readCloudRunReport(input: CloudSmokeMetadataRecord): Record | undefined; diff --git a/scripts/lib/cloud-smoke-artifacts.mjs b/scripts/lib/cloud-smoke-artifacts.mjs new file mode 100644 index 0000000..4236f9a --- /dev/null +++ b/scripts/lib/cloud-smoke-artifacts.mjs @@ -0,0 +1,94 @@ +import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { + CLOUD_AGENT_ID_PATTERN, + CLOUD_LIFECYCLE_ENTRY_TYPE, + CLOUD_LIFECYCLE_JOURNAL_PREFIX, +} from "../../shared/cursor-cloud-lifecycle-constants.mjs"; + +function walkFiles(dir, predicate) { + const files = []; + const stack = [dir]; + while (stack.length > 0) { + const current = stack.pop(); + try { + for (const entry of readdirSync(current, { withFileTypes: true })) { + const path = join(current, entry.name); + if (entry.isDirectory()) stack.push(path); + else if (predicate(entry.name)) files.push(path); + } + } catch {} + } + return files.sort((left, right) => statSync(left).mtimeMs - statSync(right).mtimeMs); +} + +export function readCloudSmokeMetadata(artifactDir) { + return walkFiles(artifactDir, (name) => name === "metadata.json").flatMap((metadataPath) => { + try { + return [{ metadataPath, metadata: JSON.parse(readFileSync(metadataPath, "utf8")) }]; + } catch { + return []; + } + }); +} + +export function readLatestCloudSmokeMetadata(artifactDir) { + return readCloudSmokeMetadata(artifactDir).at(-1); +} + +export function cloudAgentIdsFromMetadata(artifactDir) { + const ids = new Set(); + for (const { metadata } of readCloudSmokeMetadata(artifactDir)) { + const value = metadata.run?.agentId ?? metadata.providerMeta?.cloudAgentId; + if (typeof value === "string" && CLOUD_AGENT_ID_PATTERN.test(value)) ids.add(value); + } + return [...ids]; +} + +export function cloudLifecycleRecords(artifactDir) { + const records = []; + const files = walkFiles( + artifactDir, + (name) => name.endsWith(".jsonl") || (name.startsWith(`${CLOUD_LIFECYCLE_JOURNAL_PREFIX}-`) && name.endsWith(".journal")), + ); + for (const path of files) { + const journal = path.split(/[\\/]/).at(-1)?.startsWith(`${CLOUD_LIFECYCLE_JOURNAL_PREFIX}-`) === true; + let lines; + try { + lines = readFileSync(path, "utf8").split(/\r?\n/); + } catch { + continue; + } + for (const line of lines) { + if (!line) continue; + try { + const entry = JSON.parse(line); + const data = journal + ? entry + : entry?.type === "custom" && entry.customType === CLOUD_LIFECYCLE_ENTRY_TYPE + ? entry.data + : undefined; + if (data && CLOUD_AGENT_ID_PATTERN.test(data.agentId)) records.push({ path, data }); + } catch {} + } + } + return records; +} + +export function cloudAgentIdsFromLifecycleArtifacts(artifactDir) { + return [...new Set(cloudLifecycleRecords(artifactDir).map(({ data }) => data.agentId))]; +} + +function readJsonlIfPresent(path) { + if (!path || !existsSync(path)) return []; + return readFileSync(path, "utf8").split(/\n+/).filter(Boolean).map((line) => JSON.parse(line)); +} + +export function readCloudRunReport({ metadataPath, metadata }) { + const artifactPath = metadata.artifacts?.providerEvents; + if (!artifactPath) return undefined; + const providerEventsPath = resolve(artifactPath) === artifactPath + ? artifactPath + : join(dirname(metadataPath), artifactPath); + return readJsonlIfPresent(providerEventsPath).find((event) => event.phase === "cloud_run_report")?.payload; +} diff --git a/scripts/lib/cloud-smoke-pi-runner.d.mts b/scripts/lib/cloud-smoke-pi-runner.d.mts new file mode 100644 index 0000000..88f5f63 --- /dev/null +++ b/scripts/lib/cloud-smoke-pi-runner.d.mts @@ -0,0 +1,42 @@ +import type { CloudSmokeShutdownController } from "./cloud-smoke-shutdown.mjs"; + +interface CloudSmokeRpcEvent { + type: string; + [key: string]: unknown; +} + +interface CloudSmokeRpcResponse extends CloudSmokeRpcEvent { + type: "response"; + id: string; + success: boolean; + data?: { text?: string; [key: string]: unknown }; + error?: unknown; +} + +export function createCloudSmokePiRunner(options: { + root: string; + model: string; + shutdown: CloudSmokeShutdownController; + buildEnv(artifactDir: string, options?: Record): NodeJS.ProcessEnv; + buildWorkspace(artifactDir: string): string; +}): { + runPi(options: { + artifactDir: string; + envOptions?: Record; + message: string; + sessionId: string; + timeoutMs: number; + }): Promise<{ code: number | null; signal: NodeJS.Signals | null; stdout: string; stderr: string }>; + startRpc(options: { + artifactDir: string; + contextHandoff?: string; + sessionId: string; + envOptions?: Record; + }): Promise<{ + events: CloudSmokeRpcEvent[]; + send(type: string, extra?: Record, timeoutMs?: number): Promise; + stop(): Promise; + throwIfFailed(): void; + readonly stderr: string; + }>; +}; diff --git a/scripts/lib/cloud-smoke-pi-runner.mjs b/scripts/lib/cloud-smoke-pi-runner.mjs new file mode 100644 index 0000000..7309ba1 --- /dev/null +++ b/scripts/lib/cloud-smoke-pi-runner.mjs @@ -0,0 +1,214 @@ +import { spawn } from "node:child_process"; +import { existsSync, mkdirSync } from "node:fs"; +import { join } from "node:path"; +import { + CHILD_PROCESS_TREE_SPAWN_OPTIONS, + terminateChild, +} from "./cursor-child-process.mjs"; +import { + awaitCloudSmokeShutdown, + createCloudSmokeTerminalFailureState, + installCloudSmokeChildErrorHandlers, + routeCloudSmokeChildClose, + stopCloudSmokeTrackedChild, +} from "./cloud-smoke-shutdown.mjs"; + +export function createCloudSmokePiRunner({ root, model, shutdown, buildEnv, buildWorkspace }) { + const findPiBin = () => { + const local = join(root, "node_modules", ".bin", process.platform === "win32" ? "pi.cmd" : "pi"); + return existsSync(local) ? local : process.platform === "win32" ? "pi.cmd" : "pi"; + }; + + const spawnPi = (artifactDir, args, envOptions, stdio) => { + const sessionDir = join(artifactDir, "sessions"); + mkdirSync(sessionDir, { recursive: true }); + return spawn(findPiBin(), args(sessionDir), { + cwd: buildWorkspace(artifactDir), + env: buildEnv(artifactDir, envOptions), + stdio, + ...CHILD_PROCESS_TREE_SPAWN_OPTIONS, + }); + }; + + const runPi = ({ artifactDir, envOptions = {}, message, sessionId, timeoutMs }) => { + shutdown.throwIfRequested(); + const child = spawnPi( + artifactDir, + (sessionDir) => ["-e", root, "--model", model, "--approve", "--session-dir", sessionDir, "--session-id", sessionId, "-p", message], + envOptions, + ["ignore", "pipe", "pipe"], + ); + const tracking = shutdown.track(child); + let stdout = ""; + let stderr = ""; + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk) => { stdout += chunk; }); + child.stderr.on("data", (chunk) => { stderr += chunk; }); + return new Promise((resolveRun, rejectRun) => { + let settled = false; + let timeoutStarted = false; + let timeoutTermination = Promise.resolve(); + const settle = (callback, value) => { + if (settled) return; + settled = true; + clearTimeout(timer); + shutdown.signal.removeEventListener("abort", onShutdown); + callback(value); + }; + const onShutdown = () => { + const termination = Promise.allSettled([tracking, timeoutTermination]).then((results) => { + const failed = results.find((result) => result.status === "rejected"); + if (failed) throw failed.reason; + }); + void awaitCloudSmokeShutdown(shutdown, termination).then((error) => settle(rejectRun, error)); + }; + const timer = setTimeout(() => { + if (shutdown.signal.aborted) return onShutdown(); + timeoutStarted = true; + timeoutTermination = terminateChild(child); + void timeoutTermination.then( + () => shutdown.signal.aborted + ? onShutdown() + : settle(rejectRun, new Error(`pi cloud smoke timed out after ${timeoutMs}ms`)), + (error) => shutdown.signal.aborted + ? onShutdown() + : settle(rejectRun, new Error(`pi cloud smoke timed out and cleanup failed: ${error instanceof Error ? error.message : String(error)}`, { cause: error })), + ); + }, timeoutMs); + shutdown.signal.addEventListener("abort", onShutdown, { once: true }); + if (shutdown.signal.aborted) onShutdown(); + void tracking.catch((error) => shutdown.signal.aborted ? onShutdown() : settle(rejectRun, error)); + child.once("error", (error) => routeCloudSmokeChildClose( + shutdown, + timeoutStarted, + onShutdown, + (failure) => settle(rejectRun, failure), + error, + )); + child.once("close", (code, signal) => routeCloudSmokeChildClose( + shutdown, + timeoutStarted, + onShutdown, + (result) => settle(resolveRun, result), + { code, signal, stdout, stderr }, + )); + }); + }; + + const startRpc = async ({ artifactDir, contextHandoff = "fresh", sessionId, envOptions = {} }) => { + shutdown.throwIfRequested(); + const child = spawnPi( + artifactDir, + (sessionDir) => ["--mode", "rpc", "-e", root, "--model", model, "--approve", "--session-dir", sessionDir, "--session-id", sessionId], + { contextHandoff, ...envOptions }, + ["pipe", "pipe", "pipe"], + ); + const tracking = shutdown.track(child); + let stderr = ""; + const events = []; + const pending = new Map(); + let requestId = 0; + let stdoutBuffer = ""; + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stderr.on("data", (chunk) => { stderr += chunk; }); + child.stdout.on("data", (chunk) => { + stdoutBuffer += chunk; + let newlineIndex; + while ((newlineIndex = stdoutBuffer.indexOf("\n")) >= 0) { + const line = stdoutBuffer.slice(0, newlineIndex); + stdoutBuffer = stdoutBuffer.slice(newlineIndex + 1); + if (!line.trim()) continue; + let message; + try { message = JSON.parse(line); } catch { continue; } + if (message.type === "response" && pending.has(message.id)) { + if (shutdown.signal.aborted) { + rejectAfterShutdown(); + continue; + } + const request = pending.get(message.id); + pending.delete(message.id); + clearTimeout(request.timer); + request.resolve(message); + } else events.push(message); + } + }); + const rejectPending = (error) => { + for (const request of pending.values()) { + clearTimeout(request.timer); + request.reject(error); + } + pending.clear(); + }; + const terminalState = createCloudSmokeTerminalFailureState(rejectPending); + const rejectAfterShutdown = () => { + void awaitCloudSmokeShutdown(shutdown, tracking).then(rejectPending); + }; + shutdown.signal.addEventListener("abort", rejectAfterShutdown, { once: true }); + if (shutdown.signal.aborted) rejectAfterShutdown(); + const routeRpcError = installCloudSmokeChildErrorHandlers( + child, + shutdown, + rejectAfterShutdown, + terminalState.record, + ); + child.once("close", () => { + shutdown.signal.removeEventListener("abort", rejectAfterShutdown); + if (shutdown.signal.aborted) rejectAfterShutdown(); + else terminalState.record(new Error(`cloud smoke RPC exited. Stderr: ${stderr}`)); + }); + const send = (type, extra = {}, timeoutMs = 120000) => new Promise((resolveRequest, rejectRequest) => { + try { + shutdown.throwIfRequested(); + terminalState.throwIfFailed(); + } catch (error) { + rejectRequest(error); + return; + } + const id = `cloud_smoke_${++requestId}`; + const timer = setTimeout(() => { + if (shutdown.signal.aborted) return rejectAfterShutdown(); + pending.delete(id); + rejectRequest(new Error(`timeout waiting for ${type}. Stderr: ${stderr}`)); + }, timeoutMs); + pending.set(id, { resolve: resolveRequest, reject: rejectRequest, timer }); + try { + child.stdin.write(`${JSON.stringify({ id, type, ...extra })}\n`); + } catch (error) { + routeRpcError(error); + } + }); + const stop = async () => { + shutdown.signal.removeEventListener("abort", rejectAfterShutdown); + if (!shutdown.signal.aborted) terminalState.record(new Error("cloud smoke RPC stopped")); + try { + const shutdownReason = await stopCloudSmokeTrackedChild( + shutdown, + tracking, + () => terminateChild(child, { graceMs: 15_000 }), + ); + if (shutdownReason) rejectPending(shutdownReason); + } catch (error) { + rejectPending(error); + throw error; + } + }; + try { + await tracking; + if (shutdown.signal.aborted) throw await awaitCloudSmokeShutdown(shutdown, tracking); + } catch (error) { + rejectPending(error); + throw error; + } + return { + events, + send, + stop, + throwIfFailed: terminalState.throwIfFailed, + get stderr() { return stderr; }, + }; + }; + + return { runPi, startRpc }; +} diff --git a/scripts/platform-smoke/card-detect.mjs b/scripts/platform-smoke/card-detect.mjs index bba75f1..7f61ccb 100644 --- a/scripts/platform-smoke/card-detect.mjs +++ b/scripts/platform-smoke/card-detect.mjs @@ -30,6 +30,7 @@ const CARD_PATTERNS = [ }, { id: "bridge-read-failure", pattern: /^\s*(?:read \.\/definitely-missing-platform-smoke-file\.txt|ENOENT: no such file)\s*/i }, { id: "bridge-shell-success", pattern: /^\s*bridge visual smoke\s*$/i }, + { id: "http1-status", pattern: /\bcursor:local\b.*\bhttp1\b/i }, { id: "footer-status", pattern: /\bcomposer-2-5\b|\bcomposer-2\.5\b/i }, ]; diff --git a/scripts/platform-smoke/scenarios.d.mts b/scripts/platform-smoke/scenarios.d.mts new file mode 100644 index 0000000..a84b026 --- /dev/null +++ b/scripts/platform-smoke/scenarios.d.mts @@ -0,0 +1,22 @@ +interface PlatformSmokeScenario { + description: string; + cursorCalls: number; + env?: Record; + commands?: Record; + promptTemplate?: string; + finalMarker?: string | null; + requiredCards?: string[]; + requiredJSONLTools?: Array<{ name: string }>; + requiredJSONLResults?: Array>; + expectedJSONLResultToolOrder?: string[]; + visualEvidence?: Array>; + requiredBridgeDiagnostics?: boolean | "abort"; +} + +interface PlatformSmokePromptScenario extends PlatformSmokeScenario { + promptTemplate: string; +} + +export const SCENARIOS: Record; +export function getScenario(name: string): PlatformSmokeScenario | null; +export function renderPrompt(scenario: PlatformSmokePromptScenario, platform: string): string; diff --git a/scripts/platform-smoke/scenarios.mjs b/scripts/platform-smoke/scenarios.mjs index dc37dd8..c468d85 100644 --- a/scripts/platform-smoke/scenarios.mjs +++ b/scripts/platform-smoke/scenarios.mjs @@ -96,6 +96,23 @@ NATIVE_MATRIX_OK package= grep= find= list= shell= ], }, + "cursor-http1-live": { + description: "Prove an opt-in HTTP/1.1/SSE local provider turn and visible transport status.", + cursorCalls: 1, + env: { + PI_CURSOR_SETTING_SOURCES: "none", + PI_CURSOR_HTTP_1_1: "1", + PI_CURSOR_NATIVE_TOOL_DISPLAY: "1", + PI_CURSOR_REGISTER_NATIVE_TOOLS: "1", + PI_CURSOR_PI_TOOL_BRIDGE: "0", + PI_CURSOR_EXPOSE_BUILTIN_TOOLS: "0", + PI_CURSOR_SDK_EVENT_DEBUG: "1", + }, + promptTemplate: "Reply exactly HTTP1_LIVE_OK.", + finalMarker: "HTTP1_LIVE_OK", + requiredCards: ["http1-status"], + }, + "cursor-bridge-visual-matrix": { description: "Prove pi bridge routing, bridge tool cards, diagnostics, real pi tool names.", cursorCalls: 1, diff --git a/scripts/platform-smoke/targets.mjs b/scripts/platform-smoke/targets.mjs index 6d43c8d..caba2c2 100644 --- a/scripts/platform-smoke/targets.mjs +++ b/scripts/platform-smoke/targets.mjs @@ -119,6 +119,7 @@ export async function runTargetSuite(config, targetName, suiteName, leaseSession case "platform-build": return await executePlatformBuild(config, targetName, suiteDir, slug, platform, leaseSession); case "cursor-native-visual-matrix": + case "cursor-http1-live": case "cursor-bridge-visual-matrix": case "cursor-abort-cleanup": return await executeLiveSuite(config, targetName, suiteName, suiteDir, slug, leaseSession); diff --git a/src/cursor-runtime-state.ts b/src/cursor-runtime-state.ts index 753e9e5..a6032ac 100644 --- a/src/cursor-runtime-state.ts +++ b/src/cursor-runtime-state.ts @@ -25,7 +25,6 @@ import { getCursorSdkUserConfigPath, loadCursorSdkConfig, mergeCursorSdkConfigForUpdate, - parseCursorSdkConfig, parseExplicitCursorCloudEnvNames, resolveCursorSdkConfig, updateCursorSdkConfig, @@ -64,26 +63,14 @@ type CursorRuntimeContext = Pick; type CursorStatusRefresh = (ctx: ExtensionContext) => void; -let cliAutoReview = false; -let cliSandbox = false; -let cliLocalForce = false; +interface CursorCliConfigSnapshot { + config: CursorExplicitSdkConfig; + cloudEnvNames?: string; +} + +let cliCursorSnapshot: CursorCliConfigSnapshot = { config: {} }; let cliLocalForceConsumed = false; -let cliLocalResume = false; -let cliNoLocalResume = false; let envLocalForceConsumed = false; -let cliCursorRuntime: string | undefined; -let cliCursorCloudRepo: string | undefined; -let cliCursorCloudBranch: string | undefined; -let cliCursorCloudContext: string | undefined; -let cliCursorCloudDirectPush = false; -let cliCursorCloudAutoCreatePR = false; -let cliCursorCloudSkipReviewerRequest = false; -let cliCursorCloudAllowLocalState = false; -let cliCursorCloudEnv: string | undefined; -let cliCursorCloudEnvFromFiles = false; -let cliCursorCloudEnvType: string | undefined; -let cliCursorCloudEnvName: string | undefined; -let cliCursorCloudAck = false; let sessionCursorRuntime: CursorRuntime | undefined; let sessionCursorCloudAcknowledged = false; @@ -99,36 +86,10 @@ function stringFlagValue(value: boolean | string | undefined): string | undefine } export function getCursorCliConfig(): CursorExplicitSdkConfig { - const parsed = parseCursorSdkConfig({ - cloud: { - repo: cliCursorCloudRepo, - branch: cliCursorCloudBranch, - ...(cliCursorCloudDirectPush ? { directPush: true } : {}), - ...(cliCursorCloudAutoCreatePR ? { autoCreatePR: true } : {}), - ...(cliCursorCloudSkipReviewerRequest ? { skipReviewerRequest: true } : {}), - ...(cliCursorCloudAllowLocalState ? { allowLocalState: true } : {}), - envNames: parseExplicitCursorCloudEnvNames(cliCursorCloudEnv, "--cursor-cloud-env"), - ...(cliCursorCloudEnvFromFiles ? { envFromFiles: true } : {}), - environment: { - type: cliCursorCloudEnvType, - name: cliCursorCloudEnvName, - }, - ...(cliCursorCloudAck ? { acknowledged: true } : {}), - }, - local: { - ...(cliAutoReview ? { autoReview: true } : {}), - ...(cliSandbox ? { sandboxOptions: { enabled: true } } : {}), - ...(cliLocalForce ? { force: true } : {}), - ...(cliNoLocalResume ? { resume: false } : cliLocalResume ? { resume: true } : {}), - }, - }) ?? {}; - return { - ...parsed, - ...(cliCursorRuntime ? { runtime: cliCursorRuntime } : {}), - ...(cliCursorCloudContext - ? { cloud: { ...parsed.cloud, contextHandoff: cliCursorCloudContext } } - : {}), - }; + const config = structuredClone(cliCursorSnapshot.config); + const envNames = parseExplicitCursorCloudEnvNames(cliCursorSnapshot.cloudEnvNames, "--cursor-cloud-env"); + if (envNames) config.cloud = { ...config.cloud, envNames }; + return config; } export function getCursorSessionConfig(): CursorSdkConfig { @@ -208,7 +169,10 @@ export function formatCursorStatus( export function consumeCursorLocalForceOverride(resolved: { value: boolean; source: string }): boolean { if (!resolved.value) return false; if (resolved.source === "cli" && !cliLocalForceConsumed) { - cliLocalForce = false; + if (cliCursorSnapshot.config.local) { + const { force: _, ...local } = cliCursorSnapshot.config.local; + cliCursorSnapshot.config.local = local; + } cliLocalForceConsumed = true; return true; } @@ -232,24 +196,45 @@ export function restoreSessionCursorRuntimeState(branch: readonly SessionEntry[] } export function restoreCursorCliState(pi: Pick): void { - cliAutoReview = pi.getFlag("cursor-auto-review") === true; - cliSandbox = pi.getFlag("cursor-sandbox") === true; - cliLocalForce = !cliLocalForceConsumed && pi.getFlag("cursor-local-force") === true; - cliLocalResume = pi.getFlag("cursor-local-resume") === true; - cliNoLocalResume = pi.getFlag("cursor-no-local-resume") === true; - cliCursorRuntime = stringFlagValue(pi.getFlag("cursor-runtime")); - cliCursorCloudRepo = stringFlagValue(pi.getFlag("cursor-cloud-repo")); - cliCursorCloudBranch = stringFlagValue(pi.getFlag("cursor-cloud-branch")); - cliCursorCloudContext = stringFlagValue(pi.getFlag("cursor-cloud-context")); - cliCursorCloudDirectPush = pi.getFlag("cursor-cloud-direct-push") === true; - cliCursorCloudAutoCreatePR = pi.getFlag("cursor-cloud-auto-create-pr") === true; - cliCursorCloudSkipReviewerRequest = pi.getFlag("cursor-cloud-skip-reviewer-request") === true; - cliCursorCloudAllowLocalState = pi.getFlag("cursor-cloud-allow-local-state") === true; - cliCursorCloudEnv = stringFlagValue(pi.getFlag("cursor-cloud-env")); - cliCursorCloudEnvFromFiles = pi.getFlag("cursor-cloud-env-from-files") === true; - cliCursorCloudEnvType = stringFlagValue(pi.getFlag("cursor-cloud-env-type")); - cliCursorCloudEnvName = stringFlagValue(pi.getFlag("cursor-cloud-env-name")); - cliCursorCloudAck = pi.getFlag("cursor-cloud-ack") === true; + const runtime = stringFlagValue(pi.getFlag("cursor-runtime")); + const repo = stringFlagValue(pi.getFlag("cursor-cloud-repo")); + const branch = stringFlagValue(pi.getFlag("cursor-cloud-branch")); + const contextHandoff = stringFlagValue(pi.getFlag("cursor-cloud-context")); + const environmentType = stringFlagValue(pi.getFlag("cursor-cloud-env-type")); + const environmentName = stringFlagValue(pi.getFlag("cursor-cloud-env-name")); + const cloudEnvNames = stringFlagValue(pi.getFlag("cursor-cloud-env")); + const cloud: NonNullable = { + ...(repo ? { repo } : {}), + ...(branch ? { branch } : {}), + ...(contextHandoff ? { contextHandoff } : {}), + ...(pi.getFlag("cursor-cloud-direct-push") === true ? { directPush: true } : {}), + ...(pi.getFlag("cursor-cloud-auto-create-pr") === true ? { autoCreatePR: true } : {}), + ...(pi.getFlag("cursor-cloud-skip-reviewer-request") === true ? { skipReviewerRequest: true } : {}), + ...(pi.getFlag("cursor-cloud-allow-local-state") === true ? { allowLocalState: true } : {}), + ...(pi.getFlag("cursor-cloud-env-from-files") === true ? { envFromFiles: true } : {}), + ...(environmentType || environmentName + ? { environment: { ...(environmentType ? { type: environmentType } : {}), ...(environmentName ? { name: environmentName } : {}) } } + : {}), + ...(pi.getFlag("cursor-cloud-ack") === true ? { acknowledged: true } : {}), + }; + const local: NonNullable = { + ...(pi.getFlag("cursor-auto-review") === true ? { autoReview: true } : {}), + ...(pi.getFlag("cursor-sandbox") === true ? { sandboxOptions: { enabled: true } } : {}), + ...(!cliLocalForceConsumed && pi.getFlag("cursor-local-force") === true ? { force: true } : {}), + ...(pi.getFlag("cursor-no-local-resume") === true + ? { resume: false } + : pi.getFlag("cursor-local-resume") === true + ? { resume: true } + : {}), + }; + cliCursorSnapshot = { + config: { + ...(runtime ? { runtime } : {}), + ...(Object.keys(cloud).length ? { cloud } : {}), + ...(Object.keys(local).length ? { local } : {}), + }, + ...(cloudEnvNames ? { cloudEnvNames } : {}), + }; } function persistCursorRuntimePreference( @@ -504,26 +489,9 @@ export function registerCursorCloudRuntimeControls( } export function resetCursorRuntimeStateForTests(): void { - cliAutoReview = false; - cliSandbox = false; - cliLocalForce = false; + cliCursorSnapshot = { config: {} }; cliLocalForceConsumed = false; - cliLocalResume = false; - cliNoLocalResume = false; envLocalForceConsumed = false; - cliCursorRuntime = undefined; - cliCursorCloudRepo = undefined; - cliCursorCloudBranch = undefined; - cliCursorCloudContext = undefined; - cliCursorCloudDirectPush = false; - cliCursorCloudAutoCreatePR = false; - cliCursorCloudSkipReviewerRequest = false; - cliCursorCloudAllowLocalState = false; - cliCursorCloudEnv = undefined; - cliCursorCloudEnvFromFiles = false; - cliCursorCloudEnvType = undefined; - cliCursorCloudEnvName = undefined; - cliCursorCloudAck = false; sessionCursorRuntime = undefined; sessionCursorCloudAcknowledged = false; } diff --git a/src/cursor-session-agent.ts b/src/cursor-session-agent.ts index 907074b..3f8e685 100644 --- a/src/cursor-session-agent.ts +++ b/src/cursor-session-agent.ts @@ -15,10 +15,9 @@ import { import type { CursorSdkEventDebugRecorder } from "./cursor-sdk-event-debug.js"; import { loadCursorSdk, type CursorSdkModule } from "./cursor-sdk-runtime.js"; import { - claimCursorTemporarySessionStore, cursorSessionStoreIdentitiesEqual, - getCursorSessionStoreIdentities, openCursorSessionStore, + openCursorSessionStoreForScope, type CursorSessionStoreIdentity, type OpenCursorSessionStore, } from "./cursor-session-store.js"; @@ -467,33 +466,18 @@ async function createSessionAgentEntry( createAgent ??= sdk.Agent.create; resumeAgent ??= sdk.Agent.resume; } - const identities = await getCursorSessionStoreIdentities(params.cwd, scopeKey, persistentStore); - const openSessionStore = (identity: CursorSessionStoreIdentity) => { - if (!persistentStore) claimCursorTemporarySessionStore(identity); - return openCursorSessionStore(params.cwd, identity, !persistentStore); - }; const resumeHandle = resumeEligible ? getMatchingCursorSessionAgentResumeHandle(resolvedPoolKey) : undefined; - const recordedStoreIdentity = resumeHandle?.storeIdentity; - const resumableStoreIdentities = persistentStore - ? [identities.defaultStore, identities.sessionStore] - : [identities.sessionStore]; - const resumeStoreIdentity = resumeHandle - ? recordedStoreIdentity === undefined - ? persistentStore ? identities.defaultStore : undefined - : resumableStoreIdentities.find((identity) => - cursorSessionStoreIdentitiesEqual(identity, recordedStoreIdentity), - ) - : undefined; - let resumeAttemptAllowed = resumeHandle !== undefined && resumeStoreIdentity !== undefined; - let resumeNotice = persistentStore && resumeHandle && !resumeStoreIdentity ? LOCAL_RESUME_FALLBACK_NOTICE : undefined; - try { - sessionStore = await openSessionStore(resumeStoreIdentity ?? identities.sessionStore); - } catch (error) { - if (!resumeStoreIdentity || cursorSessionStoreIdentitiesEqual(resumeStoreIdentity, identities.sessionStore)) throw error; - resumeAttemptAllowed = false; - if (persistentStore) resumeNotice = LOCAL_RESUME_FALLBACK_NOTICE; - sessionStore = await openSessionStore(identities.sessionStore); - } + const storeSelection = await openCursorSessionStoreForScope({ + cwd: params.cwd, + scopeKey, + persistent: persistentStore, + hasResumeHandle: resumeHandle !== undefined, + resumeIdentity: resumeHandle?.storeIdentity, + }); + sessionStore = storeSelection.sessionStore; + const { identities } = storeSelection; + const resumeAttemptAllowed = storeSelection.resumeAttemptAllowed; + let resumeNotice = storeSelection.resumeFallback ? LOCAL_RESUME_FALLBACK_NOTICE : undefined; const buildAgentOptions = () => ({ apiKey: params.apiKey, model: params.modelSelection, @@ -518,12 +502,13 @@ async function createSessionAgentEntry( if (persistentStore) resumeNotice = LOCAL_RESUME_FALLBACK_NOTICE; if (!cursorSessionStoreIdentitiesEqual(sessionStore.identity, identities.sessionStore)) { await sessionStore.dispose().catch(() => undefined); - sessionStore = await openSessionStore(identities.sessionStore); + sessionStore = await openCursorSessionStore(params.cwd, identities.sessionStore); } } } agent ??= await createAgent(buildAgentOptions()); if (!agent) throw new Error("Cursor SDK agent creation returned no agent"); + if (!sessionStore) throw new Error("Cursor SDK session store was not opened"); return { status: "ready", diff --git a/src/cursor-session-store.ts b/src/cursor-session-store.ts index 1df2d3e..9ba8094 100644 --- a/src/cursor-session-store.ts +++ b/src/cursor-session-store.ts @@ -16,12 +16,21 @@ export interface OpenCursorSessionStore { dispose(): Promise; } +export interface CursorSessionStoreSelection { + sessionStore: OpenCursorSessionStore; + identities: { + defaultStore: CursorSessionStoreIdentity; + sessionStore: CursorSessionStoreIdentity; + }; + resumeAttemptAllowed: boolean; + resumeFallback: boolean; +} + interface CursorSessionStoreSdkOperations { getDefaultStateRoot(cwd: string): string | Promise; openSqliteStore(options: { workspaceRef: string; stateRoot: string }): Promise }>; } -const removableTemporaryStateRoots = new Map(); let sdkOperationsForTests: CursorSessionStoreSdkOperations | undefined; export function hashCursorSessionStoreScope(scopeKey: string): string { @@ -71,25 +80,18 @@ export function cursorSessionStoreIdentitiesEqual( return left.version === right.version && left.stateRoot === right.stateRoot; } -export function claimCursorTemporarySessionStore(identity: CursorSessionStoreIdentity): void { - removableTemporaryStateRoots.set(identity.stateRoot, dirname(dirname(identity.stateRoot))); -} - -export async function openCursorSessionStore( +async function openOwnedCursorSessionStore( cwd: string, identity: CursorSessionStoreIdentity, - removeOnDispose = false, + removalRoot?: string, ): Promise { const openedIdentity = Object.freeze({ ...identity }); - const stateRoot = openedIdentity.stateRoot; - const removalRoot = removeOnDispose ? removableTemporaryStateRoots.get(stateRoot) : undefined; - if (removeOnDispose && !removalRoot) { - throw new Error("Refusing to remove a Cursor SDK store without temporary-store ownership"); - } - if (removeOnDispose) removableTemporaryStateRoots.delete(stateRoot); let store: LocalAgentStore & { dispose(): Promise }; try { - store = await (await getSdkOperations()).openSqliteStore({ workspaceRef: cwd, stateRoot: toNamespacedPath(stateRoot) }); + store = await (await getSdkOperations()).openSqliteStore({ + workspaceRef: cwd, + stateRoot: toNamespacedPath(openedIdentity.stateRoot), + }); } catch (error) { if (removalRoot) await rm(removalRoot, { recursive: true, force: true }).catch(() => undefined); throw error; @@ -107,6 +109,45 @@ export async function openCursorSessionStore( }; } +export function openCursorSessionStore( + cwd: string, + identity: CursorSessionStoreIdentity, +): Promise { + return openOwnedCursorSessionStore(cwd, identity); +} + +export async function openCursorSessionStoreForScope(options: { + cwd: string; + scopeKey: string; + persistent: boolean; + hasResumeHandle: boolean; + resumeIdentity?: CursorSessionStoreIdentity; +}): Promise { + const identities = await getCursorSessionStoreIdentities(options.cwd, options.scopeKey, options.persistent); + const requestedResumeIdentity = options.hasResumeHandle + ? options.resumeIdentity ?? (options.persistent ? identities.defaultStore : undefined) + : undefined; + const resumableIdentities = options.persistent + ? [identities.defaultStore, identities.sessionStore] + : [identities.sessionStore]; + const resumeIdentity = requestedResumeIdentity && resumableIdentities + .find((identity) => cursorSessionStoreIdentitiesEqual(identity, requestedResumeIdentity)); + let resumeAttemptAllowed = options.hasResumeHandle && resumeIdentity !== undefined; + let resumeFallback = options.persistent && options.hasResumeHandle && !resumeIdentity; + const selectedIdentity = resumeIdentity ?? identities.sessionStore; + const removalRoot = options.persistent ? undefined : dirname(dirname(identities.sessionStore.stateRoot)); + let sessionStore: OpenCursorSessionStore; + try { + sessionStore = await openOwnedCursorSessionStore(options.cwd, selectedIdentity, removalRoot); + } catch (error) { + if (!resumeIdentity || cursorSessionStoreIdentitiesEqual(resumeIdentity, identities.sessionStore)) throw error; + resumeAttemptAllowed = false; + resumeFallback = true; + sessionStore = await openOwnedCursorSessionStore(options.cwd, identities.sessionStore); + } + return { sessionStore, identities, resumeAttemptAllowed, resumeFallback }; +} + export const __testUtils = { setSdkOperations(operations: CursorSessionStoreSdkOperations | undefined): void { sdkOperationsForTests = operations; diff --git a/test/cursor-sdk-process-abort-error-guard.test.ts b/test/cursor-sdk-process-abort-error-guard.test.ts new file mode 100644 index 0000000..7e0980b --- /dev/null +++ b/test/cursor-sdk-process-abort-error-guard.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from "vitest"; +import { + installCursorSdkProcessErrorGuard, + installCursorSdkSessionProcessErrorGuard, +} from "../src/cursor-sdk-process-error-guard.js"; + +function makeCursorSdkRawAbortDomException(): DOMException { + const error = new DOMException("This operation was aborted", "AbortError"); + error.stack = + "AbortError: This operation was aborted\n" + + " at AbortSignal.abort (/repo/node_modules/@cursor/sdk/dist/esm/996.js:1:5705)\n" + + " at Y.onStall (/repo/node_modules/@cursor/sdk/dist/esm/357.js:1:75246)"; + return error; +} + +function makeCursorSdkRawAbortError(): Error { + const error = new Error("This operation was aborted"); + error.name = "AbortError"; + error.stack = + "AbortError: This operation was aborted\n" + + " at abort (/repo/node_modules/@cursor/sdk/dist/esm/index.js:1:1125976)"; + return error; +} + +function processListenerCalled(event: "uncaughtException" | "unhandledRejection", error: unknown): boolean { + let called = false; + const listener = () => { called = true; }; + process.once(event, listener); + try { + if (event === "uncaughtException") process.emit(event, error as Error, "uncaughtException"); + else process.emit(event, error, Promise.resolve()); + return called; + } finally { + process.removeListener(event, listener); + } +} + +describe("Cursor SDK raw AbortError process guard", () => { + it("suppresses a DOMException during a provider turn that declares abort suppression", () => { + const guard = installCursorSdkProcessErrorGuard(); + guard.suppressAbortErrors(); + try { + expect(processListenerCalled("uncaughtException", makeCursorSdkRawAbortDomException())).toBe(false); + } finally { + guard.dispose(); + } + }); + + it("suppresses the unhandledRejection path", () => { + const guard = installCursorSdkProcessErrorGuard(); + guard.suppressAbortErrors(); + try { + expect(processListenerCalled("unhandledRejection", makeCursorSdkRawAbortDomException())).toBe(false); + } finally { + guard.dispose(); + } + }); + + it("does not suppress a provider turn that has not declared abort suppression", () => { + const guard = installCursorSdkProcessErrorGuard(); + try { + expect(processListenerCalled("uncaughtException", makeCursorSdkRawAbortDomException())).toBe(true); + } finally { + guard.dispose(); + } + }); + + it("suppresses a plain Error variant with Cursor SDK provenance", () => { + const guard = installCursorSdkProcessErrorGuard(); + guard.suppressAbortErrors(); + try { + expect(processListenerCalled("uncaughtException", makeCursorSdkRawAbortError())).toBe(false); + } finally { + guard.dispose(); + } + }); + + it("does not suppress an AbortError without Cursor SDK provenance", () => { + const guard = installCursorSdkProcessErrorGuard(); + guard.suppressAbortErrors(); + const error = makeCursorSdkRawAbortDomException(); + error.stack = "AbortError: This operation was aborted\n at abort (/repo/src/app.ts:1:1)"; + try { + expect(processListenerCalled("uncaughtException", error)).toBe(true); + } finally { + guard.dispose(); + } + }); + + it("does not suppress without an active provider turn", () => { + const guard = installCursorSdkSessionProcessErrorGuard(); + try { + expect(processListenerCalled("uncaughtException", makeCursorSdkRawAbortDomException())).toBe(true); + } finally { + guard.dispose(); + } + }); +}); diff --git a/test/cursor-sdk-process-error-guard.test.ts b/test/cursor-sdk-process-error-guard.test.ts index add57cd..5aa1874 100644 --- a/test/cursor-sdk-process-error-guard.test.ts +++ b/test/cursor-sdk-process-error-guard.test.ts @@ -94,24 +94,6 @@ function makeCursorSdkWriteIterableClosedError(): Error { return error; } -function makeCursorSdkRawAbortDomException(): DOMException { - const error = new DOMException("This operation was aborted", "AbortError"); - error.stack = - "AbortError: This operation was aborted\n" + - " at AbortSignal.abort (/repo/node_modules/@cursor/sdk/dist/esm/996.js:1:5705)\n" + - " at Y.onStall (/repo/node_modules/@cursor/sdk/dist/esm/357.js:1:75246)"; - return error; -} - -function makeCursorSdkRawAbortError(): Error { - const error = new Error("This operation was aborted"); - error.name = "AbortError"; - error.stack = - "AbortError: This operation was aborted\n" + - " at abort (/repo/node_modules/@cursor/sdk/dist/esm/index.js:1:1125976)"; - return error; -} - function makeCursorSdkUnauthenticatedConnectError(): Error & { rawMessage: string; code: number } { const error = new Error("[unauthenticated] Error") as Error & { rawMessage: string; code: number }; error.name = "ConnectError"; @@ -515,114 +497,6 @@ setTimeout(() => console.log("survived"), 20); } }); - it("suppresses a raw Cursor SDK DOMException AbortError while a suppressing provider turn is active", () => { - const turnGuard = installCursorSdkProcessErrorGuard(); - turnGuard.suppressAbortErrors(); - let listenerCalled = false; - const listener = () => { - listenerCalled = true; - }; - process.once("uncaughtException", listener); - try { - const emitted = process.emit("uncaughtException", makeCursorSdkRawAbortDomException(), "uncaughtException"); - expect(emitted).toBe(true); - expect(listenerCalled).toBe(false); - } finally { - process.removeListener("uncaughtException", listener); - turnGuard.dispose(); - } - }); - - it("suppresses a raw Cursor SDK DOMException AbortError via the unhandledRejection path when suppression is active", () => { - const turnGuard = installCursorSdkProcessErrorGuard(); - turnGuard.suppressAbortErrors(); - let listenerCalled = false; - const listener = () => { - listenerCalled = true; - }; - process.once("unhandledRejection", listener); - try { - const emitted = process.emit("unhandledRejection", makeCursorSdkRawAbortDomException(), Promise.resolve()); - expect(emitted).toBe(true); - expect(listenerCalled).toBe(false); - } finally { - process.removeListener("unhandledRejection", listener); - turnGuard.dispose(); - } - }); - - it("does not suppress a Cursor SDK AbortError for a provider turn that has not declared abort suppression", () => { - const turnGuard = installCursorSdkProcessErrorGuard(); - let listenerCalled = false; - const listener = () => { - listenerCalled = true; - }; - process.once("uncaughtException", listener); - try { - const emitted = process.emit("uncaughtException", makeCursorSdkRawAbortDomException(), "uncaughtException"); - expect(emitted).toBe(true); - expect(listenerCalled).toBe(true); - } finally { - process.removeListener("uncaughtException", listener); - turnGuard.dispose(); - } - }); - - it("suppresses a plain Error AbortError variant with Cursor SDK provenance when suppression is active", () => { - const turnGuard = installCursorSdkProcessErrorGuard(); - turnGuard.suppressAbortErrors(); - let listenerCalled = false; - const listener = () => { - listenerCalled = true; - }; - process.once("uncaughtException", listener); - try { - const emitted = process.emit("uncaughtException", makeCursorSdkRawAbortError(), "uncaughtException"); - expect(emitted).toBe(true); - expect(listenerCalled).toBe(false); - } finally { - process.removeListener("uncaughtException", listener); - turnGuard.dispose(); - } - }); - - it("does not suppress an AbortError without Cursor SDK stack provenance", () => { - const turnGuard = installCursorSdkProcessErrorGuard(); - turnGuard.suppressAbortErrors(); - const error = makeCursorSdkRawAbortDomException(); - error.stack = "AbortError: This operation was aborted\n at abort (/repo/src/app.ts:1:1)"; - let listenerCalled = false; - const listener = () => { - listenerCalled = true; - }; - process.once("uncaughtException", listener); - try { - const emitted = process.emit("uncaughtException", error, "uncaughtException"); - expect(emitted).toBe(true); - expect(listenerCalled).toBe(true); - } finally { - process.removeListener("uncaughtException", listener); - turnGuard.dispose(); - } - }); - - it("does not suppress a Cursor SDK AbortError when no provider turn is active", () => { - const sessionGuard = installCursorSdkSessionProcessErrorGuard(); - let listenerCalled = false; - const listener = () => { - listenerCalled = true; - }; - process.once("uncaughtException", listener); - try { - const emitted = process.emit("uncaughtException", makeCursorSdkRawAbortDomException(), "uncaughtException"); - expect(emitted).toBe(true); - expect(listenerCalled).toBe(true); - } finally { - process.removeListener("uncaughtException", listener); - sessionGuard.dispose(); - } - }); - it("suppresses the exact observed closed-pipe EPIPE and invalidates while a contained local turn is active", () => { const onClosedPipe = vi.fn(); const turnGuard = installCursorSdkProcessErrorGuard(); diff --git a/test/cursor-session-store.test.ts b/test/cursor-session-store.test.ts index c5aa352..a62543d 100644 --- a/test/cursor-session-store.test.ts +++ b/test/cursor-session-store.test.ts @@ -1,13 +1,13 @@ import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; import { Agent, createAgentPlatform, type LocalAgentStore } from "@cursor/sdk"; -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; import { buildCursorSessionStateRoot, - claimCursorTemporarySessionStore, hashCursorSessionStoreScope, openCursorSessionStore, + openCursorSessionStoreForScope, __testUtils as storeTestUtils, } from "../src/cursor-session-store.js"; @@ -31,80 +31,96 @@ describe("cursor session store identity", () => { expect(anonymous).toContain("pi-sessions"); }); - it("removes an extension-owned temporary store after graceful disposal", async () => { + it("never resumes a fileless acquisition from the shared default store", async () => { + const workspaceRoot = mkdtempSync(join(tmpdir(), "pi-cursor-fileless-shared-store-")); + storeTestUtils.setSdkOperations({ + getDefaultStateRoot: () => workspaceRoot, + openSqliteStore: async () => ({ + dispose: async () => {}, + }) as unknown as LocalAgentStore & { dispose(): Promise }, + }); + try { + const selection = await openCursorSessionStoreForScope({ + cwd: workspaceRoot, + scopeKey: "ephemeral", + persistent: false, + hasResumeHandle: true, + resumeIdentity: { version: 1, stateRoot: workspaceRoot }, + }); + expect(selection.resumeAttemptAllowed).toBe(false); + expect(selection.sessionStore.identity.stateRoot).not.toBe(workspaceRoot); + await selection.sessionStore.dispose(); + } finally { + storeTestUtils.setSdkOperations(undefined); + rmSync(workspaceRoot, { recursive: true, force: true }); + } + }); + + it("removes a factory-owned temporary store after graceful disposal", async () => { storeTestUtils.setSdkOperations(undefined); const root = mkdtempSync(join(tmpdir(), "pi-cursor-ephemeral-store-")); - const stateRoot = buildCursorSessionStateRoot(root, "ephemeral", false); - claimCursorTemporarySessionStore({ version: 1, stateRoot }); - const store = await openCursorSessionStore(root, { version: 1, stateRoot }, true); - expect(existsSync(stateRoot)).toBe(true); + const selection = await openCursorSessionStoreForScope({ + cwd: root, + scopeKey: "ephemeral", + persistent: false, + hasResumeHandle: false, + }); + const removalRoot = dirname(dirname(selection.sessionStore.identity.stateRoot)); + expect(existsSync(selection.sessionStore.identity.stateRoot)).toBe(true); - await store.dispose(); + await selection.sessionStore.dispose(); - expect(existsSync(stateRoot)).toBe(false); + expect(existsSync(removalRoot)).toBe(false); rmSync(root, { recursive: true, force: true }); }); - it("refuses to remove a shared store even when its path resembles a session root", async () => { + it("never grants temporary-removal ownership to a caller-supplied store", async () => { const workspaceRoot = mkdtempSync(join(tmpdir(), "pi-cursor-shared-store-")); - const stateRoot = join(workspaceRoot, "pi-sessions", "a".repeat(32)); - const marker = join(stateRoot, "keep.txt"); - mkdirSync(stateRoot, { recursive: true }); - writeFileSync(marker, "keep"); - - await expect(openCursorSessionStore(workspaceRoot, { version: 1, stateRoot }, true)).rejects.toThrow("Refusing to remove"); - expect(existsSync(marker)).toBe(true); - rmSync(workspaceRoot, { recursive: true, force: true }); - }); - - it("binds temporary removal to the authorized identity before SQLite opens", async () => { - const workspaceRoot = mkdtempSync(join(tmpdir(), "pi-cursor-store-mutation-")); - const stateRoot = buildCursorSessionStateRoot(workspaceRoot, "ephemeral", false); - const identity = { version: 1 as const, stateRoot }; - const fakeStore = { dispose: vi.fn(async () => {}) } as unknown as LocalAgentStore & { dispose(): Promise }; - let resolveOpen: () => void = () => {}; - const openSqliteStore = vi.fn(() => new Promise((resolve) => { - resolveOpen = () => resolve(fakeStore); - })); - storeTestUtils.setSdkOperations({ getDefaultStateRoot: () => workspaceRoot, openSqliteStore }); - mkdirSync(stateRoot, { recursive: true }); - claimCursorTemporarySessionStore(identity); - const opening = openCursorSessionStore(workspaceRoot, identity, true); const sharedRoot = join(workspaceRoot, "shared"); const marker = join(sharedRoot, "keep.txt"); mkdirSync(sharedRoot, { recursive: true }); writeFileSync(marker, "keep"); - identity.stateRoot = sharedRoot; - await vi.waitFor(() => expect(openSqliteStore).toHaveBeenCalledTimes(1)); - resolveOpen(); - + const selection = await openCursorSessionStoreForScope({ + cwd: workspaceRoot, + scopeKey: "persisted-session", + persistent: true, + hasResumeHandle: true, + resumeIdentity: { version: 1, stateRoot: sharedRoot }, + }); try { - const store = await opening; - await store.dispose(); - expect(store.identity.stateRoot).toBe(stateRoot); - expect(existsSync(stateRoot)).toBe(false); - expect(existsSync(marker)).toBe(true); + expect(selection.resumeAttemptAllowed).toBe(false); + expect(selection.resumeFallback).toBe(true); + expect(selection.sessionStore.identity.stateRoot).not.toBe(sharedRoot); } finally { - storeTestUtils.setSdkOperations(undefined); + await selection.sessionStore.dispose(); + expect(existsSync(marker)).toBe(true); rmSync(workspaceRoot, { recursive: true, force: true }); } }); it("removes a temporary root even when SQLite disposal fails", async () => { const workspaceRoot = mkdtempSync(join(tmpdir(), "pi-cursor-store-dispose-failure-")); - const stateRoot = buildCursorSessionStateRoot(workspaceRoot, "ephemeral", false); - mkdirSync(stateRoot, { recursive: true }); - claimCursorTemporarySessionStore({ version: 1, stateRoot }); + let stateRoot = ""; storeTestUtils.setSdkOperations({ getDefaultStateRoot: () => workspaceRoot, - openSqliteStore: async () => ({ - dispose: async () => { throw new Error("dispose failed"); }, - }) as unknown as LocalAgentStore & { dispose(): Promise }, + openSqliteStore: async (options) => { + stateRoot = options.stateRoot; + mkdirSync(stateRoot, { recursive: true }); + return { + dispose: async () => { throw new Error("dispose failed"); }, + } as unknown as LocalAgentStore & { dispose(): Promise }; + }, }); try { - const store = await openCursorSessionStore(workspaceRoot, { version: 1, stateRoot }, true); - await expect(store.dispose()).rejects.toThrow("dispose failed"); - expect(existsSync(stateRoot)).toBe(false); + const selection = await openCursorSessionStoreForScope({ + cwd: workspaceRoot, + scopeKey: "ephemeral", + persistent: false, + hasResumeHandle: false, + }); + const removalRoot = dirname(dirname(stateRoot)); + await expect(selection.sessionStore.dispose()).rejects.toThrow("dispose failed"); + expect(existsSync(removalRoot)).toBe(false); } finally { storeTestUtils.setSdkOperations(undefined); rmSync(workspaceRoot, { recursive: true, force: true }); diff --git a/test/maintainer-scripts-declarations.test.ts b/test/maintainer-scripts-declarations.test.ts index 3fd7059..be6fe6b 100644 --- a/test/maintainer-scripts-declarations.test.ts +++ b/test/maintainer-scripts-declarations.test.ts @@ -115,6 +115,10 @@ const DECLARATION_TYPE_ONLY_EXPORTS: Record = { "CursorSdkEventTimingSnapshot", "CursorSdkEventJsonlSink", ], + "scripts/lib/cloud-smoke-artifacts.d.mts": [ + "CloudSmokeLifecycleRecord", + "CloudSmokeMetadataRecord", + ], "scripts/lib/cloud-smoke-cleanup-evidence.d.mts": [ "CloudSmokeBranchLaneEvidence", "CloudSmokeCancelLaneEvidence", diff --git a/test/smoke-cli-package-contracts.test.ts b/test/smoke-cli-package-contracts.test.ts index 2d06e26..eff7851 100644 --- a/test/smoke-cli-package-contracts.test.ts +++ b/test/smoke-cli-package-contracts.test.ts @@ -131,7 +131,9 @@ describe("smoke CLI and package contracts", () => { }); it("keeps the required paid cloud matrix CLI contract and helper surface", () => { - const source = readFileSync("scripts/cloud-runtime-smoke.mjs", "utf8"); + const entrypointSource = readFileSync("scripts/cloud-runtime-smoke.mjs", "utf8"); + const runnerSource = readFileSync("scripts/lib/cloud-smoke-pi-runner.mjs", "utf8"); + const source = `${entrypointSource}\n${runnerSource}`; const help = run(process.execPath, ["scripts/cloud-runtime-smoke.mjs", "--help"]); expect(help.status).toBe(0); expect(help.stdout).toContain("npm run smoke:cloud"); @@ -155,8 +157,10 @@ describe("smoke CLI and package contracts", () => { "projectCloudSmokeMatrixEvidence", "listCloudSmokePackageSourcePaths", ]) expect(source, anchor).toContain(anchor); - // Helper modules are loaded by the entrypoint; CLI contract stays source-shape only. + // Helper modules are loaded by the entrypoint; CLI and child-transport contracts stay source-shape only. expect(source).toContain('from "./lib/cloud-smoke-cleanup-evidence.mjs"'); + expect(source).toContain('from "./lib/cloud-smoke-artifacts.mjs"'); + expect(source).toContain('from "./lib/cloud-smoke-pi-runner.mjs"'); expect(source).toContain('from "./lib/cloud-smoke-github.mjs"'); expect(source).toContain('from "./lib/cloud-smoke-shutdown.mjs"'); for (const shutdownAnchor of [ @@ -169,7 +173,8 @@ describe("smoke CLI and package contracts", () => { "throwIfFailed: terminalState.throwIfFailed", "process.exitCode = 1", ]) expect(source, shutdownAnchor).toContain(shutdownAnchor); - expect(source).toContain('try {\n\t\t\tchild.stdin.write(`${JSON.stringify({ id, type, ...extra })}\\n`);\n\t\t} catch (error) {\n\t\t\trouteRpcError(error);\n\t\t}'); + expect(source).toContain('child.stdin.write(`${JSON.stringify({ id, type, ...extra })}\\n`);'); + expect(source).toContain("routeRpcError(error);"); expect(source).toContain("installCloudSmokeSignalHandlers(cloudSmokeShutdown, process, () => { process.exitCode = 1; })"); expect(source).not.toContain("removeSignalHandlers"); expect(source.match(/await checkpointCloudSmokeShutdown\(cloudSmokeShutdown\)/g)).toHaveLength(3); diff --git a/test/smoke-tooling.test.ts b/test/smoke-tooling.test.ts index d264145..72f8a01 100644 --- a/test/smoke-tooling.test.ts +++ b/test/smoke-tooling.test.ts @@ -5,6 +5,7 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; import { CURSOR_TOOL_PRESENTATION_SPECS } from "../src/cursor-tool-presentation-registry.js"; +import { getScenario } from "../scripts/platform-smoke/scenarios.mjs"; function run(command: string, args: string[], env = process.env, cwd = process.cwd()) { return spawnSync(command, args, { cwd, encoding: "utf8", env, shell: process.platform === "win32" && command === "npm" }); @@ -192,6 +193,16 @@ try { } }); + it("keeps the required HTTP/1.1 live lane explicit", () => { + const scenario = getScenario("cursor-http1-live"); + expect(scenario).toMatchObject({ + cursorCalls: 1, + finalMarker: "HTTP1_LIVE_OK", + requiredCards: ["http1-status"], + env: { PI_CURSOR_HTTP_1_1: "1", PI_CURSOR_PI_TOOL_BRIDGE: "0", PI_CURSOR_SDK_EVENT_DEBUG: "1" }, + }); + }); + it("rejects invalid platform smoke targets and suites before Crabbox runs", () => { const invalidTarget = run(process.execPath, ["scripts/platform-smoke.mjs", "run", "--target", "plan9"]); expect(invalidTarget.status).toBe(2); @@ -209,10 +220,10 @@ try { import { detectCards, assertRequiredCards } from "./scripts/platform-smoke/card-detect.mjs"; import { isSafeBundlePath } from "./scripts/platform-smoke/targets.mjs"; const promptOnly = detectCards("1. call pi__read on ./package.json\n2. grep ./README.md\n"); -const rendered = detectCards("read /workspace/pi-cursor-sdk/package.json\ngrep /pi-cursor-sdk/ in C:/workspace/README.md\nbridge visual smoke\nENOENT: no such file or directory\ncomposer-2-5\n"); +const rendered = detectCards("read /workspace/pi-cursor-sdk/package.json\ngrep /pi-cursor-sdk/ in C:/workspace/README.md\nbridge visual smoke\nENOENT: no such file or directory\ncursor:local · fast:off · http1\ncomposer-2-5\n"); const wrapped = detectCards("read /workspace/very-long-test-workspace/package.js\non\n"); const wrappedMidToken = detectCards("read /workspace/very-long-test-workspace/package.j\nson\n"); -const checks = assertRequiredCards(".", rendered, ["bridge-read-success", "grep", "bridge-shell-success", "bridge-read-failure", "footer-status"]); +const checks = assertRequiredCards(".", rendered, ["bridge-read-success", "grep", "bridge-shell-success", "bridge-read-failure", "http1-status", "footer-status"]); const wrappedChecks = assertRequiredCards(".", wrapped, ["bridge-read-success"]); const wrappedMidTokenChecks = assertRequiredCards(".", wrappedMidToken, ["bridge-read-success"]); const result = {