git:20260824.2b9512c to git:20260915.88c2733

8 added, 4 removed. Audit A to A.

# cursor-cli-oauth
The native Cursor provider (`cursor`, the api2.cursor.sh protobuf transport shipped in v2026.8.16) is the first-party, primary way to use Cursor from senpi. This extension is the documented FALLBACK lane: reach for it when the native path does not work well (protocol drift, transport failures) or when Cursor's own agent harness - running turns through the official `cursor-agent` CLI in print mode - is explicitly wanted. It never replaces or modifies the native provider, and `/cursor-account status` recommends the native provider whenever both are configured.
Generated: 2026-08-17
## FILE ROLES
| File | Role |
|---|---|
- | `index.ts` | Extension entry: registers the `cursor-cli-oauth` provider unconditionally with the offline model catalog (swapping in the probe-backed catalog when it resolves), wires `/cursor-account`, installs the default native-bootstrap credential reader, and delegates every turn to `streamSimple` with settings, accounts, and the executable re-resolved per turn |
+ | `index.ts` | Extension entry: registers the `cursor-cli-oauth` provider unconditionally with the offline model catalog (swapping in the probe-backed catalog when the lane-gated refresh resolves one), wires `/cursor-account`, installs the default native-bootstrap credential reader, and delegates every turn to `streamSimple` with settings, accounts, and the executable re-resolved per turn |
+ | `environment.ts` | `cursorAgentEnvironment(home)`: the single child environment for every cursor-agent spawn - `HOME` = the account home, `AGENT_CLI_CREDENTIAL_STORE=file`, `PATH`/`TERM`/`LANG`/`LC_ALL`/`FORCE_COLOR` passthrough, nothing else (no `SSH_*`/`MOSH_*` markers, which would trigger the CLI's macOS keychain preflight; senpi#1722) |
+ | `catalog-refresh.ts` | Startup catalog refresh: `assessConfiguration` first, so `disabled`/`not-installed`/`no-accounts` resolve `undefined` without spawning; `configured` probes `cursor-agent models` inside the pinned (else first) account HOME via `runInCursorAccountHome` |
+ | `models-probe.ts` | `runModelsProbe({ executable, stdoutPath, timeoutMs, home })`: the `cursor-agent models` spawner (15 s SIGKILL deadline, stdout captured to a file, `cursorAgentEnvironment(home)`), typed timeout/exit errors |
| `settings.ts` | `cursorCliOauthProvider` settings parsing: fixed defaults (`enabled: false`, `forceExecution: true`, `executionMode: "agent"`, `resumeMode: "auto"`, `contextRecapOnModelSwitch: true`, `modelCatalogTtlHours: 24`), `SENPI_CURSOR_CLI_OAUTH_*` env overrides winning over disk, invalid values silently ignored; exports the sandbox-allowlist validator factory |
| `accounts.ts` | Multi-account slot model: `CursorCliAccountSlot` and the credential shape with sentinel top-level fields, `addAccount`/`removeAccount`/`pinAccount`/`listAccounts`, name validation `^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$`, `assertSentinelInvariant`, `refreshSlot` through `store.modify` |
| `native-bootstrap.ts` | Default credential reader: when enabled, the executable resolves, and managed accounts are empty, copies the native Senpi `cursor` OAuth credential into one canonical `native` slot; re-checks the target under the auth lock, deduplicates concurrent reads, never overwrites incompatible/existing credentials, and degrades non-throwingly |
| `affinity.ts` | HRW (rendezvous) account selection keyed by senpi session id (sha256 `BigUInt64BE` score), pinned account wins unless blocked, expired `rate_limit` blocks cleared while `auth_error` blocks persist, `AllCursorAccountsBlockedError` carrying the soonest unblock time |
| `errors.ts` | Closed ten-kind error union and `classifyCursorCliError`: exact-line matchers on probe-observed stderr wording plus typed `binary_missing`/`malformed_stream` kinds, retryability flags, rate-limit block durations (server hint else 60 s, capped at 48 h); the `context_overflow` wordings list is currently empty (the ceiling probe recorded no verbatim wording), so that kind is defined but unmatched |
- | `executable.ts` | `cursor-agent` resolution chain: `SENPI_CURSOR_CLI_OAUTH_EXECUTABLE`, then `CURSOR_AGENT_EXECUTABLE`, then settings `executablePath`, then explicit PATH probing, then the newest `~/.local/share/cursor-agent/versions/*`; typed `CursorAgentNotInstalledError` naming the install command; `probeCursorAgentVersion` under a 10 s deadline |
+ | `executable.ts` | `cursor-agent` resolution chain: `SENPI_CURSOR_CLI_OAUTH_EXECUTABLE`, then `CURSOR_AGENT_EXECUTABLE`, then settings `executablePath`, then explicit PATH probing, then the newest `~/.local/share/cursor-agent/versions/*`; typed `CursorAgentNotInstalledError` naming the install command; `probeCursorAgentVersion` under a 10 s deadline with `cursorAgentEnvironment(homedir())` |
| `spawn-args.ts` | Pure argv serializer for one print-mode invocation (`-p`, `--output-format stream-json`, `--stream-partial-output`, `--trust`, optional `--model`/`--resume`/`--force`/`--mode plan`/`--sandbox`); applies no execution policy |
| `stream-parser.ts` | Incremental NDJSON parser for the stream-json dialect: typed init/thinking/assistant/tool_call/result events, split-line tolerance, a bounded pending buffer (1 MiB) with `line_overflow` handling, unknown events counted, non-JSON noise routed to a bounded diagnostic ring, `malformed_stream` events instead of throws |
- | `transport.ts` | Spawns the resolved executable detached in its own process group with an explicit env allowlist (`HOME` = the account home, `AGENT_CLI_CREDENTIAL_STORE=file`, `PATH`/`TERM`/`LANG`/`LC_ALL`/`FORCE_COLOR`); rejects prompts over 130 KB pre-spawn; abort sends SIGTERM to the group then SIGKILL after 5 s; exposes the pid, parsed events, bounded stderr, and a settled outcome |
+ | `transport.ts` | Spawns the resolved executable detached in its own process group with `cursorAgentEnvironment(accountHome)`; rejects prompts over 130 KB pre-spawn; abort sends SIGTERM to the group then SIGKILL after 5 s; exposes the pid, parsed events, bounded stderr, and a settled outcome |
| `home-store.ts` | Durable per-account HOMEs under `<agentDir>/cursor-cli-oauth/accounts/<slot>/home`: rewrites `.cursor/auth.json` (`accessToken`/`refreshToken`/`apiKey: null`/`bedrockCredentials: null`) at mode 0600 inside 0700 directories immediately before each run, reads back rotated refresh tokens after; logs byte lengths only; traversal-checked paths; never deletes a HOME |
| `oauth-login.ts` | Provider OAuth config (`check`/`login`/`refreshToken`/`getApiKey`) reusing the `packages/ai` Cursor PKCE flow, first slot named `default`; one `configuredFor` predicate backs both `check` and turn-time lane resolution (`file-store` only, no ambient branch exists); local desktop/keychain import remains explicit, while `importNativeCursorCredential` is shared by explicit and automatic Senpi-native credential copies |
| `session-router.ts` | Sticky chat routing: per-senpi-session `{accountName, chatId, lastModel}` captured from `system/init`; same- or different-model turns resume via `--resume`, a model switch prepends a one-turn 8 KB context recap, and resume failure or `context_overflow` restarts a fresh chat with the recap plus a notice; prompt and recap are shrunk to the transport ceiling before spawning |
| `failover.ts` | Account rotation around one attempt: `rate_limit` blocks the slot (server hint else 60 s, max 48 h), `auth_error` blocks until re-login; retries only before any visible assistant delta; a replacement account always starts a fresh chat with a user-visible notice and never inherits chat context |
- | `models.ts` | Model catalog: cached `cursor-agent models` probe (15 s deadline, full-stdout file capture, ANSI strip, `<id> - <label>` parsing, TTL cache at `<agentDir>/cursor-cli-oauth/models.json`) degrading to the exact 15-entry static fallback; zero cost, text-only input, 64 K max tokens |
+ | `models.ts` | Model catalog: cache-first `resolveCursorCliModelCatalog` (TTL cache at `<agentDir>/cursor-cli-oauth/models.json`, ANSI strip, `<id> - <label>` parsing) over a caller-supplied `runProbe` - the caller owns the HOME the listing comes from - degrading to the exact 15-entry static fallback; zero cost, text-only input, 64 K max tokens |
| `guardrails.ts` | Execution policy: `--force` only in agent mode with `noApprovalAcknowledgedAt` set (typed `CursorCliExecutionRefusalError` naming the acknowledgement step otherwise); plan mode never forces; force-disabled agent mode and unproven sandbox modes warn once per session; deny lists sanitized to exact full commands and written per-spawn as `permissions.deny` `Shell(...)` entries in the account HOME's `cli-config.json` |
| `stream.ts` | The turn path (`streamCursorCliOauth`): re-resolves settings, accounts, and the executable per turn, refreshes expired tokens, composes failover and the session router, maps events to ordered text/thinking deltas with cumulative-snapshot dedupe, keeps Cursor-executed tool protocol out of assistant text and host tool calls, and applies usage isolation (senpi's own sent-payload estimate plus the CLI's output tokens; CLI input/cache numbers quarantined in a `cursor_cli_oauth_cli_usage` diagnostic) |
| `account-command.ts` | `/cursor-account` command (`list`/`add`/`remove`/`pin`/`unpin`/`import`/`status`): reads account state fresh from the credential store on every invocation, reuses `claude-sdk-oauth`'s account-events emitter (import only; that lane is never edited), and fences every deferred continuation behind the generation guard |
| `diagnostics.ts` | Status collection and rendering for `/cursor-account status` (lane always `file-store`, context owner always `senpi`, selected account, chat id, last model, executable path and version with the `2026.08.11` floor warning, block windows, native-provider recommendation when configured); plus reload/shutdown safety: the generation fence, the tracked-child registry, and the `session_shutdown` teardown with process-group kills |
| `changes.md` | Fork-change record; read before touching anything here |
## INVARIANTS
- Native-first positioning: the native `cursor` provider is the primary path; this lane is the documented fallback and never replaces or modifies it.
- senpi is the context owner in every mode. `usage.input` is senpi's own estimate of the payload it spawned, `usage.output` is the CLI's `outputTokens`, `cacheRead`/`cacheWrite`/`totalTokens` stay 0, and the CLI's `inputTokens`/`cacheReadTokens`/`request_id` live only in the `cursor_cli_oauth_cli_usage` diagnostic. Nothing under `extensions/builtin/compaction/` or core compaction is touched, and no `session_compact` handler is registered.
- File-store-only auth: per-account sandboxed HOMEs with `AGENT_CLI_CREDENTIAL_STORE=file` and sentinel top-level credential fields. No ambient request lane exists. When the lane is explicitly enabled (`enabled: true`; it defaults to false), `cursor-agent` resolves, and no managed account exists, the default reader copies Senpi's stored native `cursor` OAuth credential into one managed `native` slot without modifying the source. Reading the user's desktop/CLI store or system keychain remains explicit via `/cursor-account import local`; `CURSOR_API_KEY` is never set and the system keychain is never written.
- Explicit opt-in: `cursorCliOauthProvider.enabled` defaults to false, so a logged-in host `cursor-agent` alone never makes the lane available and never triggers native-credential bootstrap. A verbatim `enabled: false` (settings or `SENPI_CURSOR_CLI_OAUTH_ENABLED=0`) is a kill switch that returns disabled before any credential work; with the flag merely absent, stored managed accounts - which exist only after an explicit `/login cursor-cli-oauth` or `/cursor-account import` - keep the lane available, because that login IS the opt-in. `isCursorCliOauthLaneEnabled` is the single rule shared by `assessConfiguration`, the turn path, and the bootstrap gate. Automatic bootstrap never writes `noApprovalAcknowledgedAt`; force execution still requires its separate acknowledgement.
- The CLI executes its own tools autonomously: `--force` requires the explicit `noApprovalAcknowledgedAt` acknowledgement, and provider tool frames never enter assistant text or bridge onto senpi tools.
- Chats are private to each account's HOME; cross-account failover always starts a fresh chat with a notice and never transfers context.
- Process lifecycle: children spawn detached in their own process group and are killed by tracked pid only - never by name matching (senpi #823). Deferred continuations are fenced per extension generation (senpi #866).
- Provider registration never blocks on the executable; `check` reports one of `configured (file-store, <n> accounts)`, `disabled by settings` (kill switch or a flagless lane with no stored accounts), `cursor-agent not installed: <guidance>`, or `no accounts: run /login cursor-cli-oauth`.
+ - No cursor-agent spawn unless the lane is `configured`: the startup catalog probe, the turn path, and `check` share `assessConfiguration`, and every spawn (turn, `models`, `--version`) uses `cursorAgentEnvironment` - an inherited `process.env` never reaches the CLI (senpi#1722).
- Every behavior change adds a `changes.md` section with expected conflict zones.
## TESTS
`packages/coding-agent/test/cursor-cli-oauth/*.test.ts` (20 suites) plus the hermetic `test/fixtures/fake-cursor-agent.mjs`; every unit/integration test points `SENPI_CURSOR_AGENT_EXECUTABLE` at the fixture, so no test needs the real binary, the network, or credentials. Real-CLI probes are opt-in behind `SENPI_CURSOR_CLI_LIVE=1` under `.agents/skills/senpi-qa/scripts/probes/cursor-cli/`.
## MERGE RISK
LOW inside this fork-new directory. The two shared files (`extensions/builtin/index.ts`, `provider-display-names.ts`) are the standard collision points for any new builtin lane; see `changes.md` for the expected zones.