diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e629527663..c67b5fc698 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,6 +11,7 @@ on: - "knip.json" - "package-lock.json" - "package.json" + - "packages/cli/**" - "packages/control-plane/**" - "packages/github-bot/**" - "packages/linear-bot/**" @@ -33,6 +34,7 @@ on: - "knip.json" - "package-lock.json" - "package.json" + - "packages/cli/**" - "packages/control-plane/**" - "packages/github-bot/**" - "packages/linear-bot/**" @@ -163,6 +165,31 @@ jobs: - name: Run control-plane unit tests run: npm test -w @open-inspect/control-plane + test-cli: + name: Test (CLI) + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + persist-credentials: false + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: "22" + cache: "npm" + + - name: Install dependencies + run: npm ci + + - name: Build shared package + run: npm run build -w @open-inspect/shared + + - name: Run CLI tests + run: npm test -w @open-inspect/cli + test-cp-integration: name: Test (control-plane integration ${{ matrix.shard }}) runs-on: ubuntu-latest diff --git a/AGENTS.md b/AGENTS.md index 39acf6e0aa..c368da848a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,7 +28,7 @@ events back through the same WebSocket chain. ### Package Dependency Graph ``` -@open-inspect/shared ← control-plane, web, slack-bot, github-bot, linear-bot +@open-inspect/shared ← cli, control-plane, web, slack-bot, github-bot, linear-bot ``` **Build `@open-inspect/shared` first** whenever you change shared types. Other packages import from @@ -39,6 +39,7 @@ it at build time. | Package | Lang / Framework | Purpose | | --------------- | ---------------------------------- | ----------------------------------------------------------- | | `shared` | TypeScript | Shared types, auth utilities, model definitions | +| `cli` | TypeScript / Commander / MCP SDK | Session CLI and local stdio MCP server | | `control-plane` | TypeScript / CF Workers + DO | Session management, WebSocket streaming, GitHub integration | | `web` | TypeScript / Next.js 16 + React 19 | User-facing dashboard, OAuth, real-time UI | | `slack-bot` | TypeScript / CF Workers + Hono | Slack event handler, session creation | @@ -60,6 +61,7 @@ npm run format # Prettier only npm run typecheck # tsc across all TS packages # Tests — TypeScript (Vitest) +npm test -w @open-inspect/cli npm test -w @open-inspect/control-plane # unit tests (node env) npm run test:integration -w @open-inspect/control-plane # integration (workerd/Miniflare + real D1) npm test -w @open-inspect/web diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index aa29510767..acd9639298 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -47,6 +47,7 @@ npm test | Package | Description | | -------------------------- | ------------------------------------ | +| `packages/cli` | Session CLI and local stdio MCP | | `packages/control-plane` | Cloudflare Workers + Durable Objects | | `packages/web` | Next.js web application | | `packages/sandbox-runtime` | Shared in-sandbox agent runtime | diff --git a/README.md b/README.md index 58c915e481..73ed254421 100644 --- a/README.md +++ b/README.md @@ -120,6 +120,7 @@ ownership, bots, and member suspension. | Package | Description | | ------------------------------------------------- | ------------------------------------------- | +| [cli](packages/cli) | Session CLI and local stdio MCP server | | [control-plane](packages/control-plane) | Cloudflare Workers + Durable Objects | | [web](packages/web) | Next.js web client | | [sandbox-runtime](packages/sandbox-runtime) | Shared in-sandbox agent runtime | diff --git a/docs/plans/mcp-cli.md b/docs/plans/mcp-cli.md new file mode 100644 index 0000000000..53d9b98974 --- /dev/null +++ b/docs/plans/mcp-cli.md @@ -0,0 +1,1208 @@ +# Open-Inspect MCP and CLI + +## Status + +Full V1 product requirements, reconciled with the workspace RBAC implementation merged on +2026-08-31. The V1 implementation now covers device login, revocable CLI credentials, discovery, all +supported session targets, attachments, session operations and outputs, non-interactive CLI output, +and the local stdio MCP server. Hosted MCP remains a fast follow. + +The public command name in examples is `oi`. The final binary and package names remain a release +decision and do not change the requirements. + +## Summary + +Open-Inspect will let individual developers and their AI agents launch and manage coding sessions +without using the web interface. V1 provides two automation-oriented surfaces with the same +request/response operations: + +- A non-interactive CLI with human-readable, JSON, and streaming NDJSON output. +- A local stdio MCP server distributed with the CLI and authenticated through the same CLI login. + +Both surfaces use a shared, server-authorized control-plane contract. The primary workflow is: + +1. A developer authenticates the CLI through the existing Open-Inspect web identity flow. +2. The developer configures their AI client to launch the local Open-Inspect MCP server. +3. The AI discovers repositories, environments, models, reasoning options, and skills. +4. The AI creates an Open-Inspect session with an initial prompt and optional attachments. +5. The AI reads historical events, follows incremental progress, waits for the session to settle, + and sends follow-up prompts when needed. +6. The AI reads resulting diffs, artifacts, pull requests, and child-session state. + +Session creation is asynchronous and returns a session ID after initialization and optional initial +prompt preparation, without waiting for agent execution. Separate operations expose history, live +progress, follow-up prompting, stopping, and settlement waits. V1 does not expose raw secrets, +administrative settings, automation CRUD, skill CRUD, session deletion, or child-session creation. + +A hosted remote MCP server is a fast-follow product surface. V1 does not depend on it; the local MCP +server establishes the tool contract and validates demand while reusing CLI authentication. + +## Decisions + +| Area | V1 decision | +| ------------------- | ----------------------------------------------------------------------------------------------------------------- | +| Primary user | Individual developers and AI agents acting on their behalf. | +| Primary job | Launch, observe, and continue Open-Inspect sessions programmatically. | +| Scope | Sessions plus read-only discovery and session-related output resources. | +| CLI mode | Non-interactive and automation-oriented; no interactive chat UI. | +| MCP deployment | Local stdio server bundled with the CLI. | +| Hosted MCP | Fast follow, not required for V1. | +| Capability model | CLI and MCP expose the same request/response operations; CLI uniquely adds continuous terminal streaming. | +| Authentication | Browser login locally; URL and one-time code for headless systems. | +| Credential lifetime | Revocable 30-day CLI login, invalidated when user access is removed. | +| API keys | Personal API keys are not required for V1. | +| Service accounts | Deferred unless required for initial operations or deployment. | +| Authorization | Enforced by the control plane through the merged workspace RBAC registry and workspace-wide session permissions. | +| Session creation | Asynchronous, with optional initial prompt and attachments. | +| Session target | Match current product modes: repository set, saved environment, or no repository. | +| Events | Persisted session events are the historical source of truth; live output follows the same event model. | +| Waiting | Explicit wait operation reports when a session settles. | +| Output | Human-readable text, JSON, and NDJSON event streams. | +| MCP tools | Narrow, composable tools rather than one broad management tool. | +| Secrets | Never readable; no new raw-secret input through CLI or MCP. | +| Related resources | Repositories, environments, models, reasoning options, and skills are read-only discovery surfaces. | +| Session outputs | Artifacts, diffs, pull requests, and child sessions are read-only, except follow-up prompts to an existing child. | +| Webhooks | Not required; polling and live streams cover V1. | + +## Motivation + +Open-Inspect currently provides session creation and interaction through its web application and bot +integrations. The control plane already has HTTP routes, a session WebSocket protocol, persisted +events, canonical identities, repository/environment discovery, and session-related output APIs. +These interfaces are not available through a general external login or a first-party management CLI. + +Customers want their existing AI agents to delegate coding work into Open-Inspect. The initiating +agent needs to create a session, observe its trajectory, wait for it to settle, inspect the result, +and send follow-up instructions. Requiring a person to translate each action through the web UI +breaks that delegation loop. + +The researched products validate this workflow through combinations of session APIs, local CLIs, +hosted MCP servers, event streams, and agent/run wait operations. Devin exposes direct session +management through hosted MCP. Cursor separates durable agents from executions and exposes a +resumable run stream. Ona exposes broad environment and agent controls through its CLI and Connect +API. Open-Inspect already has most of the underlying session behavior; the missing product boundary +is external authentication and a stable automation-oriented interface. + +## Users + +### Individual developer + +The developer owns an Open-Inspect identity and has already been admitted to an installation. They +want to use the CLI directly or grant their local AI client the ability to act as them. + +### User-operated AI agent + +The agent runs in a desktop application, terminal harness, editor, or remote development system. It +connects to the local MCP server, uses the developer's authenticated identity, and delegates one or +more coding tasks to Open-Inspect. + +### Platform automation + +CI systems and unattended organizational services are likely future users. V1 does not optimize for +them because browser/device login represents a human user and service-account requirements are not +yet critical. + +## Jobs To Be Done + +- When my AI agent identifies work suited to a background coding agent, I want it to launch an + Open-Inspect session so work can proceed independently. +- When an Open-Inspect session is running, I want my AI agent to see incremental progress and know + when work has settled so it can decide whether to continue, inspect, or report back. +- When a session needs clarification or additional work, I want my AI agent to send a follow-up + prompt into the existing context. +- When work completes, I want my AI agent to inspect the trajectory, diff, artifacts, pull requests, + and child-session state so it can evaluate the result. +- When choosing a target, model, or skill profile, I want my AI agent to discover valid current + options instead of relying on hard-coded identifiers. +- When I use the CLI on a remote machine, I want to authenticate from another browser-capable device + without transferring a web cookie or long-lived raw API key manually. + +## Goals + +- Let an admitted user authenticate a CLI through the existing web identity system. +- Support browser-capable and browserless/headless machines. +- Let the user's AI client invoke Open-Inspect through a local MCP server. +- Provide equivalent session capabilities in the CLI and MCP server where the protocols permit. +- Create sessions against repositories, multi-repository targets, saved environments, or no + repository. +- Configure title, model, reasoning effort, skill selection, provider-account selection, initial + prompt, and attachments during launch where the current product supports them. +- List and inspect sessions. +- Send prompts and attachments to an existing session. +- Read paginated historical trajectories from persisted events. +- Display incremental live progress and provide an explicit wait-until-settled operation. +- Stop active session execution. +- Discover selectable repositories, environments, models, reasoning options, and managed skills. +- Read artifacts, diffs, pull-request state, and child-session state. +- Preserve server-side authorization, auditability, known-secret redaction, immediate policy changes + for HTTP requests, and a five-minute maximum revocation bound for active streams and waits. + +## Non-Goals + +- Interactive terminal chat or a terminal recreation of the web session UI. +- Replacing the Open-Inspect web application. +- Personal API-key creation and management. +- General service-account or workload-identity support. +- Hosted remote MCP availability in V1. +- Automation create, update, delete, pause, resume, or trigger operations. +- Skill create, update, delete, assignment, profile, or revision management. +- Repository or environment creation and mutation. +- Secret listing, reading, creation, mutation, or raw secret injection. +- Session archive, unarchive, or delete. +- Explicit child-session creation or cancellation. +- Pull-request creation or mutation from the external interface. +- Artifact upload from the external interface. +- Outbound completion webhooks. +- Replay of transport-specific socket frames; persisted event revisions are recovered through the + forward checkpoint feed instead. +- A new client-side authorization model. +- Multi-tenant authorization beyond the installation's current and emerging RBAC model. + +## Product Principles + +### Agent-first, human-operable + +Every core operation must be deterministic and usable without prompts, menus, or terminal control +sequences. Human-readable output remains available, but structured output is a first-class contract. + +### One capability model + +CLI commands and MCP tools map to the same product operations and server-side authorization checks. +Protocol-specific conveniences may differ, but a user must not need one surface to complete a core +session workflow started through the other. + +### Async by default + +Creating or prompting a session acknowledges accepted work quickly. Observation and waiting are +separate operations. This avoids holding a creation request open for the lifetime of an agent task +and permits many delegated sessions to run concurrently. + +### Persisted state over transient state + +Historical event reads and canonical session snapshots are authoritative. Live streams reduce +latency but do not become a second source of truth. + +### Server-authorized + +The CLI and MCP server do not infer access from local state. Every request is authenticated and +authorized by the control plane. The local MCP process is not a security boundary. + +### Secrets stay opaque + +Discovery can expose that a selectable configuration exists, but neither CLI nor MCP returns secret +values. V1 requires an explicit external-output projection that removes credential fields and +redacts known Open-Inspect-managed secret values from structured events and errors. Diffs and +artifacts are user/session-authored content already visible through the product; V1 does not claim +to detect arbitrary secrets embedded in that content or in unstructured third-party tool output. + +## V1 Scope + +### Capability Matrix + +| Capability | CLI | Local MCP | Notes | +| ----------------------------- | --- | ------------------------ | -------------------------------------------------------- | +| Login/logout/status | Yes | Uses CLI credential | Login remains a CLI operation. | +| List repositories | Yes | Yes | Read-only selectable targets. | +| List/get environments | Yes | Yes | Read-only selectable targets. | +| List models/reasoning options | Yes | Yes | Current supported values. | +| List managed skills/profiles | Yes | Yes | Read-only session selections. | +| List provider accounts | Yes | Yes | Installation metadata gated by `provider_accounts.read`. | +| Create session | Yes | Yes | Optional initial prompt and attachments. | +| List sessions | Yes | Yes | Bounded pagination. | +| Get session | Yes | Yes | Canonical snapshot and related links. | +| Send prompt | Yes | Yes | Supports attachments. | +| Stop execution | Yes | Yes | Does not delete or archive. | +| List historical events | Yes | Yes | Paginated persisted trajectory. | +| Follow live events | Yes | No streaming tool result | MCP uses event pages and wait calls. | +| Wait for settlement | Yes | Yes | Returns canonical terminal session state. | +| List messages | Yes | Yes | Higher-level conversation view. | +| Read artifacts | Yes | Yes | List metadata and retrieve supported content. | +| Read diffs | Yes | Yes | Session/repository-aware. | +| Read pull requests | Yes | Yes | No create/update operation. | +| List/get child sessions | Yes | Yes | No spawn operation. | +| Prompt existing child | Yes | Yes | Uses child-specific server behavior where required. | +| Automation CRUD | No | No | Deferred. | +| Skill CRUD | No | No | Deferred. | + +### Surface Boundary + +V1's supported products are the CLI and local MCP server. They communicate with a versioned external +control-plane contract. Direct use of the underlying HTTP contract by third-party applications is +not a separately supported V1 product surface, although requests and responses remain structured and +versioned so the first-party clients can evolve safely. + +## Authentication Experience + +### Login Command + +`oi login` starts one device authorization flow that works on local and remote machines. + +On a browser-capable machine: + +1. The CLI requests a short-lived login attempt and receives a high-entropy device secret plus a + separate human-readable user code. +2. The CLI opens the verification URL in the default browser. +3. The user completes the existing GitHub or Google web authentication flow. +4. The browser shows the requesting device and asks the user to approve CLI access. +5. The CLI receives a revocable credential associated with the canonical user and installation. +6. The CLI displays the authenticated user and credential expiration date. + +On a headless machine: + +1. `oi login --no-browser` prints the verification URL and human-readable user code while retaining + the high-entropy device secret locally. +2. The user opens the URL on another device and enters or confirms the code. +3. The user completes the same web login and approval flow. +4. The waiting CLI receives the credential without the user copying a bearer token back to the + remote machine. + +If automatic browser opening fails, normal `oi login` displays the same URL and code rather than +failing the flow. + +### Login Requirements + +- The one-time code expires after 10 minutes, is single-use, and is safe to display in terminal + output because possession still requires approval through an authenticated browser. +- The CLI polls with a separate unguessable device secret that is never shown in the browser or + terminal. The control plane stores only its hash. +- Approval atomically binds the authenticated user, installation, user code, and device-secret hash. + Exactly one polling client can exchange an approved attempt for a credential; later exchanges + fail. +- The approval page must identify the Open-Inspect installation and requesting CLI device. +- The CLI must not store or reuse the browser's Better Auth cookie. +- The resulting CLI credential represents the canonical authenticated user. +- The user-facing login lasts no more than 30 days and displays its expiration. +- The credential may use shorter-lived access tokens internally, provided refresh is automatic and + bounded by the 30-day login. +- Every authenticated external request must check the RBAC `users.suspended_at`, role assignment, + and required permission. Suspending the user or changing their role takes effect on the next HTTP + request without waiting for local credential expiration. +- Explicit server-side revocation must invalidate the credential. +- V1 event follow and wait behavior uses bounded HTTP polling, so every poll reauthorizes current + role and suspension state. A later socket-based external stream must use the merged five-minute + wall-clock authorization lease and reconnect after expiry. +- `oi logout` must revoke the current credential when the server is reachable and always remove the + local copy. +- `oi auth status` must report installation, user identity, expiration, and whether reauthentication + is required without printing credential material. +- Re-running `oi login` must replace the active credential for that installation only after the new + login succeeds. +- A login attempt must never authorize a different installation than the one displayed to the user. + +### Credential Storage + +The CLI stores credentials in the operating system credential store when available. A file fallback +must use user-only filesystem permissions and clearly report its location. Structured command +output, logs, diagnostics, crash reports, and MCP errors must never include credential values. + +### Multiple Installations + +The credential model must identify the Open-Inspect installation/base URL. V1 may use one active +context at a time, but login data must not silently cross installations. Commands must provide a way +to inspect and select the active context if more than one login is retained. + +### Service Accounts and API Keys + +V1 does not require personal API keys or service accounts. The external authentication boundary must +leave room for service-account credentials later without treating a human device credential as a +service identity. + +## Authorization and RBAC + +The workspace RBAC implementation merged on 2026-08-31 is the authorization source for V1. CLI/MCP +does not add a parallel external-client permission or per-credential scopes. It uses the same +code-owned permission registry, suspension state, route policy metadata, deny-by-default behavior, +and user authorization service as web requests. + +- Every external request resolves to the canonical human principal represented by the CLI + credential. +- CLI credentials authenticate directly as `principal.kind: "user"`; they are not a first-party + service and do not use bot/service capability ceilings. The explicit external V1 route allowlist + is the product capability ceiling. +- Authentication never implies authorization. +- The local CLI, local MCP server, and AI client are not trusted to filter unauthorized data. +- Missing role assignments, suspended users, unknown permissions, policy errors, and authorization + service failures deny access. +- Sessions are workspace resources. Creator and participant fields are attribution and filters, not + authorization boundaries. +- HTTP requests load current policy on every request. Existing session WebSockets close when their + non-renewed five-minute authorization lease expires; mutating socket commands recheck their + required permission when invoked. +- CLI credentials add no permission beyond the user's current role. +- The approval page states that the connected AI client can exercise the user's current role, + including session mutations that role permits. + +### Operation Permissions + +| CLI/MCP operation | Required merged RBAC permission(s) | +| ---------------------------------------------------- | ----------------------------------------------------------------- | +| Repository list | `repositories.read` | +| Environment list/get | `environments.read` | +| Create with repository target | `sessions.create` and `repositories.use` | +| Create with environment target | `sessions.create` and `environments.use` | +| Create without a target | `sessions.create` | +| Create with initial prompt/attachments | Creation permissions above plus `sessions.collaborate` | +| Create with managed skills | `skills.read`, unless selection is explicitly `none` | +| Create with an existing profile | `skills.read`, `skill_profiles.manage_own`, and profile ownership | +| Skills discovery | `skills.read`; profiles require `skill_profiles.manage_own` | +| Provider-account discovery/explicit selection | `provider_accounts.read` | +| Session list/get/events/messages/artifacts/diffs/PRs | `sessions.read` | +| Prompt or attach to session | `sessions.collaborate` | +| Stop session | `sessions.lifecycle` | +| List/read child | `sessions.read` plus direct parent/child validation | +| Prompt existing child | `sessions.collaborate` plus direct parent/child validation | + +Model and reasoning discovery requires an active assigned user and returns only models enabled by +workspace policy. It does not grant `models.preferences.manage`. + +The external create adapter performs provider-account and skill/profile checks before account or +skill resolution and before session initialization. The current internal create route does not +enforce these selection-specific grants and is not sufficient by itself. + +The default roles consequently behave as follows: + +- Owner and Administrator can read and operate every session. +- Member can create, read, collaborate with, stop, sandbox-access, and delete every session. +- Viewer can read every session but cannot create, prompt, attach, stop, or access its sandbox. +- Custom roles receive only their registered permissions. + +The external surface exposes only the narrower V1 operation set even when a role has additional web +permissions such as session delete or archive. Route authorization remains workspace-wide exactly as +documented in `docs/AUTH.md`. + +## Session Experience + +### Discover Targets and Options + +Before session creation, a client can discover: + +- repositories available to the installation and user; +- saved environments and their ordered repository members; +- supported models; +- valid reasoning-effort values for a selected model where available; +- managed skills and user-selectable skill profiles; +- provider accounts permitted by `provider_accounts.read`, as non-secret ID, provider, status, + default, and display metadata. + +Discovery returns stable identifiers and display metadata. Provider accounts are installation-shared +configuration in the proposed RBAC model, not user-owned resources. Callers with +`provider_accounts.read` can discover and explicitly select them; callers without it omit explicit +selection and use server-resolved defaults. Discovery never returns account tokens or API keys. No +discovery operation returns source-control credentials, repository secrets, environment secrets, or +MCP credentials. + +### Create Session + +Session creation supports the current mutually exclusive target modes: + +- no repository; +- one repository with an optional branch; +- an ordered ad-hoc repository list, where the first repository is primary; +- one saved environment, whose repositories are snapshotted by the existing product behavior. + +The request can configure: + +- title; +- model; +- reasoning effort; +- managed skill selection; +- model-provider account selections supported by the current session contract; +- initial prompt text; +- initial prompt attachments. + +The request does not add a raw secret field or an automatic pull-request behavior field. Pull +requests remain an output of agent work and the existing in-session agent tool. + +External create requests reject unknown, disabled, or unauthorized model, reasoning-effort, skill, +profile, provider-account, repository, and environment selections. They never silently replace an +invalid explicit selection with a default. Omitted selections may continue to use documented +server-side defaults. + +Managed-skill selection is permission-aware. Explicit `none` requires no skill read grant. Explicit +`all`, an omitted selection that defaults to all, or an existing profile requires `skills.read`; +profile use also requires `skill_profiles.manage_own` and ownership of that profile. A caller +lacking those grants receives a permission error rather than silently receiving or dropping managed +skills. + +Before any side effect, composite creation authorizes every requested stage. A request with an +initial prompt or attachment requires both the applicable creation/target permissions and +`sessions.collaborate`; a role with `sessions.create` but no collaborate permission can create only +an unprompted session. Resuming an idempotent partial operation reauthorizes current user +suspension, role permissions, target use, and workspace session permission before upload or prompt +delivery continues. + +The operation returns after the session is initialized and the initial prompt, when supplied, is +accepted for delivery. It does not wait for sandbox startup or task completion. The response +includes at least: + +```json +{ + "sessionId": "session-id", + "status": "created", + "url": "https://open-inspect.example/sessions/session-id" +} +``` + +Every create operation carries a client-generated idempotency key. Repeating a request with the same +key and equivalent input returns the original session result rather than creating another session; +reusing the key with different input returns a conflict. + +Initial attachments require the session ID before they can be uploaded. The product operation +therefore creates the session, uploads every attachment to that session, and only then enqueues the +initial prompt. If any upload or prompt delivery fails, the response identifies the created session, +reports the failed stage, and does not enqueue a prompt with a partial attachment set. Retrying with +the same idempotency key resumes or returns the same operation rather than creating another session. +Images uploaded before a later-stage failure remain bound to the created session and follow normal +session attachment retention; they cannot be referenced from another session. + +### List Sessions + +Session listing uses the current limit/offset pagination, with a maximum page size of 100, and +defaults to newest activity first. Each item includes the fields needed to select a session without +loading its full trajectory: + +- session ID and title; +- canonical session status; +- repository/environment summary; +- creator identity permitted by current visibility rules; +- creation and last-update timestamps; +- archive state where existing records include it; +- web URL; +- parent session ID when the session is a child. + +V1 filtering covers the existing status, excluded-status, automation-lineage, and creator filters. +Repository and environment filters are deferred until the session index supports them directly. +Unsupported filters fail explicitly rather than being silently ignored. + +### Get Session + +Session read returns the canonical session snapshot used by the web product, including current +status, target, participant-visible metadata, active sandbox state, and links or identifiers for +related messages, events, artifacts, diffs, pull requests, and children. It does not embed the full +trajectory by default. + +### Send Prompt + +A caller can enqueue a follow-up with text, attachments, or both. Blank text without attachments is +invalid. The operation returns acknowledgement and message identity without waiting for completion. +Prompt ordering follows the existing session queue behavior. + +Every prompt operation carries a client-generated idempotency key scoped to the canonical user and +session. Repeating equivalent input with the same key returns the original message acknowledgement; +reusing the key with different prompt, attachment, model, or reasoning input returns a conflict. The +idempotency record is retained with the session/message history so a network retry cannot enqueue +duplicate work. + +The caller can select model and reasoning overrides only where the existing prompt contract permits +them. Callback context and integration-owned identity fields are not accepted from CLI/MCP users. An +explicit invalid or disabled model/reasoning combination returns a validation error rather than +being ignored or replaced with a default. + +### Stop Session + +A caller can stop active execution through the current stop semantics. Stop does not archive, +delete, or erase the session. The resulting status remains observable through session reads and +events. + +### Child Sessions + +V1 can list child sessions, inspect one child, read its trajectory, and send a follow-up where the +existing child lifecycle accepts follow-ups. V1 does not expose child creation, explicit model/depth +controls for spawning, or child cancellation. + +The current child-follow-up route is sandbox-authenticated and derives attribution from parent agent +activity. External child prompting therefore requires a new user-authenticated operation that +authorizes the canonical user against the parent/child session tree and records that user as the +source of the follow-up. + +This is an intentional V1 mutation exception: explicit child spawning remains agent-internal, but a +user or user-operated agent may continue an already visible child by addressing that child session. + +The interface must distinguish control-plane child sessions from OpenCode internal subtask activity +that appears inside a single session trajectory. + +## Events and Trajectories + +### Historical Source of Truth + +Persisted session events are the authoritative trajectory. Existing event rows can be updated in +place as text and tool state evolve, so an event ID alone is not an immutable delivery identity. V1 +therefore exposes two related views: + +- A canonical history snapshot containing the latest persisted revision of each event in timeline + order. +- A forward change feed containing every externally visible event creation or revision after an + opaque checkpoint. + +Each event has a stable event ID and a monotonically increasing revision. Each change has a +session-monotonic checkpoint. The snapshot response includes a high-water checkpoint representing +all changes included by that snapshot. Event IDs, revisions, and checkpoints are opaque except for +comparison of revisions belonging to the same event ID. + +The external change page is a deliberate projection of the current internal event contract: + +```json +{ + "checkpoint": "opaque-checkpoint", + "hasMore": false, + "changes": [ + { + "kind": "upsert", + "revision": "opaque-revision", + "event": { + "id": "event-id", + "type": "tool_call", + "messageId": "message-id", + "createdAt": 1788004800000, + "data": {} + } + } + ] +} +``` + +The exact `data` shape depends on event type and follows the shared session event contract. A change +feed request with `afterCheckpoint` returns changes strictly after that checkpoint in forward commit +order. Applying only changes with a revision greater than the client's current revision for that ID +reconstructs current trajectory state without losing tool/text updates. Reusing the same checkpoint +is safe and can replay identical `(id, revision)` pairs. + +Canonical event snapshots are retained with normal session history. The forward revision feed is a +rolling recovery window, retained for up to 24 hours and at most 50,000 revisions per session. The +server may coalesce high-frequency internal token/tool updates before creating an external revision, +but it must persist and publish the final revision of each event. + +When retention removes a requested checkpoint, the server returns `checkpoint_expired` with no +partial changes. The client fetches a fresh canonical snapshot and continues from its new high-water +checkpoint. The server captures that checkpoint consistently so snapshot-then-follow has no gap. + +The existing endpoint returns persisted JSON directly and has no forward update checkpoint, so V1 +requires a new external event projection/change feed. That projection removes fields classified as +credentials and redacts exact values of Open-Inspect-managed secrets available to the session. It +does not claim to detect arbitrary credentials copied into unstructured user or third-party tool +text. + +### Higher-Level Messages + +Clients can also read the persisted message/conversation view used by integrations and the web +application. Messages provide a simpler user/assistant trajectory when a caller does not need every +tool or sandbox event. Event history remains available for complete inspection. + +### Live Progress + +Increment 1 follows progress by repeatedly polling bounded forward change-feed pages after the +snapshot's high-water checkpoint. Human-readable mode renders concise status and assistant progress. +NDJSON mode emits one complete event revision per line and does not mix progress logs into stdout. +The client applies changes in forward checkpoint order, deduplicates identical `(id, revision)` +pairs, and advances its checkpoint only after consuming every page in the bounded response. + +Live broadcast transport and reconnect semantics are deferred. A later live transport must persist +each externally visible revision before broadcast and carry the same event ID, revision, and +checkpoint as the forward change feed. After reconnecting, the client first requests changes after +its last applied checkpoint; a later revision of the same event is emitted and replaces that event +in canonical state. + +### Settlement + +`session_wait` and the corresponding CLI operation wait until the requested session reaches one of +the current canonical terminal statuses: `completed`, `failed`, `cancelled`, or `archived`. +`created` and `active` are not settled. Sandbox state does not alter settlement because session and +sandbox lifecycles are intentionally separate. Child sessions do not delay parent settlement; +callers that need child completion wait on those child session IDs separately. + +The wait result includes the canonical session status, latest assistant message when available, and +identifiers for newly available pull requests or artifacts. A wait timeout is not a session failure; +it returns a distinct timed-out result with current state. V1 does not invent waiting-for-user or +waiting-for-approval states that are absent from the canonical session status contract. + +The implementation may combine live events with canonical status polling. The final result must be +confirmed against canonical persisted session state rather than inferred only from a transient +event. + +## Attachments + +- Initial and follow-up prompts support the image formats currently accepted by the web product: + PNG, JPEG, WebP, and GIF, up to six images and 10 MiB per image. +- The CLI accepts local file paths and uploads content through authenticated Open-Inspect attachment + handling before sending the prompt. +- MCP local-path attachments require roots negotiated through the MCP session. The server resolves + the real path after symlinks and accepts a file only when that final path remains beneath a + granted root. When the client supplies no roots, local-path attachment input is disabled. Tool + arguments cannot add or widen roots. +- `session_prompt` may also accept attachment IDs previously uploaded to that same session. V1 does + not accept arbitrary attachment URLs or attachment IDs from another session. +- Structured errors identify the rejected attachment without exposing local filesystem content. +- Artifact upload is separate from prompt attachments and remains out of scope. + +## Session Outputs + +### Artifacts + +Clients can list the current artifact types: `pr`, `screenshot`, `video`, `preview`, and `branch`. +V1 does not introduce a generic file-artifact abstraction. + +- Screenshot and video bytes remain available through the current protected media path. +- PR and branch artifacts return structured metadata only. +- Preview artifacts return their existing authorized URL/metadata representation; V1 does not + promise arbitrary preview-file downloads. +- Artifact responses preserve artifact ID, type, timestamps, URL when already present, and typed + metadata available for that artifact kind. + +V1 does not upload, modify, or delete artifacts through CLI/MCP. A future generic artifact resource +can add canonical filename, content type, size, and temporary download semantics. + +### Diffs + +Clients can read the current session diff and file-level diff details supported by the web product. +Multi-repository sessions must retain repository identity in diff responses. V1 does not apply or +edit diffs through this interface. + +### Pull Requests + +Clients can list and read pull requests associated with the session, including repository, provider, +number, URL, state, head branch, and base branch where available. The current product stores and +summarizes this data but does not expose a general pull-request read route, so V1 requires a new +read-only projection. V1 does not create, refresh, close, merge, or otherwise mutate pull requests +through CLI/MCP. + +## CLI Requirements + +### Command Shape + +The command hierarchy uses nouns and explicit actions. Illustrative V1 commands are: + +```text +oi login [--no-browser] +oi logout +oi auth status +oi context list +oi context use + +oi repo list +oi environment list +oi environment get +oi model list +oi skill list +oi provider-account list + +oi session create [target and execution options] +oi session list +oi session get +oi session prompt [prompt and attachment options] +oi session stop +oi session events [--follow] +oi session messages +oi session wait +oi session artifacts +oi session diff +oi session prs +oi session children + +oi mcp serve +``` + +The final command grammar can consolidate read-only subresources, but all capability-matrix +operations must remain directly invocable without an interactive menu. + +### Input + +- Commands accept flags for simple values and JSON input for complete structured requests. +- Prompt text can be supplied as an argument, stdin, or a file. +- Attachments can be repeated as local path flags. +- Create and prompt commands generate an idempotency key by default and accept an explicit + `--idempotency-key` so callers can safely retry after an unknown outcome. +- Conflicting target modes fail before creating a session. +- Empty or malformed structured input fails with field-level errors. +- Commands do not prompt for missing business inputs in non-interactive mode. +- Authentication may prompt only as part of the explicit login command. + +### Output + +Every non-authentication command supports: + +- `text`: concise human-readable output; +- `json`: one complete JSON result; +- `stream-json`: NDJSON for operations that can emit progress. + +`json` and `stream-json` write only machine-readable data to stdout. Diagnostics and progress that +are not part of the schema go to stderr. Successful create/prompt commands expose stable +identifiers. Failures return a non-zero process exit code and a structured error in JSON modes when +possible. + +### Exit Behavior + +Exit codes distinguish at least: + +- success; +- invalid local input; +- unauthenticated or expired login; +- forbidden operation; +- resource not found; +- conflict or session state rejection; +- network/service failure; +- wait timeout; +- remote session failure when a command explicitly waits for completion. + +The numeric mapping must be documented and stable for the V1 compatibility period. + +## MCP Requirements + +### Deployment + +The V1 MCP server runs locally over stdio and is launched by an MCP-compatible client. +`oi mcp serve` loads the selected Open-Inspect context and CLI credential, then communicates with +the remote control plane. It must not require the AI client to receive or embed an Open-Inspect +bearer token in MCP configuration. + +An illustrative client configuration is: + +```json +{ + "mcpServers": { + "open-inspect": { + "command": "oi", + "args": ["mcp", "serve"] + } + } +} +``` + +If no valid CLI login exists, server startup or the first tool call returns an actionable error that +directs the user to `oi login`. The MCP server must not start an interactive browser flow inside an +arbitrary AI tool call. + +### Tool Design + +V1 uses narrow tools with explicit verbs and resource scopes: + +| Tool | Purpose | +| ----------------------- | ------------------------------------------------------------------------ | +| `repository_list` | Discover selectable repositories. | +| `environment_list` | Discover saved environments. | +| `environment_get` | Read one environment and its ordered repositories. | +| `model_list` | Discover models and reasoning options. | +| `skill_list` | Discover selectable managed skills/profiles. | +| `provider_account_list` | Discover permitted installation provider-account metadata. | +| `session_create` | Idempotently create a session and optionally enqueue its initial prompt. | +| `session_list` | List visible sessions with pagination and filters. | +| `session_get` | Read canonical session state. | +| `session_prompt` | Idempotently enqueue a follow-up prompt or attachments. | +| `session_stop` | Stop active execution. | +| `session_events` | Read a canonical snapshot page or forward revisions after a checkpoint. | +| `session_messages` | Read the higher-level conversation. | +| `session_wait` | Wait for settlement or timeout. | +| `session_artifacts` | List/read session artifacts. | +| `session_diff` | Read session and file diffs. | +| `session_pull_requests` | Read associated pull requests. | +| `session_children` | List/read child-session state. | +| `session_child_prompt` | Send a follow-up to an existing child session. | + +Tool descriptions must tell an agent when to create a new session, when to send a follow-up, and +when to use wait versus event pagination. They must not imply access to secrets or unsupported +mutation. + +### MCP Behavior + +- Tool inputs and outputs use JSON Schema and stable resource identifiers. +- `session_create` and `session_prompt` require caller-supplied idempotency keys so an MCP client + can reuse the same key after an unknown result. +- Long-running work is represented by `session_wait`, not by holding `session_create` open. +- `session_wait` accepts a caller-controlled timeout and returns current state when it expires. +- Event history uses pagination; one tool response must not attempt to return an unbounded + trajectory. +- Artifact tools return bounded metadata and protected media URLs; diff content uses bounded pages + with explicit truncation and continuation. +- Tool errors distinguish authentication, authorization, validation, not found, conflict, timeout, + and service failure. +- MCP tool calls are attributed to the authenticated canonical user. +- The local server may expose read-only MCP resources later, but tools are sufficient for V1. + +### V1 Limits + +| Resource | Default | Maximum and continuation | +| --------------------------------------------------- | -------------- | ------------------------------------------------------------------------ | +| Session/repository/environment/skill/provider lists | 50 items | 100 items; use limit/offset continuation | +| Event snapshot/change page | 100 revisions | 500 revisions; use page cursor or forward checkpoint | +| Message page | 50 messages | 100 messages; use continuation cursor | +| `session_wait` | 60 seconds | 300 seconds per call; callers may repeat using current session state | +| Diff file list | 50 files | 100 files per page | +| Diff content | 256 KiB | 512 KiB per response; return `truncated: true` and a continuation cursor | +| Serialized MCP tool result | Not applicable | 1 MiB; larger results must paginate or return protected media URLs | + +Every truncated response includes `hasMore` and a continuation cursor/checkpoint. A server never +silently drops content to satisfy a limit. Attachment limits are defined separately by the existing +six-image, 10 MiB-per-image contract. + +### Hosted MCP Fast Follow + +A hosted service is expected to expose the same product capabilities over MCP Streamable HTTP, but +its final tool schemas, authentication, and attachment behavior are not V1 requirements. V1 avoids +unnecessary local-only assumptions in shared session semantics without freezing the hosted contract. + +## External Control-Plane Contract + +The first-party CLI and MCP server require a versioned server contract that covers authentication, +discovery, sessions, prompts, events, and read-only outputs. The contract may adapt existing +internal routes, but external callers must not be required to produce internal service signatures or +forward browser cookies. + +External routes are a new SCM-neutral route family, not aliases of current GitHub-only route +metadata. Repository-less operations work independently of SCM. Repository/environment operations +support GitHub and GitLab through the configured provider and shared repository identity helpers; +Bitbucket remains unsupported until the product implements it. + +### Contract Requirements + +- External routes are explicitly versioned. +- Authentication uses the issued CLI credential or its short-lived derivative. +- Request identity comes from the verified principal, never caller-supplied user fields. +- Existing internal callback context, service-only fields, sandbox credentials, and SCM credential + brokerage remain unavailable. +- Canonical event history uses an opaque page cursor; forward event polling and live recovery use + the external revision checkpoint. Session listing retains bounded limit/offset pagination in V1. +- Session creation and prompt submission require idempotency keys. Stop remains idempotent under its + current lifecycle semantics. Mutation responses distinguish accepted, rejected, and unknown + outcomes so clients do not guess after network failure. +- Create-session response semantics identify partial prompt-delivery failure. +- Errors use one consistent machine-readable envelope with code, message, optional field details, + and request ID. +- Server responses include enough request correlation for support diagnostics without exposing + secrets. + +### Compatibility + +V1 is allowed an explicitly labeled beta period. During beta, schema changes are documented and kept +additive where practical. After the V1 contract is declared stable: + +- existing fields and enum values retain their meanings; +- additive response fields do not require a version change; +- clients ignore unknown additive response fields; +- breaking request, response, authentication, or tool-schema changes require a new version or a + documented deprecation period; +- CLI and local MCP versions report their client version with requests; +- the server returns an actionable incompatibility error for unsupported clients. + +## Error Experience + +All surfaces use a shared error taxonomy. At minimum: + +| Code | Meaning | +| --------------------- | ----------------------------------------------------------- | +| `unauthenticated` | Login is absent, expired, revoked, or invalid. | +| `forbidden` | Identity is valid but lacks server-side permission. | +| `invalid_request` | Input failed validation, with field details where possible. | +| `not_found` | Requested visible resource does not exist. | +| `conflict` | Current session/resource state rejects the operation. | +| `rate_limited` | Caller must retry after a server-provided interval. | +| `attachment_rejected` | Attachment type, count, size, or upload failed. | +| `stream_interrupted` | Live observation disconnected before settlement. | +| `checkpoint_expired` | Forward event checkpoint left the rolling recovery window. | +| `wait_timed_out` | Wait duration elapsed; session remains valid. | +| `service_unavailable` | Transient Open-Inspect or provider failure. | +| `incompatible_client` | CLI/MCP version is unsupported. | + +Human-readable errors include an action when one is known, such as logging in again, selecting a +different target, retrying a prompt, or inspecting the session by ID. Machine output does not rely +on parsing error prose. + +## Security and Privacy + +- CLI credentials are bearer-equivalent secrets and must be encrypted by the operating system store + or protected by user-only file permissions. +- Login approval binds the credential to one installation and canonical user. +- Human user codes and high-entropy device secrets are distinct, single-use, expire after 10 + minutes, and are invalidated after success or cancellation; only a hash of the device secret is + stored. +- The control plane records credential creation, use metadata, revocation, and last-seen time + without recording token material. +- Session mutations record the acting canonical user and external client surface. +- High-volume event reads may use aggregate access telemetry rather than one durable audit row per + event, provided security investigations can identify the principal and request. +- The external-output projection removes credential fields and redacts exact known values from + Open-Inspect-managed secret stores before returning events or errors. Arbitrary unstructured text + is not represented as having perfect secret detection. +- External clients cannot call sandbox-only, callback-only, or SCM credential-broker endpoints. +- Local MCP configuration contains only the command/context selector, not a copied credential. +- Attachment paths and local file contents are never included in analytics or errors beyond the + minimum user-visible filename needed to identify a failure. +- Rate limits apply per credential/principal and protect session creation, prompt submission, event + reads, and login polling. + +## Reliability Requirements + +- A successful session-create acknowledgement always includes a usable session ID. +- Retrying session creation with the same idempotency key returns the original session; reusing the + key with different input returns a conflict. +- Retrying a prompt with the same per-user/session idempotency key returns the original message; + reusing the key with different input returns a conflict. +- Prompt acknowledgement distinguishes accepted, rejected, and unknown delivery outcomes while + retaining the key for safe recovery from an unknown network result. +- Event history and forward-change pages have deterministic ordering and no omission across page + boundaries. +- Live-follow recovery resumes after the last applied checkpoint and deduplicates only identical + event ID/revision pairs. +- `session_wait` confirms settlement from canonical session state. +- A CLI/MCP process restart does not lose server-side session state or require the session to be + recreated. +- Login polling tolerates transient network failures until the login attempt expires. +- Logout removes local credentials even when remote revocation cannot be reached and reports the + incomplete remote revocation. +- One failing session does not terminate event following or waits for unrelated sessions run by the + same external agent. + +## Observability and Audit + +The product records or derives: + +- login attempts, approvals, expirations, revocations, and failures; +- active CLI/MCP client versions; +- command/tool operation name and outcome; +- canonical user and installation; +- session IDs created or mutated; +- latency and error class for create, prompt, event, and wait operations; +- live-stream connection duration and disconnect reason; +- wait duration and settled outcome; +- attachment upload count, size category, and rejection class without content; +- use of CLI versus local MCP; +- rate-limit events and unsupported-client errors. + +Audit data must not include prompts, event bodies, diffs, artifacts, credentials, or secret values +by default. + +## Success Measures + +V1 is successful when a developer's existing AI client can complete this workflow without the web UI +after initial login: + +1. Discover a repository or environment. +2. Launch a session with an initial prompt. +3. Receive the session ID after any initial attachment upload and prompt acceptance, without waiting + for agent execution. +4. Observe incremental progress or poll persisted events. +5. Wait until the session settles. +6. Inspect the final conversation and diff/PR/artifact state. +7. Send a follow-up prompt and observe the next trajectory. + +Product telemetry will measure: + +- successful login completion rate by browser-capable versus headless flow; +- users who create at least one session through CLI/MCP after login; +- sessions created through CLI versus MCP; +- successful create-to-first-event rate; +- successful wait-to-settlement rate; +- live-stream disconnect and reconciliation rate; +- follow-up prompt acceptance rate; +- authentication expiration/revocation failures; +- frequency of external agents inspecting outputs after settlement. + +Numerical adoption targets are set for the customer beta once the initial cohort and traffic volume +are known. Functional acceptance does not depend on an arbitrary adoption target. + +## Implementation Increments + +### Increment 1: Core Delegation Loop + +The first production increment delivers a complete, repository-less, text-only loop rather than +partially exposing every V1 resource: + +- browser/headless device login, logout, status, 30-day revocable CLI credentials; +- direct human-user bearer authentication and current RBAC checks; +- repository-less session create with an initial text prompt and idempotency; +- session list/get, idempotent text follow-up, stop, resumable event-change polling, and bounded + wait; +- non-interactive text/JSON/NDJSON CLI output; +- local stdio MCP tools for the same operations. + +Increment 1 rejects repository/environment targets, attachments, managed-skill/profile selections, +explicit provider accounts, child operations, pull-request reads, and generic output downloads. +Fields are rejected explicitly rather than ignored. Its bounded event change feed provides pinned +snapshots, monotonic checkpoints, coalesced upserts, and delete tombstones. Changes are retained for +up to 24 hours and at most 50,000 revisions per session; an older checkpoint returns +`checkpoint_expired` so the client can resume from a fresh snapshot. Live transport remains later +work. The external route family remains versioned so later increments add capabilities without +changing the core login/session contract. + +### Later V1 Increments + +Later increments add discovery and targets, attachments with negotiated MCP roots, skill/provider +selection, live event transport, artifacts/diffs/PRs, and child reads/follow-up. Full V1 is complete +only when every acceptance criterion below is met. + +## Acceptance Criteria + +### Authentication + +- A user can run `oi login`, authenticate through the configured GitHub or Google web provider, and + return to an authenticated CLI. +- A headless user can complete the same login from another device using a one-time code. +- The human-readable user code cannot poll or redeem a credential; only the initiating CLI's + high-entropy device secret can exchange one approved attempt, exactly once. +- A CLI login expires within 30 days and reports its expiration. +- Suspending the user or removing a required workspace permission affects the next CLI/MCP HTTP poll + or mutation. +- V1 follow/wait polling stops on the first denied request. A future socket stream reconnects after + the merged five-minute wall-clock authorization lease expires. +- `oi logout` removes the local credential and attempts server revocation. +- No command, log, JSON result, or MCP response prints issued CLI credentials or structured + managed-credential fields. User/session-authored content retains the separately documented + unstructured-content boundary. + +### Session Workflow + +- CLI and MCP can discover valid repositories, environments, models, reasoning options, and skills + when the current role permits them. `provider_accounts.read` gates installation provider-account + discovery and explicit selection. +- CLI and MCP can create repository, multi-repository, environment, and repository-less sessions + according to current target validation. +- Composite creation verifies create, target-use, and collaborate permissions before creating the + session or uploading attachments. +- Initial and follow-up prompts accept supported attachments. +- Create returns a session ID before agent completion. +- CLI and MCP can list and read the created session. +- CLI can follow incremental events in text and NDJSON formats. +- CLI and MCP can read the same persisted historical trajectory in deterministic order. +- CLI and MCP can wait for settlement and distinguish timeout from session failure. +- CLI and MCP can send a follow-up and observe subsequent events. +- Retrying create or prompt with the same idempotency key cannot create duplicate sessions or + messages. +- Explicit invalid model, reasoning, target, skill, profile, or provider-account selections fail + validation rather than falling back silently. +- CLI and MCP can read associated messages, artifacts, diffs, pull requests, and children. +- Unsupported mutations, including delete, archive, child spawn, PR creation, and secret access, are + absent rather than merely hidden in documentation. + +### Authorization and Safety + +- Every operation is authorized by the control plane as the canonical logged-in user. +- Login requires an active RBAC assignment and explicitly discloses that the client inherits the + user's current role permissions. +- A client cannot assert another user, service, callback context, or sandbox identity. +- A client cannot retrieve secret-store values or credential fields from repository, environment, + model-provider, MCP, or integration configuration. Known managed secret values are redacted from + structured external events and errors. Diffs, artifacts, prompts, and arbitrary tool text retain + the same user-visible content boundary as the web product and are not represented as secret-free. +- Missing role permissions produce `forbidden`; authenticated roles with `sessions.read` can read + all workspace sessions. +- Member can read, prompt, and stop any workspace session; Viewer can read but cannot create, + prompt, attach, or stop; Administrator and Owner can operate any session. +- Revoked and expired credentials fail closed. + +### Protocol Quality + +- Structured CLI output is valid JSON or NDJSON with no mixed stdout diagnostics. +- MCP tool schemas are discoverable and enforce the documented V1 page, response, diff, and wait + limits. +- MCP path attachments cannot escape negotiated roots, including through symlinks; path attachments + are unavailable when no roots are granted. +- Error codes are consistent between CLI and MCP. +- Snapshot plus forward-checkpoint reconciliation does not omit event revisions or emit the same + `(eventId, revision)` twice; later revisions of one event remain observable. +- A checkpoint outside the 24-hour/50,000-revision recovery window returns `checkpoint_expired` and + a fresh snapshot resumes observation without treating the condition as session failure. +- Client and server versions are observable for compatibility diagnostics. + +## Dependencies + +- Existing canonical user and Better Auth web login. +- A device authorization and CLI credential lifecycle in the control plane. +- The merged workspace RBAC role assignment, `suspended_at` state, route policies, service ceilings, + and five-minute WebSocket authorization leases. +- Enforced server-side workspace permissions shared with web principals. +- Existing session create, prompt, stop, snapshot, event, message, diff, artifact metadata, and + child-session routes. +- New external projections for sanitized events, pull-request reads, and typed artifact metadata not + already covered by current media routes. +- A forward event revision/checkpoint feed shared by polling and live observation. +- Retry-safe prompt idempotency keyed by canonical user, session, and client request ID. +- A new user-authenticated child-follow-up operation with canonical-user authorization and + attribution; the current sandbox-authenticated child route is not sufficient. +- Existing repository, environment, model, reasoning, skill, and provider-account discovery data. +- Existing attachment upload and prompt attachment handling. +- Stable shared session and event contracts. +- Packaging and distribution for the CLI and local MCP server. + +## Risks and Constraints + +### Browser identity does not currently equal external API authentication + +The current browser backend signs requests as `service:web` and forwards the browser session. The +CLI cannot safely copy that browser behavior or cookie. V1 requires a distinct revocable credential +that still resolves to the same canonical user. + +### Current authorization is broad + +Open-Inspect is single-workspace and session permissions are intentionally workspace-wide. CLI +credentials do not add per-credential scopes; they inherit the canonical user's current role. This +means a Member-authorized local AI can read, prompt, stop, sandbox-access, or delete any session in +the web product, although the V1 external surface deliberately omits sandbox access and deletion. + +### Event schemas evolve with agent runtimes + +The web trajectory contains event-type-specific payloads, including tool details. A stable envelope, +additive parsing, redaction, and version reporting are needed so external agents do not couple to +incidental internal fields. + +### MCP tool responses are bounded + +Complete trajectories and diffs can exceed practical MCP response limits. Pagination and summaries +prevent one tool call from returning unbounded content; artifact tools return bounded metadata and +existing protected media URLs rather than generic file bytes. + +### Local MCP inherits local-user trust + +Any local process that can invoke the MCP server through an authorized client can ask it to perform +operations as the logged-in user. The MCP process cannot replace operating-system isolation or the +AI client's own tool-approval controls. + +### Session create plus initial prompt spans existing operations + +The current session creation and prompt interfaces are separate. The external product operation must +make partial success explicit so retries do not create duplicate sessions or lose the session ID. + +## Fast Follow + +The expected first fast-follow release exposes comparable product capabilities through a hosted +remote MCP server using MCP Streamable HTTP. It adds remote MCP authentication and authorization +without requiring the local CLI process. Exact tool and attachment contracts are determined in that +release rather than constrained by V1 acceptance. + +Other post-V1 candidates are: + +- service accounts and scoped machine credentials; +- user-managed personal API keys; +- explicit credential scopes; +- automation CRUD and run inspection; +- managed skill CRUD; +- outbound session webhooks; +- MCP resources for read-only trajectories and artifacts; +- child-session creation and cancellation; +- session archive, unarchive, and delete; +- direct public API and generated SDK support; +- transport-specific hosted stream optimizations beyond the durable event revision feed. + +## Open Questions + +- Final binary, package, and command name (`oi` is a working name). +- Supported operating systems and installation channels for the first beta. +- Whether one active installation context is sufficient for V1 UI, even if credentials retain + multiple contexts. +- Beta compatibility window and minimum supported CLI version policy. +- Hosted MCP authentication method and timing after local MCP validation. + +## Related Documentation + +- `docs/AUTH.md` +- `packages/shared/src/rbac.ts` +- `public/docs/internal/2026-08-29-mcp-external-agent-interfaces-research.md` +- `docs/HOW_IT_WORKS.md` +- `packages/control-plane/README.md` +- `packages/shared/src/types/session-api.ts` +- `packages/control-plane/src/session/event-stream.ts` +- `packages/control-plane/src/routes/session-create.ts` +- `packages/control-plane/src/routes/session-prompt.ts` +- `packages/control-plane/src/routes/session-runtime-proxy.ts` +- `packages/control-plane/src/routes/session-children.ts` +- `packages/control-plane/src/routes/session-child-spawn.ts` +- `packages/control-plane/src/routes/session-ws-token.ts` +- `packages/web/src/lib/control-plane.ts` +- `packages/control-plane/src/auth/authenticate.ts` diff --git a/knip.json b/knip.json index e8d4b84a47..a7a3e562be 100644 --- a/knip.json +++ b/knip.json @@ -28,6 +28,10 @@ "src/types/repositories.ts": ["duplicates"] } }, + "packages/cli": { + "entry": ["src/**/*.test.ts"], + "project": ["src/**/*.ts"] + }, "packages/control-plane": { "entry": ["test/integration/**/*.test.ts"], "project": ["src/**/*.ts", "test/**/*.ts", "scripts/**/*.ts"] diff --git a/package-lock.json b/package-lock.json index 3c0cb3a554..ccf8a5f784 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2518,6 +2518,18 @@ "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", "license": "MIT" }, + "node_modules/@hono/node-server": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.1.tgz", + "integrity": "sha512-ELuehkj5VCBdgEw9zs+ivkKwyzzUCSQuE96YmiPvn1ECBoZCczbFXJLeEGMTYjphP6gydh4pHMqEYPVMYUVgQg==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "hono": "^4" + } + }, "node_modules/@humanfs/core": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", @@ -3222,6 +3234,288 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz", + "integrity": "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9 || ^2.0.5", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/@napi-rs/keyring": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring/-/keyring-2.0.0.tgz", + "integrity": "sha512-TnrIt0nO9U2Ue9E9vJQjso1hqhIYiGrn2Ew1HBsMSqQe4+ceOqPJJwRVsV0/Wy7pqp04/doOLASyIFM3gGwZJw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "optionalDependencies": { + "@napi-rs/keyring-darwin-arm64": "2.0.0", + "@napi-rs/keyring-darwin-x64": "2.0.0", + "@napi-rs/keyring-freebsd-x64": "2.0.0", + "@napi-rs/keyring-linux-arm-gnueabihf": "2.0.0", + "@napi-rs/keyring-linux-arm64-gnu": "2.0.0", + "@napi-rs/keyring-linux-arm64-musl": "2.0.0", + "@napi-rs/keyring-linux-riscv64-gnu": "2.0.0", + "@napi-rs/keyring-linux-x64-gnu": "2.0.0", + "@napi-rs/keyring-linux-x64-musl": "2.0.0", + "@napi-rs/keyring-win32-arm64-msvc": "2.0.0", + "@napi-rs/keyring-win32-ia32-msvc": "2.0.0", + "@napi-rs/keyring-win32-x64-msvc": "2.0.0" + } + }, + "node_modules/@napi-rs/keyring-darwin-arm64": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-darwin-arm64/-/keyring-darwin-arm64-2.0.0.tgz", + "integrity": "sha512-yvIfviiXpsDSsPdyzWWd7STZt7v774biPfMBpWkiK7rwauwWbOmVjUzgiJ11rbhJbWLqXE3CuQMyLgvcbm3jIA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-darwin-x64": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-darwin-x64/-/keyring-darwin-x64-2.0.0.tgz", + "integrity": "sha512-XJUONH0c5cg7M9/1Vj3WeIi8TtWYdZlo8Jqho09ga8OWm9cFNxUv7+4QZx5UK/3JSp2qiyhTlresyKdcpwpnRw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-freebsd-x64": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-freebsd-x64/-/keyring-freebsd-x64-2.0.0.tgz", + "integrity": "sha512-u/M114J9Lp3RqtIZihIqhvreNQ5f8wgLFo9tJFy+bIIO/xEHkMR0LENrfUF8hSY6FTNyS2kSbbkN6yOItBFY5g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-arm-gnueabihf": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-arm-gnueabihf/-/keyring-linux-arm-gnueabihf-2.0.0.tgz", + "integrity": "sha512-CHMv/KTuELo/MsrGUha52KrFGPBuBkeAonnMFUd3nNcxBTSmeJbCAZTuVSXzpessdpb4tT9xIMQsv2ZdWrl8iw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-arm64-gnu": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-arm64-gnu/-/keyring-linux-arm64-gnu-2.0.0.tgz", + "integrity": "sha512-BeUUPGSnW026yDGT4pKuNXDnwxw0xslwiSK6cuOIsDNLi3UO93rfF/7moqKznrBldgsZr8pl9LkMdRk8bnbjEA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-arm64-musl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-arm64-musl/-/keyring-linux-arm64-musl-2.0.0.tgz", + "integrity": "sha512-zj7wZ23Vs7SL4odnGDbWnhZhiyrnEgBe4+8dzKHDi1mTBX1d00FYlGOubkjvx3AeK7mm6G2FyFDDFqIxyR82gA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-riscv64-gnu": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-riscv64-gnu/-/keyring-linux-riscv64-gnu-2.0.0.tgz", + "integrity": "sha512-xk/1SOhuk2yQvXiN+pBhR4njSfquLm1SUKUIJcPIeYV1bHFXbUsYrXdfy4NtynSW0lhs41zWjLXSdE8TLeTt2g==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-x64-gnu": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-x64-gnu/-/keyring-linux-x64-gnu-2.0.0.tgz", + "integrity": "sha512-12Dq6t2TOrQTibcJcfV5bnHbTvMwEz6zSDqQLHMO2x388gGFQBAeEvw7Hmt+R1QavxspTa2ptgI7axomu9TH+w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-x64-musl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-x64-musl/-/keyring-linux-x64-musl-2.0.0.tgz", + "integrity": "sha512-7NJZvFUiL1FPCrSIQ1L4IUzN6l/2zoxT3IN6j3rHTuDdIEunJaycrUovDMvPlBNP1Vh3fSYz6Pp7tFjdt/1/oQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-win32-arm64-msvc": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-win32-arm64-msvc/-/keyring-win32-arm64-msvc-2.0.0.tgz", + "integrity": "sha512-JTktZGXKow0HF/rhaZiQYB8DUS/iSX7S7FvJDoGxPT8mGavKE3w/vadtfKSBNDL4uGef3wu2Ll0XxSfDKBsm2Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-win32-ia32-msvc": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-win32-ia32-msvc/-/keyring-win32-ia32-msvc-2.0.0.tgz", + "integrity": "sha512-AzvIFTqn1hJzCPu0foeYWn+kXruAIrVq3Z2IfJ24WPbIXUdD7+rcj49fqco7b14Z6C1aUfsSTyvrAgDfUWhKvA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-win32-x64-msvc": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-win32-x64-msvc/-/keyring-win32-x64-msvc-2.0.0.tgz", + "integrity": "sha512-POpEUTV6U+pb69cpuOgtLV4xhcyEWDWq+/9zdiZmNxTL1AenE0MllWrbepS+Hng4M7EarQ5TB3kX69ASqGwfIw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.3.tgz", @@ -3580,6 +3874,10 @@ "integrity": "sha512-S8u2cJzklBC0FgTwWVLaM8tMrDuDMVE4xiTK4EYXM9GntyvrdbSoxqDQa+Fh57CCNApyIpyeqPhhFEmHPfrXgw==", "license": "MIT" }, + "node_modules/@open-inspect/cli": { + "resolved": "packages/cli", + "link": true + }, "node_modules/@open-inspect/control-plane": { "resolved": "packages/control-plane", "link": true @@ -7003,7 +7301,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", - "dev": true, "license": "MIT", "dependencies": { "mime-types": "^3.0.0", @@ -7075,6 +7372,45 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-formats/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, "node_modules/ansi-colors": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", @@ -7749,15 +8085,6 @@ "url": "https://paulmillr.com/funding/" } }, - "node_modules/better-auth/node_modules/jose": { - "version": "6.2.4", - "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.4.tgz", - "integrity": "sha512-N8acGzVsQy6M/fjFcxtysNc4Q379TcM5dM/qKkNtsHFji88yANnXTr7BLeP75iPnFwBfQzM/jg2BZ9+HZrHCZA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/panva" - } - }, "node_modules/better-call": { "version": "1.3.7", "resolved": "https://registry.npmjs.org/better-call/-/better-call-1.3.7.tgz", @@ -7811,7 +8138,6 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", - "dev": true, "license": "MIT", "dependencies": { "bytes": "^3.1.2", @@ -7836,7 +8162,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -7919,11 +8244,25 @@ "dev": true, "license": "MIT" }, + "node_modules/bundle-name": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", + "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", + "license": "MIT", + "dependencies": { + "run-applescript": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.8" @@ -7965,7 +8304,6 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -8348,7 +8686,6 @@ "version": "14.0.3", "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", - "dev": true, "license": "MIT", "engines": { "node": ">=20" @@ -8372,7 +8709,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", - "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -8386,7 +8722,6 @@ "version": "1.0.5", "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -8417,12 +8752,28 @@ "version": "1.2.2", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.6.0" } }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/cron-parser": { "version": "5.6.0", "resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-5.6.0.tgz", @@ -8439,7 +8790,6 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -8744,6 +9094,34 @@ "dev": true, "license": "MIT" }, + "node_modules/default-browser": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.1.tgz", + "integrity": "sha512-m1pAzaJgZ/gssEqlOhJkPJp8Xly7QyW6xcrkUa2KKcDeDSEMP7X8xipU3snUcfisTQx0w1AGae+9UtJSfVnXGw==", + "license": "MIT", + "dependencies": { + "bundle-name": "^4.1.0", + "default-browser-id": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/default-browser-id": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz", + "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/define-data-property": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", @@ -8762,6 +9140,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/define-lazy-prop": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", + "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/define-properties": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", @@ -8799,7 +9189,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.8" @@ -8932,7 +9321,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", - "dev": true, "license": "MIT" }, "node_modules/electron-to-chromium": { @@ -8953,7 +9341,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.8" @@ -9299,7 +9686,6 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", - "dev": true, "license": "MIT" }, "node_modules/escape-string-regexp": { @@ -9569,7 +9955,6 @@ "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -9590,6 +9975,27 @@ "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", "license": "MIT" }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.1.tgz", + "integrity": "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/execa": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", @@ -9651,7 +10057,6 @@ "version": "5.2.1", "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", - "dev": true, "license": "MIT", "dependencies": { "accepts": "^2.0.0", @@ -9691,11 +10096,29 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-rate-limit": { + "version": "8.7.0", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.7.0.tgz", + "integrity": "sha512-hOwV7WOxXfjRpAM1DSJWZDXx3GhplwD8IfwuwvogD8i1Qnkgosw/H45s4ZnFAUHDAhPjlY9hLBvJhKmGMyY26g==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, "node_modules/express/node_modules/cookie": { "version": "0.7.2", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -9711,7 +10134,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, "license": "MIT" }, "node_modules/fast-glob": { @@ -9756,6 +10178,22 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-uri": { + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.6.tgz", + "integrity": "sha512-7Ical1vFEMr0onbVzEDIreM22I4khW+fzyQPwvAFWBp1iwdshSZRsL4jjRvPG9JP1uiqMHRto+YU6R2/CzDz5Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/fast-xml-builder": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz", @@ -9860,7 +10298,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", - "dev": true, "license": "MIT", "dependencies": { "debug": "^4.4.0", @@ -10025,7 +10462,6 @@ "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -10049,7 +10485,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.8" @@ -10596,7 +11031,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", - "dev": true, "license": "MIT", "dependencies": { "depd": "~2.0.0", @@ -10680,7 +11114,6 @@ "version": "0.7.2", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", - "dev": true, "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" @@ -10754,7 +11187,6 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true, "license": "ISC" }, "node_modules/inline-style-parser": { @@ -10787,11 +11219,19 @@ "node": ">=12" } }, + "node_modules/ip-address": { + "version": "10.7.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.0.tgz", + "integrity": "sha512-BGFsyJd5mpXp3rK6jIdADLNgpJUK1jnjzvYF8lK+VyDab9JAmqN0YOKDdP17HlgKb2+ehPgDc8EtnRLbGCAMhA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.10" @@ -10977,6 +11417,21 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/is-docker": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", + "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-document.all": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-document.all/-/is-document.all-1.0.0.tgz", @@ -11076,6 +11531,36 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/is-in-ssh": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-in-ssh/-/is-in-ssh-1.0.0.tgz", + "integrity": "sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-inside-container": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", + "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", + "license": "MIT", + "dependencies": { + "is-docker": "^3.0.0" + }, + "bin": { + "is-inside-container": "cli.js" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-map": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", @@ -11151,7 +11636,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", - "dev": true, "license": "MIT" }, "node_modules/is-regex": { @@ -11312,6 +11796,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-wsl": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", + "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==", + "license": "MIT", + "dependencies": { + "is-inside-container": "^1.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/isarray": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", @@ -11323,7 +11822,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, "license": "ISC" }, "node_modules/istanbul-lib-coverage": { @@ -11408,6 +11906,15 @@ "jiti": "bin/jiti.js" } }, + "node_modules/jose": { + "version": "6.2.10", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.10.tgz", + "integrity": "sha512-iiW7J9qRFlGxvCOIBDBDxFePQSn7ZMAnrYGhrrOo6siO/MIqwfyilLR27pkfDgUk+raLuzADS8A3S/KLBisc0g==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -11493,6 +12000,12 @@ "dev": true, "license": "MIT" }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", @@ -12452,7 +12965,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.8" @@ -12462,7 +12974,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", - "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -13079,7 +13590,6 @@ "version": "1.54.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -13089,7 +13599,6 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", - "dev": true, "license": "MIT", "dependencies": { "mime-db": "^1.54.0" @@ -13266,7 +13775,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -13961,7 +14469,6 @@ "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -14090,7 +14597,6 @@ "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", - "dev": true, "license": "MIT", "dependencies": { "ee-first": "1.1.1" @@ -14103,7 +14609,6 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, "license": "ISC", "dependencies": { "wrappy": "1" @@ -14142,6 +14647,26 @@ "regex-recursion": "^6.0.2" } }, + "node_modules/open": { + "version": "11.0.2", + "resolved": "https://registry.npmjs.org/open/-/open-11.0.2.tgz", + "integrity": "sha512-RWqF+pBSkqecEvCKOn8QYhaNdRMJDZRIrlS/7rTDdLHaPcfXGCZ/h8zb413NfvdeAV0MR7T1yJcA34/q+CSm1Q==", + "license": "MIT", + "dependencies": { + "default-browser": "^5.5.1", + "define-lazy-prop": "^3.0.0", + "is-in-ssh": "^1.0.0", + "is-inside-container": "^1.0.0", + "powershell-utils": "^0.2.1", + "wsl-utils": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -14350,7 +14875,6 @@ "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.8" @@ -14386,7 +14910,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -14465,6 +14988,15 @@ "node": ">= 6" } }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -14665,6 +15197,18 @@ "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", "license": "MIT" }, + "node_modules/powershell-utils": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.2.1.tgz", + "integrity": "sha512-C+y9x90UElAddDZmV4qOx9W53B61PO7cIqWz2dQsWlwswuq4mr8NEwytdGKboYbQlGZ3awrkTeNvcZiZNHnQ8A==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -14766,7 +15310,6 @@ "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", - "dev": true, "license": "MIT", "dependencies": { "forwarded": "0.2.0", @@ -14790,7 +15333,6 @@ "version": "6.15.2", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", - "dev": true, "license": "BSD-3-Clause", "dependencies": { "side-channel": "^1.1.0" @@ -14826,7 +15368,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -14836,7 +15377,6 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", - "dev": true, "license": "MIT", "dependencies": { "bytes": "~3.1.2", @@ -15265,7 +15805,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -15399,7 +15938,6 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", - "dev": true, "license": "MIT", "dependencies": { "debug": "^4.4.0", @@ -15416,13 +15954,24 @@ "version": "8.4.2", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", - "dev": true, "license": "MIT", "funding": { "type": "opencollective", "url": "https://opencollective.com/express" } }, + "node_modules/run-applescript": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", + "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -15505,7 +16054,6 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true, "license": "MIT" }, "node_modules/saxes": { @@ -15541,7 +16089,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", - "dev": true, "license": "MIT", "dependencies": { "debug": "^4.4.3", @@ -15568,7 +16115,6 @@ "version": "2.2.1", "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", - "dev": true, "license": "MIT", "dependencies": { "encodeurl": "^2.0.0", @@ -15649,7 +16195,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", - "dev": true, "license": "ISC" }, "node_modules/sharp": { @@ -15714,7 +16259,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -15727,7 +16271,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -15756,7 +16299,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -15776,7 +16318,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -15793,7 +16334,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -15812,7 +16352,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -15952,7 +16491,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.8" @@ -16535,7 +17073,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.6" @@ -16636,7 +17173,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", - "dev": true, "license": "MIT", "dependencies": { "content-type": "^2.0.0", @@ -16655,7 +17191,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -16941,7 +17476,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.8" @@ -17057,7 +17591,6 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.8" @@ -17352,7 +17885,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -17590,7 +18122,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true, "license": "ISC" }, "node_modules/ws": { @@ -17615,6 +18146,34 @@ } } }, + "node_modules/wsl-utils": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-1.0.0.tgz", + "integrity": "sha512-Hl0ZOAs672vg+06kfujwRhoS6/jehvULrlFkuF2dRu6pHgA8U06h3xqNIqNNU1LTXPcedxByAR4GS6pwQK0mgA==", + "license": "MIT", + "dependencies": { + "is-wsl": "^3.1.0", + "powershell-utils": "^0.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/wsl-utils/node_modules/powershell-utils": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.1.0.tgz", + "integrity": "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/xml-name-validator": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", @@ -17766,6 +18325,15 @@ "url": "https://github.com/sponsors/colinhacks" } }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + }, "node_modules/zwitch": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", @@ -17776,6 +18344,32 @@ "url": "https://github.com/sponsors/wooorm" } }, + "packages/cli": { + "name": "@open-inspect/cli", + "version": "0.1.0", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.17.4", + "@napi-rs/keyring": "^2.0.0", + "@open-inspect/shared": "file:../shared", + "commander": "^14.0.0", + "open": "^11.0.2", + "zod": "^4.4.3" + }, + "bin": { + "oi": "dist/bin.js" + }, + "devDependencies": { + "esbuild": "^0.28.1", + "typescript": "^5.7.2", + "vitest": "^4.1.0" + }, + "engines": { + "node": ">=22.13.0" + }, + "optionalDependencies": { + "@napi-rs/keyring": "^2.0.0" + } + }, "packages/control-plane": { "name": "@open-inspect/control-plane", "version": "0.1.0", diff --git a/packages/cli/README.md b/packages/cli/README.md new file mode 100644 index 0000000000..9f2fca4591 --- /dev/null +++ b/packages/cli/README.md @@ -0,0 +1,71 @@ +# `@open-inspect/cli` + +The `oi` CLI and bundled local stdio MCP server provide the full V1 automation surface: discovery, +targeted session creation, prompts and image attachments, event following, settlement waits, and +read-only session outputs. + +Context metadata is stored separately from credentials. Immutable credential references make context +rotation atomic: readers observe either the complete old URL/credential pair or the complete new +pair. The CLI uses `@napi-rs/keyring` for macOS Keychain, Linux Secret Service, and Windows +Credential Manager. If that optional native module is unavailable, it explicitly falls back to an +atomic file in the platform configuration directory. Native operation failures do not copy secrets +into the fallback. The fallback is forced to mode `0600` on POSIX systems; Windows does not provide +equivalent mode guarantees. Use `OPEN_INSPECT_CONFIG_DIR` to relocate and profile the fallback +store. + +```bash +oi login --url https://control-plane.example.com +oi auth status +oi session list --limit 50 --offset 0 --output json +oi session create --title "Investigate" --model opencode/kimi-k2.5 \ + --repo-owner acme --repo-name app --attach ./context.png \ + --idempotency-key retryable-create-id +oi repo list --output json +oi environment list --output json +oi session events --follow --output stream-json +oi session wait +oi mcp serve +``` + +`oi context list` lists contexts without credentials and `oi context use ` switches the active +context. Use `oi login --context ` to add or replace one. + +Session listing accepts a shared bounded `limit` and zero-based `offset` in both the CLI and MCP +tool. When `hasMore` is true, pass the returned `continuationOffset` as the next offset. Create +reasoning is optional for models without reasoning support; follow-up `--reasoning` remains an +independent optional override. + +Before polling, login stores the device authorization secret and recovery metadata. If exchange +issues a credential but local staging or promotion fails, that secret can revoke the issued +credential without bearer plaintext, including after process restart. Newly issued credentials are +also stored under a deterministic reference derived from the server credential ID and recorded as +pending revocation. One atomic promotion installs the new context, changes device recovery to +local-only cleanup, removes the bearer staging marker, and moves any replaced credential to the +pending queue. Login and logout drain both queues. Logout always removes the local active context. +Its result reports `remoteRevocationComplete: false` when remote revocation or queued recovery is +incomplete. + +`oi session events --follow` reads one complete, checkpoint-pinned initial snapshot and then +polls the bounded change feed without rescanning full history. Each emitted record is an `upsert` or +`delete` change in forward commit/checkpoint order; delete/upsert pairs represent event renames. +Revision comparisons apply only to records with the same event ID, and the server may coalesce +high-frequency revisions. A checkpoint advances only after all cursor pages have completed. Changes +are retained for up to 24 hours and at most 50,000 revisions per session; an expired checkpoint +causes the CLI to resume from a fresh snapshot. + +`oi session prompt [prompt]` accepts prompt text positionally, through `--content`, from +`--content-file` (use `-` for stdin), or as a complete request object through `--input`. Use +`--idempotency-key` to make retries safe; `--client-request-id` remains accepted for compatibility. +Artifact lists remain available through `oi session artifacts `; add `--artifact ` to fetch +screenshot or video content as bounded base64. Pull request lists remain available through +`oi session prs `; add `--pr ` to retrieve one pull request. + +All error modes use the stable `{ "error": { "code", "message", ... } }` envelope. Text mode renders +the same envelope, while JSON modes serialize it directly. Failed create and prompt requests include +their retry identifier under `error.context` so an unknown outcome can be retried safely. MCP waits +accept at most 300,000 ms, and the complete MCP tool-result envelope is capped at 1 MiB. + +Exit codes are stable for V1: `0` success, `1` general failure, `2` authentication, `3` invalid +input, `4` conflict/session-state rejection, `5` timeout, `6` transport failure, `7` service +failure, `8` not found, `9` expired checkpoint, `10` rate limited, `11` forbidden, `12` remote +session failure, and `13` incompatible client. diff --git a/packages/cli/package.json b/packages/cli/package.json new file mode 100644 index 0000000000..f197386eda --- /dev/null +++ b/packages/cli/package.json @@ -0,0 +1,34 @@ +{ + "name": "@open-inspect/cli", + "version": "0.1.0", + "private": true, + "type": "module", + "bin": { + "oi": "dist/bin.js" + }, + "engines": { + "node": ">=22.13.0" + }, + "scripts": { + "build": "esbuild src/bin.ts --bundle --format=esm --platform=node --target=node22 --outfile=dist/bin.js --sourcemap --external:commander --external:zod --external:@modelcontextprotocol/sdk/* --external:@napi-rs/keyring", + "lint": "eslint src/", + "lint:fix": "eslint src/ --fix", + "test": "vitest run", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.17.4", + "@open-inspect/shared": "file:../shared", + "commander": "^14.0.0", + "open": "^11.0.2", + "zod": "^4.4.3" + }, + "devDependencies": { + "esbuild": "^0.28.1", + "typescript": "^5.7.2", + "vitest": "^4.1.0" + }, + "optionalDependencies": { + "@napi-rs/keyring": "^2.0.0" + } +} diff --git a/packages/cli/src/api-client.test.ts b/packages/cli/src/api-client.test.ts new file mode 100644 index 0000000000..6a23211f86 --- /dev/null +++ b/packages/cli/src/api-client.test.ts @@ -0,0 +1,258 @@ +import { describe, expect, it, vi } from "vitest"; +import { ApiClient, ApiError } from "./api-client.js"; +import { CliError, exitCodeFor } from "./errors.js"; + +const credential = `oi_cli_${"a".repeat(64)}`; + +function json(value: unknown, status = 200): Response { + return Response.json(value, { status }); +} + +describe("ApiClient", () => { + it("uses public device auth endpoints without persisting or authorizing the device secret", async () => { + const fetch = vi + .fn() + .mockResolvedValueOnce( + json( + { + deviceSecret: "a".repeat(64), + userCode: "ABCD-EFGH", + verificationUrl: "https://web.example.com/cli/authorize", + expiresAt: Date.now() + 60_000, + pollIntervalMs: 1, + }, + 201 + ) + ) + .mockResolvedValueOnce(json({ status: "pending", expiresAt: Date.now() + 60_000 }, 202)) + .mockResolvedValueOnce(new Response(null, { status: 204 })); + const client = new ApiClient({ baseUrl: "https://api.example.com/", fetch }); + + const started = await client.startDeviceAuthorization("test host"); + await client.exchangeDeviceAuthorization(started.deviceSecret); + await client.revokeDeviceAuthorization(started.deviceSecret); + + expect(fetch.mock.calls.map(([request]) => new URL(String(request)).pathname)).toEqual([ + "/external/v1/cli/device-authorizations", + "/external/v1/cli/device-authorizations/exchange", + "/external/v1/cli/device-authorizations/revoke", + ]); + expect(new Headers(fetch.mock.calls[1]?.[1]?.headers).has("Authorization")).toBe(false); + expect(new Headers(fetch.mock.calls[2]?.[1]?.headers).has("Authorization")).toBe(false); + }); + + it("reauthorizes every request and preserves caller-supplied request IDs", async () => { + const credentials = [credential, credential.replace("a", "b")]; + const authorize = vi.fn(() => Promise.resolve(credentials.shift())); + const fetch = vi + .fn() + .mockResolvedValueOnce(json({ sessions: [], hasMore: false })) + .mockResolvedValueOnce(json({ messageId: "message-1", status: "queued" })); + const client = new ApiClient({ + baseUrl: "https://api.example.com", + authorize, + fetch, + }); + + await client.listSessions(); + await client.promptSession("session/with slash", { + content: "Continue", + clientRequestId: "caller-request", + }); + + expect(authorize).toHaveBeenCalledTimes(2); + expect(new Headers(fetch.mock.calls[0]?.[1]?.headers).get("Authorization")).toBe( + `Bearer ${credential}` + ); + expect(new URL(String(fetch.mock.calls[1]?.[0])).pathname).toBe( + "/external/v1/sessions/session%2Fwith%20slash/messages" + ); + expect(JSON.parse(String(fetch.mock.calls[1]?.[1]?.body))).toMatchObject({ + clientRequestId: "caller-request", + }); + }); + + it("encodes checkpoint, cursor, and limit event query options", async () => { + const fetch = vi + .fn() + .mockImplementation(() => + Promise.resolve(json({ changes: [], checkpoint: 12, cursor: "next page", hasMore: true })) + ); + const client = new ApiClient({ + baseUrl: "https://api.example.com", + authorize: () => Promise.resolve(credential), + fetch, + }); + + await client.events("s1", { after: 10, limit: 200 }); + await client.events("s1", { cursor: "next page" }); + + expect(new URL(String(fetch.mock.calls[0]?.[0])).search).toBe("?after=10&limit=200"); + expect(new URL(String(fetch.mock.calls[1]?.[0])).searchParams.get("cursor")).toBe("next page"); + await expect(client.events("s1", { after: 1, cursor: "invalid" })).rejects.toMatchObject({ + name: "ZodError", + }); + }); + + it("validates and encodes bounded session list pagination", async () => { + const fetch = vi + .fn() + .mockResolvedValue(json({ sessions: [], hasMore: true, continuationOffset: 125 })); + const client = new ApiClient({ + baseUrl: "https://api.example.com", + authorize: () => Promise.resolve(credential), + fetch, + }); + + await expect(client.listSessions({ limit: 25, offset: 100 })).resolves.toEqual({ + sessions: [], + hasMore: true, + continuationOffset: 125, + }); + expect(new URL(String(fetch.mock.calls[0]?.[0])).search).toBe("?limit=25&offset=100"); + await expect(client.listSessions({ limit: 201 })).rejects.toMatchObject({ name: "ZodError" }); + }); + + it("encodes artifact cursors and revision-bound diff continuations", async () => { + const fetch = vi + .fn() + .mockResolvedValueOnce(json({ artifacts: [], cursor: "next-artifact", hasMore: true })) + .mockResolvedValueOnce( + json({ + version: 1, + current: null, + lastError: null, + unavailableReason: null, + hasMore: false, + }) + ); + const client = new ApiClient({ + baseUrl: "https://api.example.com", + authorize: () => Promise.resolve(credential), + fetch, + }); + + await client.artifacts("s1", { limit: 20, cursor: "artifact cursor" }); + await client.diff("s1", { limit: 20, offset: 20, revisionId: "revision-1" }); + + expect(new URL(String(fetch.mock.calls[0]?.[0])).search).toBe( + "?limit=20&cursor=artifact+cursor" + ); + expect(new URL(String(fetch.mock.calls[1]?.[0])).search).toBe( + "?limit=20&offset=20&revisionId=revision-1" + ); + }); + + it("retrieves bounded binary artifact content and one encoded pull request", async () => { + const pullRequest = { + id: "pr/1", + provider: "github", + repoOwner: "owner", + repoName: "repo", + number: 1, + url: "https://github.com/owner/repo/pull/1", + state: "open", + headBranch: "feature", + baseBranch: "main", + }; + const fetch = vi + .fn() + .mockResolvedValueOnce( + new Response(new Uint8Array([0, 255, 1]), { headers: { "Content-Type": "image/png" } }) + ) + .mockResolvedValueOnce(json(pullRequest)); + const client = new ApiClient({ + baseUrl: "https://api.example.com", + authorize: () => Promise.resolve(credential), + fetch, + }); + + await expect(client.artifactContent("s/1", "artifact/1")).resolves.toEqual({ + contentType: "image/png", + contentBase64: "AP8B", + offset: 0, + hasMore: false, + }); + await expect(client.pullRequest("s/1", "pr/1")).resolves.toEqual(pullRequest); + expect(new URL(String(fetch.mock.calls[0]?.[0])).pathname).toBe( + "/external/v1/sessions/s%2F1/artifacts/artifact%2F1/content" + ); + expect(new URL(String(fetch.mock.calls[1]?.[0])).pathname).toBe( + "/external/v1/sessions/s%2F1/pull-requests/pr%2F1" + ); + }); + + it.each([ + [401, "auth", 2], + [400, "validation", 3], + [409, "conflict", 4], + [408, "timeout", 5], + [404, "not_found", 8], + [410, "expired", 9], + [429, "rate_limited", 10], + [503, "service", 7], + ] as const)("classifies HTTP %s responses as %s errors", async (status, kind, exitCode) => { + const client = new ApiClient({ + baseUrl: "https://api.example.com", + authorize: () => Promise.resolve(credential), + fetch: vi.fn().mockResolvedValue(json({ error: "request failed" }, status)), + }); + const error = await client.listSessions().catch((cause) => cause); + expect(error).toBeInstanceOf(ApiError); + expect(error).toMatchObject({ status, kind }); + expect(exitCodeFor(error)).toBe(exitCode); + }); + + it("never exposes an arbitrary HTML error body", async () => { + const client = new ApiClient({ + baseUrl: "https://api.example.com", + authorize: () => Promise.resolve(credential), + fetch: vi.fn().mockResolvedValue( + new Response("", { + status: 502, + statusText: "Bad Gateway", + }) + ), + }); + const error = await client.listSessions().catch((cause) => cause); + expect(error.message).toContain("Bad Gateway"); + expect(error.message).not.toContain("html"); + }); + + it("distinguishes missing auth, timeout, transport, invalid JSON, and oversized responses", async () => { + const unauthenticated = new ApiClient({ baseUrl: "https://api.example.com", fetch: vi.fn() }); + await expect(unauthenticated.listSessions()).rejects.toMatchObject({ kind: "auth" }); + + const controller = new AbortController(); + controller.abort(); + const timedOut = new ApiClient({ + baseUrl: "https://api.example.com", + authorize: () => Promise.resolve(credential), + fetch: vi.fn().mockRejectedValue(new Error("aborted")), + }); + await expect(timedOut.listSessions({ signal: controller.signal })).rejects.toMatchObject({ + kind: "timeout", + }); + + const transport = new ApiClient({ + baseUrl: "https://api.example.com", + authorize: () => Promise.resolve(credential), + fetch: vi.fn().mockRejectedValue(new TypeError("network down")), + }); + await expect(transport.listSessions()).rejects.toMatchObject({ kind: "transport" }); + + const invalid = new ApiClient({ + baseUrl: "https://api.example.com", + authorize: () => Promise.resolve(credential), + fetch: vi.fn().mockResolvedValue(new Response("not json")), + }); + await expect(invalid.listSessions()).rejects.toBeInstanceOf(CliError); + + const oversized = new ApiClient({ + baseUrl: "https://api.example.com", + authorize: () => Promise.resolve(credential), + fetch: vi.fn().mockResolvedValue(new Response("x".repeat(5 * 1024 * 1024 + 1))), + }); + await expect(oversized.listSessions()).rejects.toMatchObject({ kind: "transport" }); + }); +}); diff --git a/packages/cli/src/api-client.ts b/packages/cli/src/api-client.ts new file mode 100644 index 0000000000..e113e92c22 --- /dev/null +++ b/packages/cli/src/api-client.ts @@ -0,0 +1,568 @@ +import { + cliDeviceAuthorizationExchangeResponseSchema, + cliMeResponseSchema, + CLI_API_VERSION_HEADER, + CLI_CLIENT_SURFACE_HEADER, + CLI_CLIENT_VERSION_HEADER, + CLI_EXTERNAL_API_V1_PATH, + CLI_EXTERNAL_API_VERSION, + revokeCliDeviceAuthorizationRequestSchema, + startCliDeviceAuthorizationResponseSchema, + type CliDeviceAuthorizationExchangeResponse, + type CliMeResponse, + type StartCliDeviceAuthorizationResponse, +} from "@open-inspect/shared/types/cli-auth"; +import { + externalApiErrorResponseSchema, + externalCreateSessionRequestSchema, + externalCreateSessionResponseSchema, + externalEventPageSchema, + externalEventFeedQuerySchema, + externalFollowUpRequestSchema, + externalFollowUpResponseSchema, + externalSessionListQuerySchema, + externalSessionListResponseSchema, + externalSessionSchema, + externalStopSessionResponseSchema, + externalSessionWaitResponseSchema, + type ExternalCreateSessionRequest, + type ExternalFollowUpRequest, + type ExternalEventFeedQuery, + type ExternalSessionListQuery, + type ExternalSession, +} from "@open-inspect/shared/types/external-session-api"; +import { + externalArtifactListResponseSchema, + externalArtifactContentResponseSchema, + externalDiffContentResponseSchema, + externalDiffStateResponseSchema, + externalPullRequestSchema, + externalChildPromptRequestSchema, + externalChildSessionListResponseSchema, + externalChildSessionSchema, + externalEnvironmentListResponseSchema, + externalEnvironmentResponseSchema, + externalDiffListQuerySchema, + externalKeysetListQuerySchema, + externalListQuerySchema, + externalMessageListResponseSchema, + externalModelListResponseSchema, + externalProviderAccountListResponseSchema, + externalPullRequestListResponseSchema, + externalRepositoryListResponseSchema, + externalSkillListResponseSchema, + type ExternalListQuery, + type ExternalDiffListQuery, + type ExternalKeysetListQuery, +} from "@open-inspect/shared/types/external-resources-api"; +import { sessionAttachmentUploadResponseSchema } from "@open-inspect/shared/types/session-attachments"; +import type { z } from "zod"; +import { CliError } from "./errors.js"; + +const SESSIONS_PATH = "/external/v1/sessions"; +const EXTERNAL_PATH = "/external/v1"; +const MAX_SUCCESS_BYTES = 5 * 1024 * 1024; +const MAX_ERROR_BYTES = 16 * 1024; +const DEFAULT_ARTIFACT_CHUNK_BYTES = 512 * 1024; +export type EventPage = z.infer; +export type EventQuery = ExternalEventFeedQuery & { signal?: AbortSignal }; + +export interface ApiClientOptions { + baseUrl: string; + authorize?: () => Promise; + fetch?: typeof globalThis.fetch; + clientSurface?: "cli" | "mcp"; +} + +/** Typed external API transport shared by CLI commands and MCP operations. */ +export class ApiClient { + private readonly baseUrl: string; + private readonly authorize: () => Promise; + private readonly fetch: typeof globalThis.fetch; + private readonly clientSurface: "cli" | "mcp"; + + constructor(options: ApiClientOptions) { + this.baseUrl = options.baseUrl.replace(/\/$/, ""); + this.authorize = options.authorize ?? (() => Promise.resolve(undefined)); + this.fetch = options.fetch ?? globalThis.fetch; + this.clientSurface = options.clientSurface ?? "cli"; + } + + async startDeviceAuthorization(deviceName: string): Promise { + return startCliDeviceAuthorizationResponseSchema.parse( + await this.request( + `${CLI_EXTERNAL_API_V1_PATH}/device-authorizations`, + { + method: "POST", + body: JSON.stringify({ deviceName }), + }, + false + ) + ); + } + + async exchangeDeviceAuthorization( + deviceSecret: string + ): Promise { + return cliDeviceAuthorizationExchangeResponseSchema.parse( + await this.request( + `${CLI_EXTERNAL_API_V1_PATH}/device-authorizations/exchange`, + { + method: "POST", + body: JSON.stringify({ deviceSecret }), + }, + false + ) + ); + } + + async revokeDeviceAuthorization(deviceSecret: string): Promise { + await this.request( + `${CLI_EXTERNAL_API_V1_PATH}/device-authorizations/revoke`, + { + method: "POST", + body: JSON.stringify(revokeCliDeviceAuthorizationRequestSchema.parse({ deviceSecret })), + }, + false + ); + } + + async me(signal?: AbortSignal): Promise { + return cliMeResponseSchema.parse( + await this.request(`${CLI_EXTERNAL_API_V1_PATH}/me`, { signal }) + ); + } + + async revokeCredential(): Promise { + await this.request(`${CLI_EXTERNAL_API_V1_PATH}/credentials/current`, { method: "DELETE" }); + } + + async listRepositories(options: ExternalListQuery = {}) { + return externalRepositoryListResponseSchema.parse( + await this.request(`${EXTERNAL_PATH}/repositories${listSuffix(options)}`) + ); + } + + async listEnvironments(options: ExternalListQuery = {}) { + return externalEnvironmentListResponseSchema.parse( + await this.request(`${EXTERNAL_PATH}/environments${listSuffix(options)}`) + ); + } + + async getEnvironment(id: string) { + return externalEnvironmentResponseSchema.parse( + await this.request(`${EXTERNAL_PATH}/environments/${encodeURIComponent(id)}`) + ); + } + + async listModels() { + return externalModelListResponseSchema.parse(await this.request(`${EXTERNAL_PATH}/models`)); + } + + async listSkills(options: ExternalListQuery = {}) { + return externalSkillListResponseSchema.parse( + await this.request(`${EXTERNAL_PATH}/skills${listSuffix(options)}`) + ); + } + + async listProviderAccounts(options: ExternalListQuery = {}) { + return externalProviderAccountListResponseSchema.parse( + await this.request(`${EXTERNAL_PATH}/provider-accounts${listSuffix(options)}`) + ); + } + + async createSession( + input: ExternalCreateSessionRequest + ): Promise> { + return externalCreateSessionResponseSchema.parse( + await this.request(SESSIONS_PATH, { + method: "POST", + body: JSON.stringify(externalCreateSessionRequestSchema.parse(input)), + }) + ); + } + + async listSessions( + options: ExternalSessionListQuery & { signal?: AbortSignal } = {} + ): Promise> { + const { signal, ...queryOptions } = options; + const parsed = externalSessionListQuerySchema.parse(queryOptions); + const query = new URLSearchParams(); + if (parsed.limit !== undefined) query.set("limit", String(parsed.limit)); + if (parsed.offset !== undefined) query.set("offset", String(parsed.offset)); + if (parsed.status !== undefined) query.set("status", parsed.status); + if (parsed.excludeStatus !== undefined) query.set("excludeStatus", parsed.excludeStatus); + if (parsed.excludeAutomationLineage !== undefined) { + query.set("excludeAutomationLineage", String(parsed.excludeAutomationLineage)); + } + if (parsed.createdBy !== undefined) query.set("createdBy", parsed.createdBy); + const suffix = query.size ? `?${query}` : ""; + return externalSessionListResponseSchema.parse( + await this.request(`${SESSIONS_PATH}${suffix}`, { signal }) + ); + } + + async getSession(id: string, signal?: AbortSignal): Promise { + return externalSessionSchema.parse( + await this.request(`${SESSIONS_PATH}/${encodeURIComponent(id)}`, { signal }) + ); + } + + async promptSession( + id: string, + input: ExternalFollowUpRequest + ): Promise> { + return externalFollowUpResponseSchema.parse( + await this.request(`${SESSIONS_PATH}/${encodeURIComponent(id)}/messages`, { + method: "POST", + body: JSON.stringify(externalFollowUpRequestSchema.parse(input)), + }) + ); + } + + async uploadAttachment(id: string, file: Blob, name: string, idempotencyKey?: string) { + const form = new FormData(); + form.set("file", file, name); + return sessionAttachmentUploadResponseSchema.parse( + await this.request(`${SESSIONS_PATH}/${encodeURIComponent(id)}/attachments`, { + method: "POST", + body: form, + ...(idempotencyKey ? { headers: { "Idempotency-Key": idempotencyKey } } : {}), + }) + ); + } + + async messages(id: string, options: { limit?: number; cursor?: string } = {}) { + const query = new URLSearchParams(); + if (options.limit !== undefined) query.set("limit", String(options.limit)); + if (options.cursor) query.set("cursor", options.cursor); + return externalMessageListResponseSchema.parse( + await this.request( + `${SESSIONS_PATH}/${encodeURIComponent(id)}/messages${query.size ? `?${query}` : ""}` + ) + ); + } + + async artifacts(id: string, options: ExternalKeysetListQuery = {}) { + return externalArtifactListResponseSchema.parse( + await this.request( + `${SESSIONS_PATH}/${encodeURIComponent(id)}/artifacts${keysetListSuffix(options)}` + ) + ); + } + + async artifactContent( + id: string, + artifactId: string, + options: { offset?: number; limit?: number } = {} + ) { + const offset = options.offset ?? 0; + const limit = options.limit ?? DEFAULT_ARTIFACT_CHUNK_BYTES; + if ( + !Number.isSafeInteger(offset) || + offset < 0 || + !Number.isSafeInteger(limit) || + limit < 1 || + limit > DEFAULT_ARTIFACT_CHUNK_BYTES + ) { + throw new CliError("validation", "Artifact offset/limit is invalid"); + } + const response = await this.requestRaw( + `${SESSIONS_PATH}/${encodeURIComponent(id)}/artifacts/${encodeURIComponent(artifactId)}/content`, + { + headers: { + Accept: "image/*,video/*", + Range: `bytes=${offset}-${offset + limit - 1}`, + }, + } + ); + const bytes = await readBoundedBytes(response, DEFAULT_ARTIFACT_CHUNK_BYTES); + const contentRange = response.headers.get("Content-Range")?.match(/^bytes (\d+)-(\d+)\/(\d+)$/); + const total = contentRange ? Number(contentRange[3]) : offset + bytes.byteLength; + const continuationOffset = offset + bytes.byteLength; + return externalArtifactContentResponseSchema.parse({ + contentType: response.headers.get("Content-Type") ?? "application/octet-stream", + contentBase64: Buffer.from(bytes).toString("base64"), + offset, + hasMore: continuationOffset < total, + ...(continuationOffset < total ? { continuationOffset } : {}), + }); + } + + async diff(id: string, options: ExternalDiffListQuery = {}) { + return externalDiffStateResponseSchema.parse( + await this.request( + `${SESSIONS_PATH}/${encodeURIComponent(id)}/diff${diffListSuffix(options)}` + ) + ); + } + + async diffFile( + id: string, + revisionId: string, + fileId: string, + options: { offset?: number; limit?: number } = {} + ) { + const query = new URLSearchParams(); + if (options.offset !== undefined) query.set("offset", String(options.offset)); + if (options.limit !== undefined) query.set("limit", String(options.limit)); + return externalDiffContentResponseSchema.parse( + await this.request( + `${SESSIONS_PATH}/${encodeURIComponent(id)}/diff/${encodeURIComponent(revisionId)}/files/${encodeURIComponent(fileId)}${query.size ? `?${query}` : ""}` + ) + ); + } + + async pullRequests(id: string, options: ExternalListQuery = {}) { + return externalPullRequestListResponseSchema.parse( + await this.request( + `${SESSIONS_PATH}/${encodeURIComponent(id)}/pull-requests${listSuffix(options)}` + ) + ); + } + + async pullRequest(id: string, pullRequestId: string) { + return externalPullRequestSchema.parse( + await this.request( + `${SESSIONS_PATH}/${encodeURIComponent(id)}/pull-requests/${encodeURIComponent(pullRequestId)}` + ) + ); + } + + async children(id: string, options: ExternalListQuery = {}) { + return externalChildSessionListResponseSchema.parse( + await this.request( + `${SESSIONS_PATH}/${encodeURIComponent(id)}/children${listSuffix(options)}` + ) + ); + } + + async child(id: string, childId: string) { + return externalChildSessionSchema.parse( + await this.request( + `${SESSIONS_PATH}/${encodeURIComponent(id)}/children/${encodeURIComponent(childId)}` + ) + ); + } + + async promptChild( + id: string, + childId: string, + input: z.infer + ) { + return externalFollowUpResponseSchema.parse( + await this.request( + `${SESSIONS_PATH}/${encodeURIComponent(id)}/children/${encodeURIComponent(childId)}/messages`, + { + method: "POST", + body: JSON.stringify(externalChildPromptRequestSchema.parse(input)), + } + ) + ); + } + + async stopSession(id: string): Promise> { + return externalStopSessionResponseSchema.parse( + await this.request(`${SESSIONS_PATH}/${encodeURIComponent(id)}/stop`, { method: "POST" }) + ); + } + + async events(id: string, options: EventQuery = {}): Promise { + const { signal, ...queryOptions } = options; + const parsed = externalEventFeedQuerySchema.parse(queryOptions); + const query = new URLSearchParams(); + if (parsed.after !== undefined) query.set("after", String(parsed.after)); + if (parsed.cursor) query.set("cursor", parsed.cursor); + if (parsed.limit !== undefined) query.set("limit", String(parsed.limit)); + const suffix = query.size ? `?${query}` : ""; + return externalEventPageSchema.parse( + await this.request(`${SESSIONS_PATH}/${encodeURIComponent(id)}/events${suffix}`, { + signal, + }) + ); + } + + async waitStatus( + id: string, + signal?: AbortSignal + ): Promise> { + return externalSessionWaitResponseSchema.parse( + await this.request(`${SESSIONS_PATH}/${encodeURIComponent(id)}/wait`, { signal }) + ); + } + + private async request( + path: string, + init: RequestInit = {}, + authenticated = true + ): Promise { + const headers = new Headers(init.headers); + headers.set("Accept", "application/json"); + headers.set(CLI_API_VERSION_HEADER, CLI_EXTERNAL_API_VERSION); + headers.set(CLI_CLIENT_VERSION_HEADER, "0.1.0"); + headers.set(CLI_CLIENT_SURFACE_HEADER, this.clientSurface); + if (init.body !== undefined && !(init.body instanceof FormData)) { + headers.set("Content-Type", "application/json"); + } + if (authenticated) { + const credential = await this.authorize(); + if (!credential) throw new CliError("auth", "Authentication required"); + headers.set("Authorization", `Bearer ${credential}`); + } + + let response: Response; + try { + response = await this.fetch(`${this.baseUrl}${path}`, { ...init, headers }); + } catch (cause) { + if (init.signal?.aborted) + throw new CliError("timeout", "API request was aborted", undefined, undefined, { cause }); + throw new CliError("transport", "API request failed", undefined, undefined, { + cause, + }); + } + if (!response.ok) throw await ApiError.fromResponse(response); + if (response.status === 204) return undefined; + return parseJson(await readBounded(response, MAX_SUCCESS_BYTES), "API response"); + } + + private async requestRaw(path: string, init: RequestInit = {}): Promise { + const credential = await this.authorize(); + if (!credential) throw new CliError("auth", "Authentication required"); + let response: Response; + try { + const headers = new Headers(init.headers); + headers.set("Authorization", `Bearer ${credential}`); + headers.set(CLI_API_VERSION_HEADER, CLI_EXTERNAL_API_VERSION); + headers.set(CLI_CLIENT_VERSION_HEADER, "0.1.0"); + headers.set(CLI_CLIENT_SURFACE_HEADER, this.clientSurface); + response = await this.fetch(`${this.baseUrl}${path}`, { ...init, headers }); + } catch (cause) { + throw new CliError("transport", "API request failed", undefined, undefined, { cause }); + } + if (!response.ok) throw await ApiError.fromResponse(response); + return response; + } +} + +function listSuffix(options: ExternalListQuery): string { + const parsed = externalListQuerySchema.parse(options); + const query = new URLSearchParams(); + if (parsed.limit !== undefined) query.set("limit", String(parsed.limit)); + if (parsed.offset !== undefined) query.set("offset", String(parsed.offset)); + return query.size ? `?${query}` : ""; +} + +function keysetListSuffix(options: ExternalKeysetListQuery): string { + const parsed = externalKeysetListQuerySchema.parse(options); + const query = new URLSearchParams(); + if (parsed.limit !== undefined) query.set("limit", String(parsed.limit)); + if (parsed.cursor !== undefined) query.set("cursor", parsed.cursor); + return query.size ? `?${query}` : ""; +} + +function diffListSuffix(options: ExternalDiffListQuery): string { + const parsed = externalDiffListQuerySchema.parse(options); + const query = new URLSearchParams(); + if (parsed.limit !== undefined) query.set("limit", String(parsed.limit)); + if (parsed.offset !== undefined) query.set("offset", String(parsed.offset)); + if (parsed.revisionId !== undefined) query.set("revisionId", parsed.revisionId); + return query.size ? `?${query}` : ""; +} + +export class ApiError extends CliError { + constructor(status: number, detail: string, context?: Record) { + super(errorKindForStatus(status), `API request failed (${status}): ${detail}`, status, context); + this.name = "ApiError"; + } + + static async fromResponse(response: Response): Promise { + const body = await readBounded(response, MAX_ERROR_BYTES).catch(() => ""); + let detail = response.statusText || "Request failed"; + const context: Record = {}; + const requestId = response.headers.get("X-Request-ID"); + if (requestId) context.requestId = requestId; + const retryAfter = response.headers.get("Retry-After"); + if (retryAfter) context.retryAfter = retryAfter; + if (body) { + try { + const parsed = externalApiErrorResponseSchema.safeParse(JSON.parse(body)); + if (parsed.success) { + detail = parsed.data.error; + if (parsed.data.code) context.code = parsed.data.code; + if (parsed.data.requestId) context.requestId = parsed.data.requestId; + if (parsed.data.permission) context.permission = parsed.data.permission; + } else { + const legacy = JSON.parse(body) as { error?: unknown; code?: unknown }; + if (typeof legacy.error === "string") detail = legacy.error; + if (typeof legacy.code === "string") context.code = legacy.code; + } + } catch { + // Non-JSON error bodies are intentionally ignored. + } + } + return new ApiError( + response.status, + safeApiDetail(detail), + Object.keys(context).length ? context : undefined + ); + } +} + +function errorKindForStatus(status: number): CliError["kind"] { + if (status === 401) return "auth"; + if (status === 403) return "forbidden"; + if (status === 408) return "timeout"; + if (status === 404) return "not_found"; + if (status === 410) return "expired"; + if (status === 426) return "incompatible_client"; + if (status === 429) return "rate_limited"; + if (status >= 500) return "service"; + if (status === 400 || status === 422) return "validation"; + if (status === 409) return "conflict"; + return "general"; +} + +async function readBounded(response: Response, maximumBytes: number): Promise { + return new TextDecoder().decode(await readBoundedBytes(response, maximumBytes)); +} + +async function readBoundedBytes(response: Response, maximumBytes: number): Promise { + if (!response.body) return new Uint8Array(); + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let size = 0; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + size += value.byteLength; + if (size > maximumBytes) { + await reader.cancel(); + throw new CliError("transport", `API response exceeded ${maximumBytes} bytes`); + } + chunks.push(value); + } + const body = new Uint8Array(size); + let offset = 0; + for (const chunk of chunks) { + body.set(chunk, offset); + offset += chunk.byteLength; + } + return body; +} + +function parseJson(body: string, label: string): unknown { + try { + return JSON.parse(body); + } catch (cause) { + throw new CliError("transport", `${label} was not valid JSON`, undefined, undefined, { cause }); + } +} + +function safeApiDetail(detail: string): string { + return ( + detail + .replace(/[\r\n\t]+/g, " ") + .trim() + .slice(0, 512) || "Request failed" + ); +} diff --git a/packages/cli/src/atomic-json-file.ts b/packages/cli/src/atomic-json-file.ts new file mode 100644 index 0000000000..5cdd797c7b --- /dev/null +++ b/packages/cli/src/atomic-json-file.ts @@ -0,0 +1,69 @@ +import { randomUUID } from "node:crypto"; +import { chmod, mkdir, open, readFile, rename, rm, stat, writeFile } from "node:fs/promises"; +import { dirname } from "node:path"; + +const LOCK_RETRY_MS = 10; +const LOCK_TIMEOUT_MS = 5_000; +const STALE_LOCK_MS = 30_000; + +export async function readJsonFile(path: string): Promise { + try { + const value = JSON.parse(await readFile(path, "utf8")); + if (process.platform !== "win32") await chmod(path, 0o600); + return value; + } catch (cause) { + if ((cause as NodeJS.ErrnoException).code === "ENOENT") return undefined; + throw cause; + } +} + +export async function updateJsonFile( + path: string, + read: (value: unknown | undefined) => T, + update: (value: T) => void +): Promise { + await mkdir(dirname(path), { recursive: true, mode: 0o700 }); + const release = await acquireLock(`${path}.lock`); + try { + const value = read(await readJsonFile(path)); + update(value); + await writeJsonFile(path, value); + return value; + } finally { + await release(); + } +} + +async function writeJsonFile(path: string, value: unknown): Promise { + const temporaryPath = `${path}.${process.pid}.${randomUUID()}.tmp`; + try { + await writeFile(temporaryPath, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 }); + if (process.platform !== "win32") await chmod(temporaryPath, 0o600); + await rename(temporaryPath, path); + if (process.platform !== "win32") await chmod(path, 0o600); + } catch (cause) { + await rm(temporaryPath, { force: true }); + throw cause; + } +} + +async function acquireLock(path: string): Promise<() => Promise> { + const deadline = Date.now() + LOCK_TIMEOUT_MS; + while (true) { + try { + const handle = await open(path, "wx", 0o600); + await handle.close(); + return () => rm(path, { force: true }); + } catch (cause) { + if ((cause as NodeJS.ErrnoException).code !== "EEXIST") throw cause; + try { + if (Date.now() - (await stat(path)).mtimeMs > STALE_LOCK_MS) await rm(path); + } catch (lockCause) { + if ((lockCause as NodeJS.ErrnoException).code !== "ENOENT") throw lockCause; + } + if (Date.now() >= deadline) + throw new Error(`Timed out waiting for configuration lock: ${path}`); + await new Promise((resolve) => setTimeout(resolve, LOCK_RETRY_MS)); + } + } +} diff --git a/packages/cli/src/attachments.ts b/packages/cli/src/attachments.ts new file mode 100644 index 0000000000..47906b2d18 --- /dev/null +++ b/packages/cli/src/attachments.ts @@ -0,0 +1,22 @@ +import { SESSION_ATTACHMENT_IMAGE_MAX_BYTES } from "@open-inspect/shared/types/session-attachments"; +import { CliError } from "./errors.js"; + +export function validateAttachmentBytes(bytes: Uint8Array, name: string): void { + if (bytes.byteLength === 0) throw new CliError("validation", `Attachment is empty: ${name}`); + if (bytes.byteLength > SESSION_ATTACHMENT_IMAGE_MAX_BYTES) { + throw new CliError("validation", `Attachment exceeds 10 MiB: ${name}`); + } + if (!isSupportedImage(bytes)) { + throw new CliError("validation", `Attachment is not PNG, JPEG, WebP, or GIF: ${name}`); + } +} + +function isSupportedImage(bytes: Uint8Array): boolean { + const png = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]; + const isPng = png.every((byte, index) => bytes[index] === byte); + const isJpeg = bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff; + const signature = new TextDecoder().decode(bytes.slice(0, 12)); + const isGif = signature.startsWith("GIF87a") || signature.startsWith("GIF89a"); + const isWebp = signature.startsWith("RIFF") && signature.slice(8, 12) === "WEBP"; + return isPng || isJpeg || isGif || isWebp; +} diff --git a/packages/cli/src/bin.ts b/packages/cli/src/bin.ts new file mode 100644 index 0000000000..50442cdd99 --- /dev/null +++ b/packages/cli/src/bin.ts @@ -0,0 +1,7 @@ +#!/usr/bin/env node +import { runCli } from "./cli.js"; +import { exitCodeFor } from "./errors.js"; + +runCli().catch((cause: unknown) => { + process.exitCode = exitCodeFor(cause); +}); diff --git a/packages/cli/src/cli.test.ts b/packages/cli/src/cli.test.ts new file mode 100644 index 0000000000..e37afb6ac8 --- /dev/null +++ b/packages/cli/src/cli.test.ts @@ -0,0 +1,718 @@ +import { readFile, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { mkdtemp } from "node:fs/promises"; +import { describe, expect, it, vi } from "vitest"; +import { createCli, runCli, validateHttpUrl } from "./cli.js"; +import { ConfigStore } from "./config-store.js"; +import type { CredentialStore } from "./credential-store.js"; + +const credential = `oi_cli_${"c".repeat(64)}`; + +describe("CLI commands", () => { + it("aborts before exchange when device authorization recovery cannot be persisted", async () => { + const directory = await mkdtemp(join(tmpdir(), "oi-cli-test-")); + const store = new ConfigStore(directory, { + credentialStore: { + kind: "native", + get: vi.fn(), + set: vi.fn().mockRejectedValue(new Error("keyring locked")), + delete: vi.fn(), + } satisfies CredentialStore, + }); + const fetch = vi.fn().mockResolvedValueOnce( + Response.json( + { + deviceSecret: "d".repeat(64), + userCode: "ABCD-EFGH", + verificationUrl: "https://web.example.com/cli/authorize", + expiresAt: Date.now() + 60_000, + pollIntervalMs: 1, + }, + { status: 201 } + ) + ); + + await expect( + createCli({ store, fetch }).parseAsync([ + "node", + "oi", + "login", + "--no-browser", + "--url", + "https://api.example.com", + ]) + ).rejects.toThrow("keyring locked"); + expect(fetch).toHaveBeenCalledTimes(1); + }); + + it("drains persisted device authorization recovery before starting a new login", async () => { + const directory = await mkdtemp(join(tmpdir(), "oi-cli-test-")); + const store = new ConfigStore(directory); + await store.stageDeviceAuthorization({ + url: "https://old.example.com", + contextName: "old", + deviceSecret: "d".repeat(64), + }); + const fetch = vi + .fn() + .mockResolvedValue(Response.json({ error: "unavailable" }, { status: 503 })); + + await expect( + createCli({ store, fetch }).parseAsync([ + "node", + "oi", + "login", + "--no-browser", + "--url", + "https://new.example.com", + ]) + ).rejects.toThrow("503"); + + expect(fetch).toHaveBeenCalledTimes(1); + expect(String(fetch.mock.calls[0]?.[0])).toBe( + "https://old.example.com/external/v1/cli/device-authorizations/revoke" + ); + expect((await store.read()).pendingDeviceAuthorizations).toHaveLength(1); + }); + + it("persists only the final credential after device authorization", async () => { + const directory = await mkdtemp(join(tmpdir(), "oi-cli-test-")); + const store = new ConfigStore(directory); + const deviceSecret = "d".repeat(64); + const fetch = vi + .fn() + .mockResolvedValueOnce( + Response.json( + { + deviceSecret, + userCode: "ABCD-EFGH", + verificationUrl: "https://web.example.com/cli/authorize", + expiresAt: Date.now() + 60_000, + pollIntervalMs: 1, + }, + { status: 201 } + ) + ) + .mockResolvedValueOnce( + Response.json({ + status: "authorized", + credential, + credentialId: "credential-1", + expiresAt: Date.now() + 60_000, + }) + ); + const stdout: string[] = []; + const stderr: string[] = []; + + await createCli({ + store, + fetch, + stdout: (value) => stdout.push(value), + stderr: (value) => stderr.push(value), + }).parseAsync([ + "node", + "oi", + "login", + "--no-browser", + "--url", + "https://api.example.com/", + "--context", + "work", + ]); + + const contents = await readFile(store.filePath, "utf8"); + expect(contents).not.toContain(credential); + expect(contents).not.toContain(deviceSecret); + expect((await store.getActiveContext()).name).toBe("work"); + expect(stderr.join("")).toContain("ABCD-EFGH"); + expect(stdout.join("")).not.toContain(credential); + }); + + it("retries transient device authorization exchange failures", async () => { + const directory = await mkdtemp(join(tmpdir(), "oi-cli-test-")); + const store = new ConfigStore(directory); + const deviceSecret = "d".repeat(64); + const fetch = vi + .fn() + .mockResolvedValueOnce( + Response.json( + { + deviceSecret, + userCode: "ABCD-EFGH", + verificationUrl: "https://web.example.com/cli/authorize", + expiresAt: Date.now() + 60_000, + pollIntervalMs: 1, + }, + { status: 201 } + ) + ) + .mockResolvedValueOnce(Response.json({ error: "unavailable" }, { status: 503 })) + .mockResolvedValueOnce( + Response.json({ + status: "authorized", + credential, + credentialId: "credential-1", + expiresAt: Date.now() + 60_000, + }) + ); + + await createCli({ store, fetch, sleep: () => Promise.resolve(), stderr: vi.fn() }).parseAsync([ + "node", + "oi", + "login", + "--no-browser", + "--url", + "https://api.example.com", + ]); + + expect(fetch).toHaveBeenCalledTimes(4); + await expect(store.getActiveContext()).resolves.toMatchObject({ credential }); + }); + + it("passes bounded list pagination and exposes the continuation offset", async () => { + const directory = await mkdtemp(join(tmpdir(), "oi-cli-test-")); + const store = new ConfigStore(directory); + await store.saveContext("default", { + url: "https://api.example.com", + credential, + expiresAt: Date.now() + 60_000, + }); + const fetch = vi + .fn() + .mockResolvedValue(Response.json({ sessions: [], hasMore: true, continuationOffset: 75 })); + const stdout: string[] = []; + + await createCli({ store, fetch, stdout: (value) => stdout.push(value) }).parseAsync([ + "node", + "oi", + "--output", + "json", + "session", + "list", + "--limit", + "25", + "--offset", + "50", + ]); + + expect(JSON.parse(stdout.join(""))).toEqual({ + sessions: [], + hasMore: true, + continuationOffset: 75, + }); + expect(new URL(String(fetch.mock.calls[0]?.[0])).search).toBe("?limit=25&offset=50"); + expect(new Headers(fetch.mock.calls[0]?.[1]?.headers).get("Authorization")).toBe( + `Bearer ${credential}` + ); + }); + + it("prints event journal tombstones without rewriting their order or shape", async () => { + const directory = await mkdtemp(join(tmpdir(), "oi-cli-test-")); + const store = new ConfigStore(directory); + await store.saveContext("default", { + url: "https://api.example.com", + credential, + expiresAt: Date.now() + 60_000, + }); + const page = { + changes: [ + { kind: "delete", revision: 10, eventId: "old-name" }, + { + kind: "upsert", + revision: 11, + event: { + id: "new-name", + type: "token", + messageId: null, + createdAt: 1, + data: { text: "renamed" }, + }, + }, + ], + checkpoint: 11, + hasMore: false, + }; + const stdout: string[] = []; + + await createCli({ + store, + fetch: vi.fn().mockResolvedValue(Response.json(page)), + stdout: (value) => stdout.push(value), + }).parseAsync(["node", "oi", "--output", "json", "session", "events", "s1"]); + + expect(JSON.parse(stdout.join(""))).toEqual(page); + }); + + it("removes the local credential when remote logout can be retried", async () => { + const directory = await mkdtemp(join(tmpdir(), "oi-cli-test-")); + const store = new ConfigStore(directory); + await store.saveContext("default", { + url: "https://api.example.com", + credential, + expiresAt: Date.now() + 60_000, + }); + const fetch = vi + .fn() + .mockResolvedValue(Response.json({ error: "unavailable" }, { status: 503 })); + + await expect( + createCli({ store, fetch }).parseAsync(["node", "oi", "logout"]) + ).resolves.toBeDefined(); + await expect(store.getActiveContext()).rejects.toThrow("Not logged in"); + }); + + it("reports incomplete remote logout while removing the local active login", async () => { + const directory = await mkdtemp(join(tmpdir(), "oi-cli-test-")); + const store = new ConfigStore(directory); + await store.saveContext("default", { + url: "https://api.example.com", + credential, + expiresAt: Date.now() + 60_000, + }); + const stdout: string[] = []; + + await createCli({ + store, + fetch: vi.fn().mockResolvedValue(Response.json({ error: "unavailable" }, { status: 503 })), + stdout: (value) => stdout.push(value), + }).parseAsync(["node", "oi", "--output", "json", "logout"]); + + expect(JSON.parse(stdout.join(""))).toMatchObject({ + loggedOut: "default", + remoteRevocationComplete: false, + }); + await expect(store.getActiveContext()).rejects.toThrow("Not logged in"); + }); + + it.each([429, 500])("removes local login after remote HTTP %s", async (status) => { + const directory = await mkdtemp(join(tmpdir(), "oi-cli-test-")); + const store = new ConfigStore(directory); + await store.saveContext("default", { + url: "https://api.example.com", + credential, + expiresAt: Date.now() + 60_000, + }); + + await expect( + createCli({ + store, + fetch: vi.fn().mockResolvedValue(Response.json({ error: "retry" }, { status })), + }).parseAsync(["node", "oi", "logout"]) + ).resolves.toBeDefined(); + await expect(store.getActiveContext()).rejects.toThrow("Not logged in"); + }); + + it("removes local login after a transport failure", async () => { + const directory = await mkdtemp(join(tmpdir(), "oi-cli-test-")); + const store = new ConfigStore(directory); + await store.saveContext("default", { + url: "https://api.example.com", + credential, + expiresAt: Date.now() + 60_000, + }); + + await expect( + createCli({ + store, + fetch: vi.fn().mockRejectedValue(new TypeError("network down")), + }).parseAsync(["node", "oi", "logout"]) + ).resolves.toBeDefined(); + await expect(store.getActiveContext()).rejects.toThrow("Not logged in"); + }); + + it.each([401, 404, 410])( + "removes the local credential when logout proves it invalid with %s", + async (status) => { + const directory = await mkdtemp(join(tmpdir(), "oi-cli-test-")); + const store = new ConfigStore(directory); + await store.saveContext("default", { + url: "https://api.example.com", + credential, + expiresAt: Date.now() + 60_000, + }); + + await createCli({ + store, + fetch: vi.fn().mockResolvedValue(Response.json({ error: "invalid" }, { status })), + }).parseAsync(["node", "oi", "logout"]); + + await expect(store.getActiveContext()).rejects.toThrow("Not logged in"); + } + ); + + it("generates and reports an idempotency key for a one-shot create command", async () => { + const directory = await mkdtemp(join(tmpdir(), "oi-cli-test-")); + const store = new ConfigStore(directory); + await store.saveContext("default", { + url: "https://api.example.com", + credential, + expiresAt: Date.now() + 60_000, + }); + const fetch = vi + .fn() + .mockResolvedValue( + Response.json({ sessionId: "session-1", status: "created" }, { status: 201 }) + ); + const stdout: string[] = []; + + await createCli({ store, fetch, stdout: (value) => stdout.push(value) }).parseAsync([ + "node", + "oi", + "--output", + "json", + "session", + "create", + "--title", + "Test", + "--model", + "openai/gpt-5.6-sol", + ]); + + const output = JSON.parse(stdout.join("")); + const request = JSON.parse(String(fetch.mock.calls[0]?.[1]?.body)); + expect(output.idempotencyKey).toBe(request.idempotencyKey); + expect(output.idempotencyKey).toMatch(/^[0-9a-f-]{36}$/); + expect(request).not.toHaveProperty("reasoningEffort"); + }); + + it("accepts positional, stdin, and complete JSON prompt input with idempotency keys", async () => { + const directory = await mkdtemp(join(tmpdir(), "oi-cli-test-")); + const store = new ConfigStore(directory); + await store.saveContext("default", { + url: "https://api.example.com", + credential, + expiresAt: Date.now() + 60_000, + }); + const inputPath = join(directory, "prompt.json"); + await writeFile( + inputPath, + JSON.stringify({ content: "From JSON", clientRequestId: "json-key", model: "model/json" }) + ); + const fetch = vi + .fn() + .mockImplementation(() => + Promise.resolve(Response.json({ messageId: "m1", status: "queued" })) + ); + + await createCli({ store, fetch }).parseAsync([ + "node", + "oi", + "session", + "prompt", + "s1", + "Positional prompt", + "--idempotency-key", + "positional-key", + ]); + await createCli({ store, fetch, stdin: () => Promise.resolve("From stdin") }).parseAsync([ + "node", + "oi", + "session", + "prompt", + "s1", + "-", + "--idempotency-key", + "stdin-key", + ]); + await createCli({ store, fetch }).parseAsync([ + "node", + "oi", + "session", + "prompt", + "s1", + "--input", + inputPath, + ]); + + expect(fetch.mock.calls.map((call) => JSON.parse(String(call[1]?.body)))).toEqual([ + expect.objectContaining({ content: "Positional prompt", clientRequestId: "positional-key" }), + expect.objectContaining({ content: "From stdin", clientRequestId: "stdin-key" }), + expect.objectContaining({ + content: "From JSON", + clientRequestId: "json-key", + model: "model/json", + }), + ]); + }); + + it.each(["text", "stream-json"] as const)( + "follows the bounded checkpoint feed using %s output", + async (format) => { + const directory = await mkdtemp(join(tmpdir(), "oi-cli-test-")); + const store = new ConfigStore(directory); + await store.saveContext("default", { + url: "https://api.example.com", + credential, + expiresAt: Date.now() + 60_000, + }); + const stdout: string[] = []; + const change = { + kind: "upsert", + revision: 1, + event: { + id: "event-1", + type: "token", + messageId: null, + createdAt: 1, + data: { text: "hello" }, + }, + }; + const fetch = vi + .fn() + .mockResolvedValueOnce(Response.json({ changes: [change], checkpoint: 1, hasMore: false })) + .mockImplementation(() => + Promise.resolve(Response.json({ changes: [], checkpoint: 1, hasMore: false })) + ); + + await expect( + createCli({ + store, + fetch, + stdout: (value) => stdout.push(value), + sleep: () => new Promise((resolve) => setTimeout(resolve, 1)), + }).parseAsync([ + "node", + "oi", + "--output", + format, + "session", + "events", + "s1", + "--follow", + "--after", + "0", + "--timeout", + "3", + ]) + ).rejects.toMatchObject({ kind: "timeout" }); + + expect(String(fetch.mock.calls[0]?.[0])).toContain("/events?after=0"); + expect(String(fetch.mock.calls[0]?.[0])).not.toContain("/events/live"); + if (format === "stream-json") expect(JSON.parse(stdout[0]!)).toEqual(change); + else expect(stdout[0]).toContain("kind: upsert"); + } + ); + + it("returns a timeout exit error with the final wait status", async () => { + const directory = await mkdtemp(join(tmpdir(), "oi-cli-test-")); + const store = new ConfigStore(directory); + await store.saveContext("default", { + url: "https://api.example.com", + credential, + expiresAt: Date.now() + 60_000, + }); + const stdout: string[] = []; + const fetch = vi + .fn() + .mockImplementation(() => + Promise.resolve(Response.json({ sessionId: "s1", status: "active", settled: false })) + ); + + await expect( + createCli({ + store, + fetch, + stdout: (value) => stdout.push(value), + sleep: () => new Promise((resolve) => setTimeout(resolve, 1)), + }).parseAsync(["node", "oi", "--output", "json", "session", "wait", "s1", "--timeout", "2"]) + ).rejects.toMatchObject({ + kind: "timeout", + context: { sessionId: "s1", status: "active" }, + }); + expect(stdout).toEqual([]); + }); + + it.each([ + ["create", "idempotencyKey", "--idempotency-key"], + ["prompt", "clientRequestId", "--client-request-id"], + ] as const)( + "reports the recoverable %s request ID after a post-dispatch failure", + async (operation, field, flag) => { + const directory = await mkdtemp(join(tmpdir(), "oi-cli-test-")); + const store = new ConfigStore(directory); + await store.saveContext("default", { + url: "https://api.example.com", + credential, + expiresAt: Date.now() + 60_000, + }); + const stderr: string[] = []; + const explicit = operation === "prompt" ? "caller-retry-id" : undefined; + const args = + operation === "create" + ? [ + "session", + "create", + "--title", + "Test", + "--model", + "openai/gpt-5.6-sol", + "--reasoning", + "high", + ] + : ["session", "prompt", "s1", "--content", "Continue", flag, explicit!]; + + await expect( + runCli(["node", "oi", "--output", "json", ...args], { + store, + fetch: vi.fn().mockRejectedValue(new TypeError("socket closed")), + stderr: (value) => stderr.push(value), + }) + ).rejects.toThrow(); + + const envelope = JSON.parse(stderr.join("")); + expect(envelope.error.code).toBe("service_unavailable"); + expect(envelope.error.context[field]).toEqual( + explicit ?? expect.stringMatching(/^[0-9a-f-]{36}$/) + ); + } + ); + + it("prints a generated idempotency key after a post-dispatch text failure", async () => { + const directory = await mkdtemp(join(tmpdir(), "oi-cli-test-")); + const store = new ConfigStore(directory); + await store.saveContext("default", { + url: "https://api.example.com", + credential, + expiresAt: Date.now() + 60_000, + }); + const stderr: string[] = []; + const fetch = vi.fn().mockRejectedValue(new TypeError("socket closed")); + + await expect( + runCli( + [ + "node", + "oi", + "session", + "create", + "--title", + "Test", + "--model", + "openai/gpt-5.6-sol", + "--reasoning", + "high", + ], + { store, fetch, stderr: (value) => stderr.push(value) } + ) + ).rejects.toThrow(); + + const requestId = JSON.parse(String(fetch.mock.calls[0]?.[1]?.body)).idempotencyKey; + expect(stderr).toHaveLength(1); + expect(stderr[0]).toContain("error:"); + expect(stderr[0]).toContain(`"idempotencyKey":"${requestId}"`); + }); + + it.each([ + ["text", ["session", "create", "--unknown"]], + ["json", ["session", "create", "--unknown"]], + ["stream-json", ["session", "create", "--unknown"]], + ] as const)("routes Commander failures through one %s error envelope", async (format, args) => { + const stderr: string[] = []; + const exit = vi.spyOn(process, "exit").mockImplementation((() => undefined) as never); + try { + await expect( + runCli(["node", "oi", "--output", format, ...args], { + stderr: (value) => stderr.push(value), + }) + ).rejects.toMatchObject({ name: "CommanderError" }); + } finally { + exit.mockRestore(); + } + + expect(exit).not.toHaveBeenCalled(); + expect(stderr).toHaveLength(1); + if (format === "text") { + expect(stderr[0]).toMatch(/^error: \{"code":"invalid_request"/); + } else { + expect(JSON.parse(stderr[0]!)).toMatchObject({ error: { code: "invalid_request" } }); + } + expect(stderr[0]!.match(/unknown option|required option/g)).toHaveLength(1); + }); + + it("uses structured errors with Commander's inline output option syntax", async () => { + const stderr: string[] = []; + + await expect( + runCli(["node", "oi", "--output=json", "session", "create", "--unknown"], { + stderr: (value) => stderr.push(value), + }) + ).rejects.toMatchObject({ name: "CommanderError" }); + + expect(stderr).toHaveLength(1); + expect(JSON.parse(stderr[0]!)).toMatchObject({ error: { code: "invalid_request" } }); + }); + + it("rejects non-HTTP verification URLs and passes metacharacters as URL data", async () => { + expect(() => validateHttpUrl("javascript:alert(1)", "Verification URL")).toThrow( + "HTTP or HTTPS" + ); + expect(() => validateHttpUrl("https://user:pass@example.com", "Verification URL")).toThrow( + "credentials" + ); + expect(validateHttpUrl("https://example.com/verify?code=A&B=%26calc.exe")).toBe( + "https://example.com/verify?code=A&B=%26calc.exe" + ); + }); + + it.each([ + ["javascript:alert(1)", false], + ["https://example.com/verify?code=A&B=%26calc.exe", true], + ] as const)("validates verification URL %s before calling the opener", async (url, opens) => { + const directory = await mkdtemp(join(tmpdir(), "oi-cli-test-")); + const openUrl = vi.fn().mockResolvedValue(undefined); + const fetch = vi + .fn() + .mockResolvedValueOnce( + Response.json( + { + deviceSecret: "d".repeat(64), + userCode: "ABCD-EFGH", + verificationUrl: url, + expiresAt: Date.now() + 60_000, + pollIntervalMs: 1, + }, + { status: 201 } + ) + ) + .mockResolvedValueOnce( + Response.json({ + status: "authorized", + credential, + credentialId: "credential-1", + expiresAt: Date.now() + 60_000, + }) + ); + + await createCli({ + store: new ConfigStore(directory), + fetch, + openUrl, + stdout: vi.fn(), + stderr: vi.fn(), + }).parseAsync(["node", "oi", "login", "--url", "https://api.example.com"]); + + if (opens) expect(openUrl).toHaveBeenCalledWith(url); + else expect(openUrl).not.toHaveBeenCalled(); + }); + + it.each(["__proto__", "constructor", "prototype"])( + "rejects login context %s before starting authorization", + async (name) => { + const fetch = vi.fn(); + + await expect( + createCli({ fetch }).parseAsync([ + "node", + "oi", + "login", + "--url", + "https://api.example.com", + "--context", + name, + ]) + ).rejects.toMatchObject({ kind: "validation" }); + expect(fetch).not.toHaveBeenCalled(); + } + ); +}); diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts new file mode 100644 index 0000000000..41442e95b4 --- /dev/null +++ b/packages/cli/src/cli.ts @@ -0,0 +1,764 @@ +import { randomUUID } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import { basename } from "node:path"; +import { Command, Option } from "commander"; +import open from "open"; +import { + externalCreateSessionRequestSchema, + externalFollowUpRequestSchema, + externalSessionListQuerySchema, +} from "@open-inspect/shared/types/external-session-api"; +import { ApiClient, ApiError } from "./api-client.js"; +import { CredentialLifecycle } from "./credential-lifecycle.js"; +import { + ConfigStore, + defaultDeviceName, + normalizeBaseUrl, + validateContextName, +} from "./config-store.js"; +import { CliError, withErrorContext } from "./errors.js"; +import { serveMcp } from "./mcp-server.js"; +import { validateAttachmentBytes } from "./attachments.js"; +import { Operations } from "./operations.js"; +import { Output, type OutputFormat } from "./output.js"; + +interface CliDependencies { + store?: ConfigStore; + fetch?: typeof globalThis.fetch; + openUrl?: (url: string) => Promise; + sleep?: (milliseconds: number, signal?: AbortSignal) => Promise; + stdin?: () => Promise; + stdout?: (value: string) => void; + stderr?: (value: string) => void; +} + +/** Builds the CLI with injectable process boundaries for tests and embedding. */ +export function createCli(dependencies: CliDependencies = {}): Command { + const stdout = dependencies.stdout ?? ((value: string) => process.stdout.write(value)); + const store = dependencies.store ?? new ConfigStore(); + const credentialLifecycle = new CredentialLifecycle(store, dependencies.fetch); + const outputFor = (command: Command, fallback?: OutputFormat) => + new Output(fallback ?? command.optsWithGlobals().output, { + stdout: dependencies.stdout, + stderr: dependencies.stderr, + }); + const active = async () => store.getActiveContext(); + const operations = async (clientSurface: "cli" | "mcp" = "cli") => { + const context = await active(); + const api = new ApiClient({ + baseUrl: context.url, + fetch: dependencies.fetch, + authorize: () => Promise.resolve(context.credential), + clientSurface, + }); + return { api, operations: new Operations(api, { sleep: dependencies.sleep }) }; + }; + + const program = new Command() + .name("oi") + .description("Open Inspect command-line client") + .addOption( + new Option("--output ", "output format") + .choices(["text", "json", "stream-json"]) + .default("text") + ) + .exitOverride() + .configureOutput({ writeOut: stdout, writeErr: () => undefined }); + + program + .command("login") + .description("Authorize this device and save the final credential") + .requiredOption("--url ", "control-plane base URL") + .option("--context ", "context name", "default") + .option("--no-browser", "do not open a browser") + .action(async (options, command) => { + const output = outputFor(command); + validateContextName(options.context); + const baseUrl = normalizeBaseUrl(options.url); + await credentialLifecycle.prepareLogin(); + const api = new ApiClient({ baseUrl, fetch: dependencies.fetch }); + const started = await api.startDeviceAuthorization(defaultDeviceName()); + const deviceSecretRef = await credentialLifecycle.stageDeviceAuthorization({ + url: baseUrl, + contextName: options.context, + deviceSecret: started.deviceSecret, + }); + output.error(`Authorize code ${started.userCode} at ${started.verificationUrl}`); + if (options.browser) { + try { + await (dependencies.openUrl ?? openBrowser)( + validateHttpUrl(started.verificationUrl, "Verification URL") + ); + } catch (cause) { + output.error(`Could not open browser: ${errorMessage(cause)}`); + } + } + const controller = signalController(); + let exchange; + try { + while (Date.now() < started.expiresAt) { + try { + exchange = await api.exchangeDeviceAuthorization(started.deviceSecret); + } catch (cause) { + if (!isTransientLoginPollError(cause)) throw cause; + } + if (exchange?.status === "authorized") break; + await sleep(started.pollIntervalMs, controller.signal, dependencies.sleep); + } + } catch (cause) { + try { + await credentialLifecycle.prepareLogin(); + } catch (recoveryCause) { + throw new AggregateError( + [cause, recoveryCause], + "Device authorization failed and capability recovery remains pending" + ); + } + throw cause; + } + if (!exchange || exchange.status !== "authorized") { + await credentialLifecycle.prepareLogin(); + throw new CliError("expired", "Device authorization expired"); + } + const replacement = await credentialLifecycle.install( + options.context, + { + url: baseUrl, + credential: exchange.credential, + credentialId: exchange.credentialId, + expiresAt: exchange.expiresAt, + }, + deviceSecretRef + ); + const identity = await new ApiClient({ + baseUrl, + fetch: dependencies.fetch, + authorize: () => Promise.resolve(exchange.credential), + }) + .me() + .catch(() => null); + const credentialStore = await store.credentialStoreKind(); + output.result({ + context: options.context, + url: baseUrl, + expiresAt: exchange.expiresAt, + credentialStore, + ...(credentialStore === "file" ? { credentialFile: store.filePath } : {}), + ...(identity ? { installation: identity.installation, user: identity.user } : {}), + pendingRevocations: replacement.pendingRevocations, + pendingDeviceAuthorizations: replacement.pendingDeviceAuthorizations, + }); + }); + + program + .command("logout") + .description("Revoke and remove the active context") + .action(async (_options, command) => { + const output = outputFor(command); + const removed = await credentialLifecycle.logout(); + output.result({ + loggedOut: removed.name, + remoteRevocationComplete: removed.remoteRevocationComplete, + pendingRevocations: removed.pendingRevocations, + pendingDeviceAuthorizations: removed.pendingDeviceAuthorizations, + }); + }); + + const auth = program.command("auth").description("Manage authentication contexts"); + auth + .command("status") + .description("Show active credential status") + .action(async (_options, command) => { + const output = outputFor(command); + const config = await store.read(); + if (!config.activeContext) { + output.result({ reauthenticationRequired: true }); + return; + } + const context = await active(); + const { api } = await operations(); + try { + output.result({ + context: context.name, + url: context.url, + reauthenticationRequired: false, + ...(await api.me()), + }); + } catch (cause) { + if (!(cause instanceof ApiError) || cause.status !== 401) throw cause; + output.result({ + context: context.name, + url: context.url, + expiresAt: context.expiresAt, + reauthenticationRequired: true, + }); + } + }); + + const context = program.command("context").description("Manage named installation contexts"); + context + .command("use ") + .description("Select an active context") + .action(async (name, _options, command) => { + await store.setActiveContext(name); + outputFor(command).result({ activeContext: name }); + }); + context + .command("list") + .description("List saved contexts without credentials") + .action(async (_options, command) => { + const config = await store.read(); + outputFor(command).result({ + activeContext: config.activeContext, + contexts: Object.entries(config.contexts).map(([name, context]) => ({ + name, + url: context.url, + expiresAt: context.expiresAt, + })), + }); + }); + + const pagedDiscovery = ( + command: Command, + run: (query: { limit?: number; offset?: number }) => Promise + ) => + command + .option("--limit ", "maximum results", parseInteger) + .option("--offset ", "zero-based continuation offset", parseInteger) + .action(async (options, current) => + outputFor(current).result(await run({ limit: options.limit, offset: options.offset })) + ); + + pagedDiscovery(program.command("repo").command("list"), async (query) => + (await operations()).operations.listRepositories(query) + ); + const environment = program.command("environment").description("Discover saved environments"); + pagedDiscovery(environment.command("list"), async (query) => + (await operations()).operations.listEnvironments(query) + ); + environment + .command("get ") + .action(async (id, _options, command) => + outputFor(command).result(await (await operations()).operations.getEnvironment(id)) + ); + program + .command("model") + .command("list") + .action(async (_options, command) => + outputFor(command).result(await (await operations()).operations.listModels()) + ); + pagedDiscovery(program.command("skill").command("list"), async (query) => + (await operations()).operations.listSkills(query) + ); + pagedDiscovery(program.command("provider-account").command("list"), async (query) => + (await operations()).operations.listProviderAccounts(query) + ); + + const session = program.command("session").description("Manage sessions"); + session + .command("create") + .option("--title ") + .option("--model <model>") + .option("--reasoning <effort>") + .option("--prompt <text>") + .option("--prompt-file <path>", "read prompt from a file or - for stdin") + .option("--repo-owner <owner>") + .option("--repo-name <name>") + .option("--branch <branch>") + .option("--repositories <json>", "ordered repository target JSON") + .option("--environment <id>") + .option("--skills <mode>", "managed skills mode: all or none") + .option("--skill-profile <id>") + .option("--provider-selections <json>") + .option("--attach <path>", "attach an image", collect, []) + .option("--input <path>", "complete JSON request file or - for stdin") + .option("--idempotency-key <key>") + .action(async (options, command) => { + const idempotencyKey = options.idempotencyKey ?? randomUUID(); + const fileInput = options.input ? await readJsonInput(options.input, dependencies.stdin) : {}; + const prompt = options.promptFile + ? await readTextInput(options.promptFile, dependencies.stdin) + : options.prompt; + const skillSelection = options.skillProfile + ? { mode: "profile" as const, profileId: options.skillProfile } + : options.skills + ? { mode: options.skills } + : undefined; + const referencedAttachments = asAttachments(fileInput.initialAttachments); + if (options.attach.length + referencedAttachments.length > 6) { + throw new CliError("validation", "A prompt may include at most 6 attachments"); + } + await validateLocalAttachmentPaths(options.attach); + const input = externalCreateSessionRequestSchema.parse({ + ...fileInput, + title: options.title ?? fileInput.title, + model: options.model ?? fileInput.model, + reasoningEffort: options.reasoning ?? fileInput.reasoningEffort, + repoOwner: options.repoOwner ?? fileInput.repoOwner, + repoName: options.repoName ?? fileInput.repoName, + branch: options.branch ?? fileInput.branch, + repositories: options.repositories + ? JSON.parse(options.repositories) + : fileInput.repositories, + environmentId: options.environment ?? fileInput.environmentId, + skillSelection: skillSelection ?? fileInput.skillSelection, + providerSelections: options.providerSelections + ? JSON.parse(options.providerSelections) + : fileInput.providerSelections, + initialPrompt: options.attach.length ? undefined : (prompt ?? fileInput.initialPrompt), + initialAttachments: options.attach.length ? undefined : fileInput.initialAttachments, + initialAttachmentCount: + options.attach.length > 0 + ? options.attach.length + referencedAttachments.length + : fileInput.initialAttachmentCount, + idempotencyKey, + }); + let result; + try { + const current = (await operations()).operations; + result = await current.createSession(input); + if (options.attach.length) { + const uploadedAttachments = await uploadLocalAttachments( + current, + result.sessionId, + options.attach, + idempotencyKey + ); + const content = prompt ?? fileInput.initialPrompt; + const attachments = [...referencedAttachments, ...uploadedAttachments]; + if (content?.trim() || attachments.length) { + const prompted = await current.promptSession(result.sessionId, { + content, + attachments, + clientRequestId: `external-create:${idempotencyKey}`, + model: input.model, + reasoningEffort: input.reasoningEffort, + }); + result = { sessionId: result.sessionId, ...prompted }; + } + } + } catch (cause) { + throw withErrorContext(cause, { + idempotencyKey, + ...(result?.sessionId + ? { sessionId: result.sessionId, failedStage: "attachment_or_prompt" } + : {}), + }); + } + outputFor(command).result(options.idempotencyKey ? result : { ...result, idempotencyKey }); + }); + session + .command("list") + .option("--limit <count>", "maximum sessions to return", parseInteger) + .option("--offset <count>", "zero-based continuation offset", parseInteger) + .option("--status <status>") + .option("--exclude-status <status>") + .option("--exclude-automation-lineage") + .option("--created-by <user-id>") + .action(async (options, command) => { + const query = externalSessionListQuerySchema.parse({ + limit: options.limit, + offset: options.offset, + status: options.status, + excludeStatus: options.excludeStatus, + excludeAutomationLineage: options.excludeAutomationLineage, + createdBy: options.createdBy, + }); + outputFor(command).result(await (await operations()).operations.listSessions(query)); + }); + session + .command("get <id>") + .action(async (id, _options, command) => + outputFor(command).result(await (await operations()).operations.getSession(id)) + ); + session + .command("prompt <id> [prompt]") + .option("--content <text>") + .option("--content-file <path>", "read prompt from a file or - for stdin") + .option("--input <path>", "complete JSON request file or - for stdin") + .option("--attach <path>", "attach an image", collect, []) + .option("--idempotency-key <key>") + .option("--client-request-id <id>") + .option("--model <model>") + .option("--reasoning <effort>") + .action(async (id, prompt, options, command) => { + if ( + options.idempotencyKey && + options.clientRequestId && + options.idempotencyKey !== options.clientRequestId + ) { + throw new CliError( + "validation", + "--idempotency-key and --client-request-id must match when both are provided" + ); + } + const fileInput = options.input ? await readJsonInput(options.input, dependencies.stdin) : {}; + const clientRequestId = + options.idempotencyKey ?? + options.clientRequestId ?? + fileInput.clientRequestId ?? + randomUUID(); + if (typeof clientRequestId !== "string") { + throw new CliError("validation", "Prompt idempotency key must be a string"); + } + let result; + try { + const current = (await operations()).operations; + const referencedAttachments = asAttachments(fileInput.attachments); + if (options.attach.length + referencedAttachments.length > 6) { + throw new CliError("validation", "A prompt may include at most 6 attachments"); + } + const attachments = await uploadLocalAttachments( + current, + id, + options.attach, + clientRequestId + ); + result = await current.promptSession( + id, + externalFollowUpRequestSchema.parse({ + ...fileInput, + content: options.contentFile + ? await readTextInput(options.contentFile, dependencies.stdin) + : (options.content ?? + (prompt === "-" ? await readTextInput("-", dependencies.stdin) : prompt) ?? + fileInput.content), + attachments: [...referencedAttachments, ...attachments], + clientRequestId, + model: options.model ?? fileInput.model, + reasoningEffort: options.reasoning ?? fileInput.reasoningEffort, + }) + ); + } catch (cause) { + throw withErrorContext(cause, { idempotencyKey: clientRequestId, clientRequestId }); + } + const explicitRequestId = + options.idempotencyKey ?? options.clientRequestId ?? fileInput.clientRequestId; + outputFor(command).result( + explicitRequestId ? result : { ...result, idempotencyKey: clientRequestId } + ); + }); + session + .command("stop <id>") + .action(async (id, _options, command) => + outputFor(command).result(await (await operations()).operations.stopSession(id)) + ); + session + .command("events <id>") + .option("--cursor <cursor>") + .option("--after <checkpoint>", "event checkpoint", parseInteger) + .option("--limit <count>", "maximum changes", parseInteger) + .option("--follow") + .option("--poll-interval <ms>", "poll interval in milliseconds", parseNumber, 1_000) + .option("--timeout <ms>", "timeout in milliseconds", parseNumber, 30 * 60_000) + .action(async (id, options, command) => { + const output = outputFor(command); + const current = (await operations()).operations; + if (!options.follow) + return output.result( + await current.events(id, { + cursor: options.cursor, + after: options.after, + limit: options.limit, + }) + ); + if (output.format === "json") { + throw new CliError( + "validation", + "--output json cannot frame a followed event stream; use text or stream-json" + ); + } + if (options.cursor) { + throw new CliError("validation", "--cursor cannot be combined with --follow; use --after"); + } + const controller = signalController(); + for await (const change of current.followEvents(id, { + after: options.after, + pollIntervalMs: options.pollInterval, + timeoutMs: options.timeout, + signal: controller.signal, + })) + output.result(change); + }); + session + .command("wait <id>") + .option("--poll-interval <ms>", "poll interval in milliseconds", parseNumber, 1_000) + .option("--timeout <ms>", "timeout in milliseconds", parseNumber, 60_000) + .action(async (id, options, command) => { + const controller = signalController(); + const result = await ( + await operations() + ).operations.wait(id, { + pollIntervalMs: options.pollInterval, + timeoutMs: options.timeout, + signal: controller.signal, + }); + if (result.timedOut) { + throw new CliError("timeout", "Session wait timed out", undefined, { + sessionId: id, + status: result.status, + }); + } + if (result.status === "failed") { + throw new CliError("session_failed", "Session failed", undefined, { + sessionId: id, + status: result.status, + }); + } + outputFor(command).result(result); + }); + session + .command("messages <id>") + .option("--limit <count>", "maximum messages", parseInteger) + .option("--cursor <cursor>") + .action(async (id, options, command) => + outputFor(command).result(await (await operations()).operations.messages(id, options)) + ); + session + .command("artifacts <id>") + .option("--artifact <artifact-id>", "retrieve artifact content") + .option("--limit <count>", "maximum artifacts", parseInteger) + .option("--offset <count>", "zero-based continuation offset", parseInteger) + .action(async (id, options, command) => { + const current = (await operations()).operations; + outputFor(command).result( + options.artifact + ? await current.artifactContent(id, options.artifact, { + limit: options.limit, + offset: options.offset, + }) + : await current.artifacts(id, { limit: options.limit }) + ); + }); + session + .command("diff <id>") + .option("--revision <revision-id>") + .option("--file <file-id>") + .option("--limit <count>", "maximum diff files", parseInteger) + .option("--offset <count>", "zero-based continuation offset", parseInteger) + .action(async (id, options, command) => { + if (Boolean(options.revision) !== Boolean(options.file)) { + throw new CliError("validation", "--revision and --file must be provided together"); + } + const current = (await operations()).operations; + outputFor(command).result( + options.revision && options.file + ? await current.diffFile(id, options.revision, options.file, { + limit: options.limit, + offset: options.offset, + }) + : await current.diff(id, { limit: options.limit, offset: options.offset }) + ); + }); + session + .command("prs <id>") + .option("--pr <pull-request-id>", "retrieve one pull request") + .option("--limit <count>", "maximum pull requests", parseInteger) + .option("--offset <count>", "zero-based continuation offset", parseInteger) + .action(async (id, options, command) => { + const current = (await operations()).operations; + outputFor(command).result( + options.pr + ? await current.pullRequest(id, options.pr) + : await current.pullRequests(id, { limit: options.limit, offset: options.offset }) + ); + }); + session + .command("children <id>") + .option("--child <child-id>") + .option("--limit <count>", "maximum children", parseInteger) + .option("--offset <count>", "zero-based continuation offset", parseInteger) + .action(async (id, options, command) => { + const current = (await operations()).operations; + outputFor(command).result( + options.child + ? await current.child(id, options.child) + : await current.children(id, { limit: options.limit, offset: options.offset }) + ); + }); + session + .command("child-prompt <id> <child-id>") + .requiredOption("--content <text>") + .option("--client-request-id <id>") + .action(async (id, childId, options, command) => { + const clientRequestId = options.clientRequestId ?? randomUUID(); + outputFor(command).result( + await ( + await operations() + ).operations.promptChild(id, childId, { + content: options.content, + clientRequestId, + }) + ); + }); + + program + .command("mcp") + .command("serve") + .description("Run the local stdio MCP server") + .action(async () => { + await serveMcp((await operations("mcp")).operations); + }); + return program; +} + +export async function runCli( + argv = process.argv, + dependencies: CliDependencies = {} +): Promise<void> { + try { + await createCli(dependencies).parseAsync(argv); + } catch (cause) { + new Output(outputFormatFromArgv(argv), { + stdout: dependencies.stdout, + stderr: dependencies.stderr, + }).failure(cause); + throw cause; + } +} + +function parseNumber(value: string): number { + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed < 0) + throw new Error(`Invalid nonnegative number: ${value}`); + return parsed; +} + +function parseInteger(value: string): number { + const parsed = Number(value); + if (!Number.isSafeInteger(parsed) || parsed < 0) + throw new Error(`Invalid nonnegative integer: ${value}`); + return parsed; +} + +function collect(value: string, values: string[]): string[] { + return [...values, value]; +} + +async function readTextInput(path: string, stdin?: () => Promise<string>): Promise<string> { + return path === "-" ? (stdin ?? readStdin)() : readFile(path, "utf8"); +} + +async function readJsonInput( + path: string, + stdin?: () => Promise<string> +): Promise<Record<string, unknown>> { + const parsed: unknown = JSON.parse(await readTextInput(path, stdin)); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new CliError("validation", "Structured input must be a JSON object"); + } + return parsed as Record<string, unknown>; +} + +function asAttachments(value: unknown): Array<{ attachmentId: string; name: string }> { + return Array.isArray(value) ? (value as Array<{ attachmentId: string; name: string }>) : []; +} + +async function readStdin(): Promise<string> { + const chunks: Buffer[] = []; + for await (const chunk of process.stdin) chunks.push(Buffer.from(chunk)); + return Buffer.concat(chunks).toString("utf8"); +} + +async function uploadLocalAttachments( + operations: Operations, + sessionId: string, + paths: string[], + idempotencyKey: string +): Promise<Array<{ attachmentId: string; name: string }>> { + const uploaded = []; + const files = await Promise.all( + paths.map(async (path) => { + const bytes = await readFile(path); + const name = basename(path); + validateAttachmentBytes(bytes, name); + return { bytes, name }; + }) + ); + for (const [index, { bytes, name }] of files.entries()) { + const result = await operations.uploadAttachment( + sessionId, + new Blob([bytes]), + name, + `${idempotencyKey}:${index}` + ); + uploaded.push({ attachmentId: result.attachmentId, name }); + } + return uploaded; +} + +async function validateLocalAttachmentPaths(paths: string[]): Promise<void> { + await Promise.all( + paths.map(async (path) => { + const bytes = await readFile(path); + validateAttachmentBytes(bytes, basename(path)); + }) + ); +} + +function signalController(timeoutMs?: number): AbortController { + const controller = new AbortController(); + process.once("SIGINT", () => controller.abort(new Error("Interrupted"))); + if (timeoutMs !== undefined) + setTimeout(() => controller.abort(new CliError("timeout", "Timed out")), timeoutMs).unref(); + return controller; +} + +function sleep( + milliseconds: number, + signal: AbortSignal, + custom?: CliDependencies["sleep"] +): Promise<void> { + if (custom) return custom(milliseconds, signal); + return new Promise((resolve, reject) => { + const timer = setTimeout(resolve, milliseconds); + signal.addEventListener( + "abort", + () => { + clearTimeout(timer); + reject(signal.reason); + }, + { once: true } + ); + }); +} + +function openBrowser(url: string): Promise<void> { + return open(url, { wait: false }).then(() => undefined); +} + +export function validateHttpUrl(value: string, label = "URL"): string { + let url: URL; + try { + url = new URL(value); + } catch (cause) { + throw new CliError("validation", `${label} is invalid`, undefined, undefined, { cause }); + } + if (url.protocol !== "https:" && url.protocol !== "http:") + throw new CliError("validation", `${label} must use HTTP or HTTPS`); + if (url.username || url.password) + throw new CliError("validation", `${label} must not include credentials`); + return url.toString(); +} + +function outputFormatFromArgv(argv: string[]): OutputFormat { + const inline = argv.find((value) => value.startsWith("--output="))?.slice("--output=".length); + if (inline === "json" || inline === "stream-json") return inline; + const index = argv.findIndex((value) => value === "--output"); + const value = index >= 0 ? argv[index + 1] : undefined; + return value === "json" || value === "stream-json" ? value : "text"; +} + +function errorMessage(cause: unknown): string { + return cause instanceof Error ? cause.message : "Browser launch failed"; +} + +function isTransientLoginPollError(cause: unknown): boolean { + return ( + cause instanceof CliError && + (cause.kind === "transport" || + cause.kind === "timeout" || + cause.kind === "rate_limited" || + cause.kind === "service") + ); +} diff --git a/packages/cli/src/config-store.test.ts b/packages/cli/src/config-store.test.ts new file mode 100644 index 0000000000..9dc5c0fcbf --- /dev/null +++ b/packages/cli/src/config-store.test.ts @@ -0,0 +1,381 @@ +import { chmod, mkdtemp, readFile, readdir, stat, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it, vi } from "vitest"; +import { ConfigStore, normalizeBaseUrl } from "./config-store.js"; +import { + type CredentialStore, + createNativeCredentialBackend, + FileCredentialStore, + type NativeCredentialBackend, + isUnavailableNativeModule, + selectCredentialStore, +} from "./credential-store.js"; + +const credential = `oi_cli_${"a".repeat(64)}`; +const rotatedCredential = `oi_cli_${"b".repeat(64)}`; + +function memoryStore(): CredentialStore & { values: Map<string, string> } { + const values = new Map<string, string>(); + return { + kind: "native", + values, + get: async (reference) => values.get(reference), + set: async (reference, value) => void values.set(reference, value), + delete: async (reference) => void values.delete(reference), + }; +} + +describe("ConfigStore", () => { + it("separates reference metadata from fallback credentials and secures POSIX files", async () => { + const directory = await mkdtemp(join(tmpdir(), "oi-cli-test-")); + const store = new ConfigStore(directory); + await store.saveContext("work", { + url: "https://work.example.com", + credential, + expiresAt: 10, + }); + await store.saveContext("local", { + url: "http://localhost:8787", + credential: rotatedCredential, + expiresAt: 20, + }); + await store.setActiveContext("work"); + + expect(await store.getActiveContext()).toMatchObject({ name: "work", credential }); + expect(Object.keys((await store.read()).contexts)).toEqual(["work", "local"]); + const metadata = await readFile(store.filePath, "utf8"); + expect(metadata).not.toContain(credential); + expect(metadata).toContain("credentialRef"); + expect(await readFile(join(directory, "credentials.json"), "utf8")).toContain(credential); + if (process.platform !== "win32") { + expect((await stat(store.filePath)).mode & 0o777).toBe(0o600); + expect((await stat(join(directory, "credentials.json"))).mode & 0o777).toBe(0o600); + } + }); + + it("rotates through immutable references before deleting the old secret", async () => { + const directory = await mkdtemp(join(tmpdir(), "oi-cli-test-")); + const credentials = memoryStore(); + const references = [ + "00000000-0000-4000-8000-000000000001", + "00000000-0000-4000-8000-000000000002", + ]; + const store = new ConfigStore(directory, { + credentialStore: credentials, + generateCredentialRef: () => references.shift()!, + }); + await store.saveContext("work", { + url: "https://old.example.com", + credential, + expiresAt: 10, + }); + await store.saveContext("work", { + url: "https://new.example.com", + credential: rotatedCredential, + expiresAt: 20, + }); + + expect(await store.getActiveContext()).toMatchObject({ + url: "https://new.example.com", + credential: rotatedCredential, + }); + expect([...credentials.values]).toEqual([[referencesForTest(2), rotatedCredential]]); + }); + + it("retains the old URL/reference pair when writing a rotated secret fails", async () => { + const directory = await mkdtemp(join(tmpdir(), "oi-cli-test-")); + const credentials = memoryStore(); + const set = vi.spyOn(credentials, "set"); + const references = [referencesForTest(1), referencesForTest(2)]; + const store = new ConfigStore(directory, { + credentialStore: credentials, + generateCredentialRef: () => references.shift()!, + }); + await store.saveContext("work", { + url: "https://old.example.com", + credential, + expiresAt: 10, + }); + set.mockRejectedValueOnce(new Error("keychain locked")); + + await expect( + store.saveContext("work", { + url: "https://new.example.com", + credential: rotatedCredential, + expiresAt: 20, + }) + ).rejects.toThrow("keychain locked"); + expect(await store.getActiveContext()).toMatchObject({ + url: "https://old.example.com", + credential, + }); + expect([...credentials.values]).toEqual([[referencesForTest(1), credential]]); + }); + + it("restores the complete old pair when old-secret cleanup fails after rotation", async () => { + const directory = await mkdtemp(join(tmpdir(), "oi-cli-test-")); + const credentials = memoryStore(); + const references = [referencesForTest(1), referencesForTest(2)]; + const store = new ConfigStore(directory, { + credentialStore: credentials, + generateCredentialRef: () => references.shift()!, + }); + await store.saveContext("work", { + url: "https://old.example.com", + credential, + expiresAt: 10, + }); + vi.spyOn(credentials, "delete").mockRejectedValueOnce(new Error("cleanup failed")); + + await expect( + store.saveContext("work", { + url: "https://new.example.com", + credential: rotatedCredential, + expiresAt: 20, + }) + ).rejects.toThrow("cleanup failed"); + expect(await store.getActiveContext()).toMatchObject({ + url: "https://old.example.com", + credential, + }); + expect([...credentials.values]).toEqual([[referencesForTest(1), credential]]); + }); + + it("does not remove metadata when credential deletion fails", async () => { + const directory = await mkdtemp(join(tmpdir(), "oi-cli-test-")); + const credentials = memoryStore(); + const store = new ConfigStore(directory, { credentialStore: credentials }); + await store.saveContext("work", { + url: "https://work.example.com", + credential, + expiresAt: 10, + }); + vi.spyOn(credentials, "delete").mockRejectedValueOnce(new Error("keychain locked")); + + await expect(store.removeActiveContext()).rejects.toThrow("keychain locked"); + expect((await store.read()).activeContext).toBe("work"); + expect(await store.getActiveContext()).toMatchObject({ credential }); + }); + + it("restores a deleted credential when logout metadata removal loses a concurrency race", async () => { + const directory = await mkdtemp(join(tmpdir(), "oi-cli-test-")); + const credentials = memoryStore(); + const store = new ConfigStore(directory, { credentialStore: credentials }); + await store.saveContext("work", { + url: "https://old.example.com", + credential, + expiresAt: 10, + }); + const current = await store.read(); + const oldReference = current.contexts.work!.credentialRef; + const concurrentReference = referencesForTest(99); + credentials.values.set(concurrentReference, rotatedCredential); + vi.spyOn(credentials, "delete").mockImplementationOnce(async (reference) => { + credentials.values.delete(reference); + await writeFile( + store.filePath, + `${JSON.stringify({ + activeContext: "work", + contexts: { + work: { + url: "https://new.example.com", + expiresAt: 20, + credentialRef: concurrentReference, + }, + }, + })}\n` + ); + }); + + await expect(store.removeActiveContext()).rejects.toThrow("Context changed"); + expect(credentials.values.get(oldReference)).toBe(credential); + expect(await store.getActiveContext()).toMatchObject({ + url: "https://new.example.com", + credential: rotatedCredential, + }); + }); + + it("repairs permissive POSIX modes and uses unique atomic temporary files", async () => { + const directory = await mkdtemp(join(tmpdir(), "oi-cli-test-")); + const store = new ConfigStore(directory); + await store.saveContext("default", { + url: "https://example.com", + credential, + expiresAt: 10, + }); + if (process.platform !== "win32") { + await chmod(store.filePath, 0o644); + await store.read(); + expect((await stat(store.filePath)).mode & 0o777).toBe(0o600); + } + expect((await readdir(directory)).filter((name) => name.endsWith(".tmp"))).toEqual([]); + }); + + it("serializes concurrent context updates without losing entries", async () => { + const directory = await mkdtemp(join(tmpdir(), "oi-cli-test-")); + await Promise.all( + Array.from({ length: 20 }, (_, index) => + new ConfigStore(directory).saveContext(`context-${index}`, { + url: `https://host-${index}.example.com`, + credential: `oi_cli_${String(index).padStart(64, "a")}`, + expiresAt: index, + }) + ) + ); + expect(Object.keys((await new ConfigStore(directory).read()).contexts)).toHaveLength(20); + }); + + it.each(["__proto__", "constructor", "prototype"])( + "rejects reserved context name %s at save and selection boundaries", + async (name) => { + const directory = await mkdtemp(join(tmpdir(), "oi-cli-test-")); + const store = new ConfigStore(directory); + + await expect( + store.saveContext(name, { + url: "https://example.com", + credential, + expiresAt: 10, + }) + ).rejects.toMatchObject({ kind: "validation" }); + await expect(store.setActiveContext(name)).rejects.toMatchObject({ kind: "validation" }); + expect((await store.read()).contexts).toEqual({}); + } + ); +}); + +describe("credential backend selection", () => { + it("returns the keyring deletion promise so deferred failures reach rollback callers", async () => { + let rejectDelete!: (cause: Error) => void; + class DeferredEntry { + getPassword() { + return null; + } + setPassword() {} + deletePassword() { + return new Promise<boolean>((_resolve, reject) => { + rejectDelete = reject; + }); + } + } + const backend = createNativeCredentialBackend(DeferredEntry); + const deletion = Promise.resolve(backend.deletePassword("service", "account")); + + await expect( + Promise.race([deletion.then(() => "settled"), Promise.resolve("pending")]) + ).resolves.toBe("pending"); + rejectDelete(new Error("deferred keyring failure")); + await expect(deletion).rejects.toThrow("deferred keyring failure"); + }); + + it.each(["darwin", "linux", "win32"] as const)( + "selects injected native storage on %s and namespaces records by profile/reference", + async (platform) => { + const backend: NativeCredentialBackend = { + getPassword: vi.fn().mockReturnValue(credential), + setPassword: vi.fn(), + deletePassword: vi.fn(), + }; + const store = await selectCredentialStore("/unused", { + platform, + profile: "profile-a", + nativeBackend: backend, + }); + await store.set("reference-a", credential); + await store.get("reference-a"); + expect(backend.setPassword).toHaveBeenCalledWith( + "open-inspect-cli", + "profile-a:reference-a", + credential + ); + expect(store.kind).toBe("native"); + } + ); + + it("uses secure file fallback only when native storage is unavailable", async () => { + const directory = await mkdtemp(join(tmpdir(), "oi-cli-test-")); + const backend = await selectCredentialStore(directory, { + platform: "linux", + loadNativeBackend: vi.fn().mockResolvedValue(undefined), + }); + expect(backend).toBeInstanceOf(FileCredentialStore); + }); + + it("recognizes the keyring plain Error-with-cause load failure shape", () => { + expect( + isUnavailableNativeModule( + new Error("Cannot find native binding. npm optional dependency is missing.", { + cause: new Error("incompatible binary"), + }) + ) + ).toBe(true); + expect( + isUnavailableNativeModule(new Error("credential manager locked", { cause: new Error("I/O") })) + ).toBe(false); + expect(isUnavailableNativeModule(new Error("Failed to load native binding"))).toBe(true); + expect(isUnavailableNativeModule(new Error("Cannot find native binding."))).toBe(false); + }); + + it("falls back for binding load failures but propagates arbitrary loader errors", async () => { + const directory = await mkdtemp(join(tmpdir(), "oi-cli-test-")); + const unavailable = new Error( + "Cannot find native binding. npm optional dependency is missing.", + { + cause: new Error("missing platform package"), + } + ); + + await expect( + selectCredentialStore(directory, { + platform: "linux", + loadNativeBackend: vi.fn().mockRejectedValue(unavailable), + }) + ).resolves.toBeInstanceOf(FileCredentialStore); + await expect( + selectCredentialStore(directory, { + platform: "linux", + loadNativeBackend: vi.fn().mockRejectedValue(new Error("keyring runtime failure")), + }) + ).rejects.toThrow("keyring runtime failure"); + }); + + it("does not copy secrets to fallback after a native backend operation fails", async () => { + const directory = await mkdtemp(join(tmpdir(), "oi-cli-test-")); + const backend = await selectCredentialStore(directory, { + platform: "win32", + nativeBackend: { + getPassword: vi.fn(), + setPassword: vi.fn().mockRejectedValue(new Error("credential manager locked")), + deletePassword: vi.fn(), + }, + }); + await expect(backend.set("reference", credential)).rejects.toThrow("locked"); + await expect( + new FileCredentialStore(join(directory, "credentials.json")).get("reference") + ).resolves.toBeUndefined(); + }); +}); + +describe("normalizeBaseUrl", () => { + it.each(["http://localhost:8787/", "http://127.0.0.1:8787", "http://[::1]:8787"])( + "permits loopback HTTP URL %s", + (url) => expect(normalizeBaseUrl(url)).toMatch(/^http:/) + ); + + it.each([ + "http://example.com", + "http://0.0.0.0:8787", + "ftp://localhost", + "https://user:pass@example.com", + "https://example.com?tenant=x", + "https://example.com#fragment", + "https://example.com/prefix", + ])("rejects unsafe or ambiguous base URL %s", (url) => + expect(() => normalizeBaseUrl(url)).toThrow() + ); +}); + +function referencesForTest(index: number): string { + return `00000000-0000-4000-8000-${String(index).padStart(12, "0")}`; +} diff --git a/packages/cli/src/config-store.ts b/packages/cli/src/config-store.ts new file mode 100644 index 0000000000..a9c5dd9bee --- /dev/null +++ b/packages/cli/src/config-store.ts @@ -0,0 +1,439 @@ +import { createHash, randomUUID } from "node:crypto"; +import { homedir, hostname, platform } from "node:os"; +import { join } from "node:path"; +import { z } from "zod"; +import { CLI_DEVICE_SECRET_PATTERN } from "@open-inspect/shared/types/cli-auth"; +import { readJsonFile, updateJsonFile } from "./atomic-json-file.js"; +import { CliError } from "./errors.js"; +import { type CredentialStore, selectCredentialStore } from "./credential-store.js"; + +const RESERVED_CONTEXT_NAMES = new Set(["__proto__", "constructor", "prototype"]); +const contextNameSchema = z + .string() + .min(1) + .refine((name) => !RESERVED_CONTEXT_NAMES.has(name), "Reserved context name"); +const contextSchema = z.strictObject({ + url: z.url(), + expiresAt: z.number().int().nonnegative(), + credentialRef: z.string().min(1), +}); +const pendingRevocationSchema = z.strictObject({ + url: z.url(), + credentialRef: z.string().min(1), + credentialId: z.string().min(1).optional(), + purpose: z.enum(["staged", "replaced"]).default("replaced"), +}); +const pendingDeviceAuthorizationSchema = z.strictObject({ + url: z.url(), + contextName: contextNameSchema, + deviceSecretRef: z.string().min(1), + state: z.enum(["recovery", "cleanup"]), +}); +const configSchema = z.strictObject({ + activeContext: contextNameSchema.nullable(), + contexts: z.record(contextNameSchema, contextSchema), + pendingRevocations: z.array(pendingRevocationSchema).default([]), + pendingDeviceAuthorizations: z.array(pendingDeviceAuthorizationSchema).default([]), +}); + +export type StoredContext = Omit<z.infer<typeof contextSchema>, "credentialRef"> & { + credential: string; +}; +export type IssuedContext = StoredContext & { credentialId: string }; +export type StagedContext = Omit<IssuedContext, "credential"> & { credentialRef: string }; +export type NamedContext = StoredContext & { name: string }; +export type CliConfig = z.infer<typeof configSchema>; +export type PendingRevocation = z.infer<typeof pendingRevocationSchema> & { + credential?: string; +}; +export type PendingDeviceAuthorization = z.infer<typeof pendingDeviceAuthorizationSchema> & { + deviceSecret?: string; +}; +export type ConfigFileUpdater = ( + path: string, + read: (value: unknown | undefined) => CliConfig, + update: (value: CliConfig) => void +) => Promise<CliConfig>; + +const emptyConfig = (): CliConfig => ({ + activeContext: null, + contexts: {}, + pendingRevocations: [], + pendingDeviceAuthorizations: [], +}); + +function defaultConfigDirectory(env: NodeJS.ProcessEnv = process.env): string { + if (env.OPEN_INSPECT_CONFIG_DIR) return env.OPEN_INSPECT_CONFIG_DIR; + if (platform() === "win32") + return join(env.APPDATA ?? join(homedir(), "AppData", "Roaming"), "Open Inspect"); + if (platform() === "darwin") + return join(homedir(), "Library", "Application Support", "open-inspect"); + return join(env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), "open-inspect"); +} + +interface ConfigStoreOptions { + credentialStore?: CredentialStore | Promise<CredentialStore>; + generateCredentialRef?: () => string; + updateConfigFile?: ConfigFileUpdater; +} + +/** Stores an atomic URL/reference binding separately from immutable credential records. */ +export class ConfigStore { + readonly filePath: string; + private readonly credentials: Promise<CredentialStore>; + private readonly generateCredentialRef: () => string; + private readonly updateConfigFile: ConfigFileUpdater; + + constructor(directory?: string, options: ConfigStoreOptions = {}) { + const resolvedDirectory = directory ?? defaultConfigDirectory(); + this.filePath = join(resolvedDirectory, "contexts.json"); + this.generateCredentialRef = options.generateCredentialRef ?? randomUUID; + this.updateConfigFile = options.updateConfigFile ?? updateJsonFile; + this.credentials = Promise.resolve( + options.credentialStore ?? + selectCredentialStore(resolvedDirectory, { + profile: createHash("sha256").update(resolvedDirectory).digest("hex").slice(0, 24), + enableNative: directory === undefined, + }) + ); + } + + async read(): Promise<CliConfig> { + const value = await readJsonFile(this.filePath); + return value === undefined ? emptyConfig() : configSchema.parse(value); + } + + async credentialStoreKind(): Promise<CredentialStore["kind"]> { + return (await this.credentials).kind; + } + + async saveContext(name: string, context: StoredContext): Promise<void> { + validateContextName(name); + const credentialRef = this.generateCredentialRef(); + const metadata = contextSchema.parse({ + url: normalizeBaseUrl(context.url), + expiresAt: context.expiresAt, + credentialRef, + }); + const credentials = await this.credentials; + await credentials.set(credentialRef, context.credential); + let previous: z.infer<typeof contextSchema> | undefined; + try { + await this.update((config) => { + previous = ownContext(config, name); + config.contexts[name] = metadata; + config.activeContext ??= name; + }); + } catch (cause) { + await credentials.delete(credentialRef); + throw cause; + } + if (previous) { + const previousContext = previous; + try { + await credentials.delete(previousContext.credentialRef); + } catch (cause) { + await this.update((config) => { + if (config.contexts[name]?.credentialRef !== credentialRef) return; + config.contexts[name] = previousContext; + }); + await credentials.delete(credentialRef); + throw cause; + } + } + } + + async stageCredential(context: IssuedContext): Promise<StagedContext> { + const url = normalizeBaseUrl(context.url); + const credentialRef = credentialReference(context.credentialId); + const credentials = await this.credentials; + try { + await credentials.set(credentialRef, context.credential); + } catch (cause) { + throw new CliError( + "service", + "Credential secret could not be staged; retry with the same credential ID", + undefined, + { credentialId: context.credentialId, credentialRef }, + { cause } + ); + } + try { + await this.update((config) => { + const isBound = Object.values(config.contexts).some( + (candidate) => candidate.credentialRef === credentialRef + ); + const isPending = config.pendingRevocations.some( + (candidate) => candidate.credentialRef === credentialRef + ); + if (!isBound && !isPending) + config.pendingRevocations.push({ + url, + credentialRef, + credentialId: context.credentialId, + purpose: "staged", + }); + }); + } catch (cause) { + throw new CliError( + "service", + "Credential secret was staged, but its revocation marker could not be persisted; retry with the same credential ID", + undefined, + { credentialId: context.credentialId, credentialRef }, + { cause } + ); + } + return { url, expiresAt: context.expiresAt, credentialId: context.credentialId, credentialRef }; + } + + async stageDeviceAuthorization(input: { + url: string; + contextName: string; + deviceSecret: string; + }): Promise<string> { + const url = normalizeBaseUrl(input.url); + const contextName = validateContextName(input.contextName); + const deviceSecretRef = deviceAuthorizationReference(input.deviceSecret); + const credentials = await this.credentials; + await credentials.set(deviceSecretRef, input.deviceSecret); + try { + await this.update((config) => { + if ( + !config.pendingDeviceAuthorizations.some( + (candidate) => candidate.deviceSecretRef === deviceSecretRef + ) + ) { + config.pendingDeviceAuthorizations.push({ + url, + contextName, + deviceSecretRef, + state: "recovery", + }); + } + }); + } catch (cause) { + await credentials.delete(deviceSecretRef); + throw cause; + } + return deviceSecretRef; + } + + async promoteStagedContext( + name: string, + staged: StagedContext, + deviceSecretRef: string + ): Promise<void> { + validateContextName(name); + const metadata = contextSchema.parse({ + url: staged.url, + expiresAt: staged.expiresAt, + credentialRef: staged.credentialRef, + }); + await this.update((config) => { + const authorization = config.pendingDeviceAuthorizations.find( + (candidate) => + candidate.deviceSecretRef === deviceSecretRef && candidate.state === "recovery" + ); + if (!authorization) + throw new CliError("conflict", "Pending device authorization recovery was not found"); + const current = ownContext(config, name); + if (current?.credentialRef === staged.credentialRef) { + config.activeContext = name; + authorization.state = "cleanup"; + return; + } + const hasMarker = config.pendingRevocations.some( + (candidate) => + candidate.credentialRef === staged.credentialRef && candidate.purpose === "staged" + ); + if (!hasMarker) + throw new CliError("conflict", "Staged credential revocation marker was not found"); + const previous = current; + config.contexts[name] = metadata; + config.activeContext = name; + config.pendingRevocations = config.pendingRevocations.filter( + (candidate) => candidate.credentialRef !== staged.credentialRef + ); + if ( + previous && + previous.credentialRef !== staged.credentialRef && + !config.pendingRevocations.some( + (candidate) => candidate.credentialRef === previous.credentialRef + ) + ) { + config.pendingRevocations.push({ + url: previous.url, + credentialRef: previous.credentialRef, + purpose: "replaced", + }); + } + authorization.state = "cleanup"; + }); + } + + async getPendingRevocations(): Promise<PendingRevocation[]> { + const config = await this.read(); + const credentials = await this.credentials; + return Promise.all( + config.pendingRevocations.map(async (pending) => { + const credential = await credentials.get(pending.credentialRef); + return { ...pending, ...(credential ? { credential } : {}) }; + }) + ); + } + + async getPendingDeviceAuthorizations(): Promise<PendingDeviceAuthorization[]> { + const config = await this.read(); + const credentials = await this.credentials; + return Promise.all( + config.pendingDeviceAuthorizations.map(async (pending) => { + const deviceSecret = await credentials.get(pending.deviceSecretRef); + return { ...pending, ...(deviceSecret ? { deviceSecret } : {}) }; + }) + ); + } + + async completePendingDeviceAuthorization(deviceSecretRef: string): Promise<void> { + const current = await this.read(); + const pending = current.pendingDeviceAuthorizations.find( + (candidate) => candidate.deviceSecretRef === deviceSecretRef + ); + if (!pending) return; + await this.update((config) => { + config.pendingDeviceAuthorizations = config.pendingDeviceAuthorizations.filter( + (candidate) => candidate.deviceSecretRef !== deviceSecretRef + ); + }); + await (await this.credentials).delete(deviceSecretRef); + } + + async completePendingRevocation(credentialRef: string): Promise<void> { + const current = await this.read(); + const pending = current.pendingRevocations.find( + (candidate) => candidate.credentialRef === credentialRef + ); + if (!pending) return; + await this.update((config) => { + config.pendingRevocations = config.pendingRevocations.filter( + (candidate) => candidate.credentialRef !== credentialRef + ); + }); + await (await this.credentials).delete(credentialRef); + } + + async setActiveContext(name: string): Promise<void> { + validateContextName(name); + await this.update((config) => { + if (!ownContext(config, name)) throw new CliError("validation", `Context not found: ${name}`); + config.activeContext = name; + }); + } + + async getActiveContext(): Promise<NamedContext> { + const config = await this.read(); + if (!config.activeContext) + throw new CliError("auth", "Not logged in. Run `oi login --url <url>`."); + const context = ownContext(config, config.activeContext); + if (!context) throw new CliError("auth", `Active context not found: ${config.activeContext}`); + const credential = await (await this.credentials).get(context.credentialRef); + if (!credential) + throw new CliError("auth", `Credential not found for context: ${config.activeContext}`); + return { + name: config.activeContext, + url: context.url, + expiresAt: context.expiresAt, + credential, + }; + } + + async removeActiveContext(): Promise<NamedContext> { + const current = await this.read(); + const removedName = current.activeContext; + if (!removedName) throw new CliError("auth", "Not logged in. Run `oi login --url <url>`."); + const removedContext = ownContext(current, removedName); + if (!removedContext) throw new Error(`Active context not found: ${removedName}`); + const credentials = await this.credentials; + const credential = await credentials.get(removedContext.credentialRef); + if (!credential) throw new Error(`Credential not found for context: ${removedName}`); + + await credentials.delete(removedContext.credentialRef); + try { + await this.update((config) => { + if (config.contexts[removedName]?.credentialRef !== removedContext.credentialRef) + throw new Error(`Context changed while it was being removed: ${removedName}`); + delete config.contexts[removedName]; + config.activeContext = Object.keys(config.contexts)[0] ?? null; + }); + } catch (cause) { + await credentials.set(removedContext.credentialRef, credential); + throw cause; + } + return { + name: removedName, + url: removedContext.url, + expiresAt: removedContext.expiresAt, + credential, + }; + } + + private async update(change: (config: CliConfig) => void): Promise<void> { + await this.updateConfigFile( + this.filePath, + (value) => (value === undefined ? emptyConfig() : configSchema.parse(value)), + change + ); + } +} + +export function credentialReference(credentialId: string): string { + const parsed = z.string().min(1).parse(credentialId); + return `issued-${createHash("sha256").update(parsed).digest("hex")}`; +} + +export function deviceAuthorizationReference(deviceSecret: string): string { + const parsed = z.string().regex(CLI_DEVICE_SECRET_PATTERN).parse(deviceSecret); + return `device-${createHash("sha256").update(parsed).digest("hex")}`; +} + +export function validateContextName(name: string): string { + const result = contextNameSchema.safeParse(name); + if (!result.success) throw new CliError("validation", `Invalid context name: ${name}`); + return result.data; +} + +function ownContext(config: CliConfig, name: string): z.infer<typeof contextSchema> | undefined { + return Object.prototype.hasOwnProperty.call(config.contexts, name) + ? config.contexts[name] + : undefined; +} + +export function normalizeBaseUrl(url: string): string { + let parsed: URL; + try { + parsed = new URL(url); + } catch (cause) { + throw new CliError("validation", "Base URL is invalid", undefined, undefined, { cause }); + } + if (parsed.protocol !== "https:" && parsed.protocol !== "http:") + throw new CliError("validation", "Base URL must use HTTP or HTTPS"); + const loopback = + parsed.hostname === "localhost" || + parsed.hostname === "127.0.0.1" || + parsed.hostname === "[::1]"; + if (parsed.protocol === "http:" && !loopback) + throw new CliError( + "validation", + "HTTP base URLs are allowed only for localhost, 127.0.0.1, or [::1]" + ); + if (parsed.username || parsed.password) + throw new CliError("validation", "Base URL must not include credentials"); + if (parsed.search) throw new CliError("validation", "Base URL must not include a query string"); + if (parsed.hash) throw new CliError("validation", "Base URL must not include a fragment"); + if (parsed.pathname !== "/") + throw new CliError("validation", "Base URL must not include a path prefix"); + return parsed.origin; +} + +export function defaultDeviceName(): string { + return hostname() || "Open Inspect CLI"; +} diff --git a/packages/cli/src/credential-lifecycle.test.ts b/packages/cli/src/credential-lifecycle.test.ts new file mode 100644 index 0000000000..1699b38f4d --- /dev/null +++ b/packages/cli/src/credential-lifecycle.test.ts @@ -0,0 +1,471 @@ +import { mkdtemp } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it, vi } from "vitest"; +import { updateJsonFile } from "./atomic-json-file.js"; +import { CredentialLifecycle } from "./credential-lifecycle.js"; +import { + type ConfigFileUpdater, + ConfigStore, + credentialReference, + deviceAuthorizationReference, +} from "./config-store.js"; +import { FileCredentialStore, type CredentialStore } from "./credential-store.js"; + +const oldCredential = `oi_cli_${"a".repeat(64)}`; +const newCredential = `oi_cli_${"b".repeat(64)}`; +const thirdCredential = `oi_cli_${"c".repeat(64)}`; +const deviceSecret = "d".repeat(64); + +function issued( + url = "https://new.example.com", + credential = newCredential, + credentialId = "new-credential" +) { + return { url, credential, credentialId, expiresAt: 20 }; +} + +function memoryStore(): CredentialStore & { values: Map<string, string> } { + const values = new Map<string, string>(); + return { + kind: "native", + values, + get: async (reference) => values.get(reference), + set: async (reference, credential) => void values.set(reference, credential), + delete: async (reference) => void values.delete(reference), + }; +} + +async function seedOldContext(directory: string): Promise<void> { + await new ConfigStore(directory).saveContext("work", { + url: "https://old.example.com", + credential: oldCredential, + expiresAt: 10, + }); +} + +async function install( + store: ConfigStore, + lifecycle: CredentialLifecycle, + name = "work", + context = issued() +) { + const deviceSecretRef = await lifecycle.stageDeviceAuthorization({ + url: context.url, + contextName: name, + deviceSecret, + }); + return lifecycle.install(name, context, deviceSecretRef); +} + +describe("CredentialLifecycle", () => { + it("rolls back the device secret when authorization metadata persistence fails", async () => { + const directory = await mkdtemp(join(tmpdir(), "oi-cli-test-")); + const store = new ConfigStore(directory, { + updateConfigFile: vi.fn<ConfigFileUpdater>().mockRejectedValue(new Error("disk full")), + }); + + await expect( + store.stageDeviceAuthorization({ + url: "https://new.example.com", + contextName: "work", + deviceSecret, + }) + ).rejects.toThrow("disk full"); + await expect( + new FileCredentialStore(join(directory, "credentials.json")).get( + deviceAuthorizationReference(deviceSecret) + ) + ).resolves.toBeUndefined(); + }); + + it("promotes the new binding before revoking the old credential against its old URL", async () => { + const directory = await mkdtemp(join(tmpdir(), "oi-cli-test-")); + await seedOldContext(directory); + const store = new ConfigStore(directory); + const fetch = vi.fn<typeof globalThis.fetch>().mockImplementation(async (request, init) => { + await expect(store.getActiveContext()).resolves.toMatchObject({ + url: "https://new.example.com", + credential: newCredential, + }); + expect(String(request)).toBe("https://old.example.com/external/v1/cli/credentials/current"); + expect(new Headers(init?.headers).get("Authorization")).toBe(`Bearer ${oldCredential}`); + return new Response(null, { status: 204 }); + }); + + await expect(install(store, new CredentialLifecycle(store, fetch))).resolves.toEqual({ + pendingRevocations: 0, + pendingDeviceAuthorizations: 0, + }); + expect((await store.read()).pendingRevocations).toEqual([]); + expect(fetch).toHaveBeenCalledTimes(1); + }); + + it("returns an actionable deterministic reference when secret staging fails", async () => { + const directory = await mkdtemp(join(tmpdir(), "oi-cli-test-")); + const credentials = memoryStore(); + const store = new ConfigStore(directory, { credentialStore: credentials }); + const fetch = vi.fn().mockResolvedValue(new Response(null, { status: 204 })); + const lifecycle = new CredentialLifecycle(store, fetch); + const deviceSecretRef = await lifecycle.stageDeviceAuthorization({ + url: "https://new.example.com", + contextName: "work", + deviceSecret, + }); + vi.spyOn(credentials, "set").mockRejectedValueOnce(new Error("keyring locked")); + const error = await lifecycle + .install("work", issued(), deviceSecretRef) + .catch((cause) => cause); + + expect(error).toMatchObject({ + kind: "service", + context: { + credentialId: "new-credential", + credentialRef: credentialReference("new-credential"), + }, + }); + expect(await store.read()).toMatchObject({ activeContext: null, pendingRevocations: [] }); + expect(fetch).toHaveBeenCalledTimes(2); + expect(String(fetch.mock.calls[0]?.[0])).toBe( + "https://new.example.com/external/v1/cli/credentials/current" + ); + expect(new Headers(fetch.mock.calls[0]?.[1]?.headers).get("Authorization")).toBe( + `Bearer ${newCredential}` + ); + }); + + it("revokes the issued credential when staging metadata fails", async () => { + const directory = await mkdtemp(join(tmpdir(), "oi-cli-test-")); + const failingUpdate = vi + .fn<ConfigFileUpdater>() + .mockImplementation(updateJsonFile) + .mockImplementationOnce(updateJsonFile) + .mockRejectedValueOnce(new Error("disk full")); + const store = new ConfigStore(directory, { updateConfigFile: failingUpdate }); + const fetch = vi.fn().mockResolvedValue(new Response(null, { status: 204 })); + const lifecycle = new CredentialLifecycle(store, fetch); + const error = await install(store, lifecycle).catch((cause) => cause); + const reference = credentialReference("new-credential"); + + expect(error).toMatchObject({ + kind: "service", + context: { credentialId: "new-credential", credentialRef: reference }, + }); + expect(fetch).toHaveBeenCalledTimes(2); + expect(String(fetch.mock.calls[0]?.[0])).toBe( + "https://new.example.com/external/v1/cli/credentials/current" + ); + }); + + it("retains both recovery handles when promotion storage and network revocation fail", async () => { + const directory = await mkdtemp(join(tmpdir(), "oi-cli-test-")); + await seedOldContext(directory); + const update = vi + .fn<ConfigFileUpdater>() + .mockImplementation(updateJsonFile) + .mockImplementationOnce(updateJsonFile) + .mockImplementationOnce(updateJsonFile) + .mockRejectedValueOnce(new Error("binding write failed")); + const store = new ConfigStore(directory, { updateConfigFile: update }); + const fetch = vi + .fn<typeof globalThis.fetch>() + .mockResolvedValue(Response.json({ error: "unavailable" }, { status: 503 })); + + const failure = await install(store, new CredentialLifecycle(store, fetch)).catch( + (cause) => cause + ); + expect(failure).toBeInstanceOf(AggregateError); + expect((failure as AggregateError).errors[0]).toMatchObject({ + context: { + credentialId: "new-credential", + credentialRef: credentialReference("new-credential"), + }, + }); + expect((await store.read()).pendingRevocations).toMatchObject([ + { credentialId: "new-credential", purpose: "staged" }, + ]); + expect((await store.read()).pendingDeviceAuthorizations).toMatchObject([{ state: "recovery" }]); + await expect(store.getActiveContext()).resolves.toMatchObject({ credential: oldCredential }); + + const restarted = new ConfigStore(directory); + const restartedFetch = vi + .fn<typeof globalThis.fetch>() + .mockResolvedValue(new Response(null, { status: 204 })); + await expect( + new CredentialLifecycle(restarted, restartedFetch).logout() + ).resolves.toMatchObject({ + name: "work", + }); + expect(restartedFetch).toHaveBeenCalledTimes(3); + expect(String(restartedFetch.mock.calls[0]?.[0])).toBe( + "https://new.example.com/external/v1/cli/device-authorizations/revoke" + ); + expect(new Headers(restartedFetch.mock.calls[0]?.[1]?.headers).has("Authorization")).toBe( + false + ); + expect((await restarted.read()).pendingRevocations).toEqual([]); + }); + + it("clears the device marker before local cleanup fails after promotion", async () => { + const directory = await mkdtemp(join(tmpdir(), "oi-cli-test-")); + const credentials = memoryStore(); + const store = new ConfigStore(directory, { credentialStore: credentials }); + const lifecycle = new CredentialLifecycle(store, vi.fn()); + const deviceSecretRef = await lifecycle.stageDeviceAuthorization({ + url: "https://new.example.com", + contextName: "work", + deviceSecret, + }); + vi.spyOn(credentials, "delete").mockImplementation(async (reference) => { + if (reference === deviceSecretRef) throw new Error("keyring locked"); + credentials.values.delete(reference); + }); + + await expect(lifecycle.install("work", issued(), deviceSecretRef)).resolves.toEqual({ + pendingRevocations: 0, + pendingDeviceAuthorizations: 1, + }); + expect((await store.read()).pendingDeviceAuthorizations).toEqual([]); + await expect(store.getActiveContext()).resolves.toMatchObject({ credential: newCredential }); + }); + + it("removes the staged marker on promotion but retains a failed old revocation", async () => { + const directory = await mkdtemp(join(tmpdir(), "oi-cli-test-")); + await seedOldContext(directory); + const store = new ConfigStore(directory); + const fetch = vi + .fn<typeof globalThis.fetch>() + .mockResolvedValue(Response.json({ error: "unavailable" }, { status: 503 })); + + await expect(install(store, new CredentialLifecycle(store, fetch))).resolves.toEqual({ + pendingRevocations: 1, + pendingDeviceAuthorizations: 0, + }); + expect((await store.read()).pendingRevocations).toMatchObject([ + { url: "https://old.example.com", purpose: "replaced" }, + ]); + expect((await store.read()).pendingRevocations).not.toEqual( + expect.arrayContaining([expect.objectContaining({ purpose: "staged" })]) + ); + }); + + it("login retries persisted revocations after a process restart", async () => { + const directory = await mkdtemp(join(tmpdir(), "oi-cli-test-")); + await seedOldContext(directory); + const initial = new ConfigStore(directory); + await install( + initial, + new CredentialLifecycle( + initial, + vi.fn().mockResolvedValue(Response.json({ error: "retry" }, { status: 429 })) + ) + ); + + const restarted = new ConfigStore(directory); + const fetch = vi.fn().mockResolvedValue(new Response(null, { status: 204 })); + await expect( + install( + restarted, + new CredentialLifecycle(restarted, fetch), + "other", + issued("https://third.example.com", thirdCredential, "third-credential") + ) + ).resolves.toEqual({ pendingRevocations: 0, pendingDeviceAuthorizations: 0 }); + expect(fetch).toHaveBeenCalledTimes(1); + expect((await restarted.read()).pendingRevocations).toEqual([]); + }); + + it("login startup capability-recovers a persisted device authorization after restart", async () => { + const directory = await mkdtemp(join(tmpdir(), "oi-cli-test-")); + const initial = new ConfigStore(directory); + await initial.stageDeviceAuthorization({ + url: "https://new.example.com", + contextName: "work", + deviceSecret, + }); + const restarted = new ConfigStore(directory); + const fetch = vi.fn().mockResolvedValue(new Response(null, { status: 204 })); + + await new CredentialLifecycle(restarted, fetch).prepareLogin(); + + expect(String(fetch.mock.calls[0]?.[0])).toBe( + "https://new.example.com/external/v1/cli/device-authorizations/revoke" + ); + expect(new Headers(fetch.mock.calls[0]?.[1]?.headers).has("Authorization")).toBe(false); + expect((await restarted.read()).pendingDeviceAuthorizations).toEqual([]); + }); + + it("logout removes the active credential before draining pending and active credentials", async () => { + const directory = await mkdtemp(join(tmpdir(), "oi-cli-test-")); + const localCredentials = memoryStore(); + const initial = new ConfigStore(directory, { credentialStore: localCredentials }); + await initial.saveContext("work", { + url: "https://old.example.com", + credential: oldCredential, + expiresAt: 10, + }); + await install( + initial, + new CredentialLifecycle( + initial, + vi.fn().mockResolvedValue(Response.json({ error: "unavailable" }, { status: 503 })) + ) + ); + const activeCredentialRef = (await initial.read()).contexts.work!.credentialRef; + const restarted = new ConfigStore(directory, { credentialStore: localCredentials }); + const seen: string[] = []; + const fetch = vi.fn<typeof globalThis.fetch>().mockImplementation(async (_request, init) => { + await expect(restarted.getActiveContext()).rejects.toThrow("Not logged in"); + expect(localCredentials.values.has(activeCredentialRef)).toBe(false); + seen.push(new Headers(init?.headers).get("Authorization") ?? ""); + return new Response(null, { status: 204 }); + }); + + await new CredentialLifecycle(restarted, fetch).logout(); + + expect(seen).toEqual([`Bearer ${oldCredential}`, `Bearer ${newCredential}`]); + expect((await restarted.read()).pendingRevocations).toEqual([]); + await expect(restarted.getActiveContext()).rejects.toThrow("Not logged in"); + }); + + it("logout drains a staged credential even when no active context exists", async () => { + const directory = await mkdtemp(join(tmpdir(), "oi-cli-test-")); + await new ConfigStore(directory).stageCredential(issued()); + const restarted = new ConfigStore(directory); + const fetch = vi.fn().mockResolvedValue(new Response(null, { status: 204 })); + + await expect(new CredentialLifecycle(restarted, fetch).logout()).rejects.toThrow( + "Not logged in" + ); + + expect(fetch).toHaveBeenCalledTimes(1); + expect((await restarted.read()).pendingRevocations).toEqual([]); + }); + + it("logout removes the active context despite transient remote errors", async () => { + const directory = await mkdtemp(join(tmpdir(), "oi-cli-test-")); + await seedOldContext(directory); + const initial = new ConfigStore(directory); + await install( + initial, + new CredentialLifecycle( + initial, + vi.fn().mockResolvedValue(Response.json({ error: "unavailable" }, { status: 503 })) + ) + ); + const restarted = new ConfigStore(directory); + const fetch = vi + .fn<typeof globalThis.fetch>() + .mockRejectedValueOnce(new TypeError("network down")) + .mockResolvedValueOnce(Response.json({ error: "limited" }, { status: 429 })); + + await expect(new CredentialLifecycle(restarted, fetch).logout()).resolves.toMatchObject({ + name: "work", + remoteRevocationComplete: false, + pendingRevocations: 1, + }); + expect(await restarted.getPendingRevocations()).toMatchObject([{ credential: oldCredential }]); + await expect(restarted.getActiveContext()).rejects.toThrow("Not logged in"); + }); + + it("logout clears definitive-invalid pending and active handles after restart", async () => { + const directory = await mkdtemp(join(tmpdir(), "oi-cli-test-")); + await seedOldContext(directory); + const initial = new ConfigStore(directory); + await install( + initial, + new CredentialLifecycle( + initial, + vi.fn().mockResolvedValue(Response.json({ error: "unavailable" }, { status: 503 })) + ) + ); + const restarted = new ConfigStore(directory); + const fetch = vi + .fn<typeof globalThis.fetch>() + .mockResolvedValueOnce(Response.json({ error: "gone" }, { status: 410 })) + .mockResolvedValueOnce(Response.json({ error: "invalid" }, { status: 401 })); + + await expect(new CredentialLifecycle(restarted, fetch).logout()).resolves.toMatchObject({ + name: "work", + }); + expect((await restarted.read()).pendingRevocations).toEqual([]); + await expect(restarted.getActiveContext()).rejects.toThrow("Not logged in"); + }); + + it("restores a pending secret when marker cleanup persistence fails", async () => { + const directory = await mkdtemp(join(tmpdir(), "oi-cli-test-")); + await seedOldContext(directory); + const initial = new ConfigStore(directory); + await install( + initial, + new CredentialLifecycle( + initial, + vi.fn().mockResolvedValue(Response.json({ error: "unavailable" }, { status: 503 })) + ) + ); + const failingUpdate = vi + .fn<ConfigFileUpdater>() + .mockRejectedValue(new Error("configuration locked")); + const store = new ConfigStore(directory, { updateConfigFile: failingUpdate }); + const fetch = vi.fn().mockResolvedValue(new Response(null, { status: 204 })); + + await expect(new CredentialLifecycle(store, fetch).logout()).rejects.toThrow( + "configuration locked" + ); + + const restarted = new ConfigStore(directory); + await expect(restarted.getPendingRevocations()).resolves.toMatchObject([ + { credential: oldCredential }, + ]); + await expect(restarted.getActiveContext()).resolves.toMatchObject({ + credential: newCredential, + }); + }); + + it("clears the pending marker before local cleanup after remote revocation", async () => { + const directory = await mkdtemp(join(tmpdir(), "oi-cli-test-")); + const credentials = memoryStore(); + const store = new ConfigStore(directory, { credentialStore: credentials }); + await store.saveContext("work", { + url: "https://old.example.com", + credential: oldCredential, + expiresAt: 10, + }); + const oldReference = (await store.read()).contexts.work!.credentialRef; + vi.spyOn(credentials, "delete").mockImplementation(async (reference) => { + if (reference === oldReference) throw new Error("keyring locked"); + credentials.values.delete(reference); + }); + + await expect( + install( + store, + new CredentialLifecycle( + store, + vi.fn().mockResolvedValue(new Response(null, { status: 204 })) + ) + ) + ).resolves.toEqual({ pendingRevocations: 1, pendingDeviceAuthorizations: 0 }); + expect(await store.getPendingRevocations()).toEqual([]); + }); + + it("clears stale recovery markers when their local secrets are missing", async () => { + const directory = await mkdtemp(join(tmpdir(), "oi-cli-test-")); + const credentials = memoryStore(); + const store = new ConfigStore(directory, { credentialStore: credentials }); + await store.stageCredential(issued()); + await store.stageDeviceAuthorization({ + url: "https://new.example.com", + contextName: "work", + deviceSecret, + }); + credentials.values.clear(); + const fetch = vi.fn(); + + await expect(new CredentialLifecycle(store, fetch).prepareLogin()).resolves.toBeUndefined(); + + expect((await store.read()).pendingRevocations).toEqual([]); + expect((await store.read()).pendingDeviceAuthorizations).toEqual([]); + expect(fetch).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/cli/src/credential-lifecycle.ts b/packages/cli/src/credential-lifecycle.ts new file mode 100644 index 0000000000..8bd2ba4bbe --- /dev/null +++ b/packages/cli/src/credential-lifecycle.ts @@ -0,0 +1,215 @@ +import { ApiClient, ApiError } from "./api-client.js"; +import type { + ConfigStore, + IssuedContext, + NamedContext, + PendingDeviceAuthorization, + PendingRevocation, + StoredContext, +} from "./config-store.js"; +import { CliError, withErrorContext } from "./errors.js"; + +const DEFINITIVELY_INVALID_STATUSES = new Set([401, 404, 410]); + +/** Coordinates remote credential revocation with committed local context bindings. */ +export class CredentialLifecycle { + constructor( + private readonly store: ConfigStore, + private readonly fetch?: typeof globalThis.fetch + ) {} + + async prepareLogin(): Promise<void> { + const deviceAuthorizations = await this.drainDeviceAuthorizations(); + const credentials = await this.drainPendingCredentials(); + const failures = [...deviceAuthorizations.failures, ...credentials.failures]; + if (failures.length) throw revocationFailure(failures); + } + + stageDeviceAuthorization(input: { + url: string; + contextName: string; + deviceSecret: string; + }): Promise<string> { + return this.store.stageDeviceAuthorization(input); + } + + async install( + name: string, + context: IssuedContext, + deviceSecretRef: string + ): Promise<{ pendingRevocations: number; pendingDeviceAuthorizations: number }> { + let staged; + try { + staged = await this.store.stageCredential(context); + } catch (cause) { + const failures = [cause]; + try { + await this.revoke(context); + } catch (revokeCause) { + if (!isDefinitivelyInvalid(revokeCause)) failures.push(revokeCause); + } + const recovered = await this.drainDeviceAuthorizations(); + throw revocationFailure([...failures, ...recovered.failures]); + } + try { + await this.store.promoteStagedContext(name, staged, deviceSecretRef); + } catch (cause) { + const credentials = await this.drainPendingCredentials(); + const deviceAuthorizations = await this.drainDeviceAuthorizations(); + const failure = withErrorContext(cause, { + credentialId: context.credentialId, + credentialRef: staged.credentialRef, + }); + throw revocationFailure([failure, ...credentials.failures, ...deviceAuthorizations.failures]); + } + + const deviceAuthorizations = await this.drainDeviceAuthorizations(); + const credentials = await this.drainPendingCredentials(); + return { + pendingRevocations: credentials.remaining, + pendingDeviceAuthorizations: deviceAuthorizations.remaining, + }; + } + + async logout(): Promise< + NamedContext & { + remoteRevocationComplete: boolean; + pendingRevocations: number; + pendingDeviceAuthorizations: number; + } + > { + let removed: NamedContext; + try { + removed = await this.store.removeActiveContext(); + } catch (cause) { + if (!(cause instanceof CliError) || cause.kind !== "auth") throw cause; + await this.drainDeviceAuthorizations(); + await this.drainPendingCredentials(); + throw cause; + } + const deviceAuthorizations = await this.drainDeviceAuthorizations(); + const credentials = await this.drainPendingCredentials(); + let activeRevoked = true; + try { + await this.revoke(removed); + } catch (cause) { + activeRevoked = isDefinitivelyInvalid(cause); + } + return { + ...removed, + remoteRevocationComplete: + activeRevoked && + credentials.failures.length === 0 && + deviceAuthorizations.failures.length === 0, + pendingRevocations: credentials.remaining, + pendingDeviceAuthorizations: deviceAuthorizations.remaining, + }; + } + + private async drainPendingCredentials(): Promise<{ + remaining: number; + failures: unknown[]; + }> { + let pending: PendingRevocation[]; + try { + pending = await this.store.getPendingRevocations(); + } catch (cause) { + return { remaining: 0, failures: [cause] }; + } + let remaining = pending.length; + const failures: unknown[] = []; + for (const credential of pending) { + if (!credential.credential) { + try { + await this.store.completePendingRevocation(credential.credentialRef); + remaining -= 1; + } catch (cause) { + failures.push(cause); + } + continue; + } + try { + await this.revoke({ url: credential.url, credential: credential.credential }); + } catch (cause) { + if (!isDefinitivelyInvalid(cause)) { + failures.push(cause); + continue; + } + } + try { + await this.store.completePendingRevocation(credential.credentialRef); + remaining -= 1; + } catch (cause) { + failures.push(cause); + } + } + return { remaining, failures }; + } + + private async drainDeviceAuthorizations(): Promise<{ + remaining: number; + failures: unknown[]; + }> { + let pending: PendingDeviceAuthorization[]; + try { + pending = await this.store.getPendingDeviceAuthorizations(); + } catch (cause) { + return { remaining: 0, failures: [cause] }; + } + let remaining = pending.length; + const failures: unknown[] = []; + for (const authorization of pending) { + if (authorization.state === "recovery") { + if (!authorization.deviceSecret) { + try { + await this.store.completePendingDeviceAuthorization(authorization.deviceSecretRef); + remaining -= 1; + } catch (cause) { + failures.push(cause); + } + continue; + } + try { + await this.revokeDeviceAuthorization(authorization.url, authorization.deviceSecret); + } catch (cause) { + failures.push(cause); + continue; + } + } + try { + await this.store.completePendingDeviceAuthorization(authorization.deviceSecretRef); + remaining -= 1; + } catch (cause) { + failures.push(cause); + } + } + return { remaining, failures }; + } + + private revoke(context: Pick<StoredContext, "url" | "credential">): Promise<void> { + return new ApiClient({ + baseUrl: context.url, + fetch: this.fetch, + authorize: () => Promise.resolve(context.credential), + }).revokeCredential(); + } + + private revokeDeviceAuthorization(url: string, deviceSecret: string): Promise<void> { + return new ApiClient({ baseUrl: url, fetch: this.fetch }).revokeDeviceAuthorization( + deviceSecret + ); + } +} + +function revocationFailure(failures: unknown[]): unknown { + if (failures.length === 1) return failures[0]; + return new AggregateError(failures, "Credential recovery failed and remains retryable"); +} + +function isDefinitivelyInvalid(cause: unknown): boolean { + return ( + cause instanceof ApiError && + cause.status !== undefined && + DEFINITIVELY_INVALID_STATUSES.has(cause.status) + ); +} diff --git a/packages/cli/src/credential-store.ts b/packages/cli/src/credential-store.ts new file mode 100644 index 0000000000..d212083857 --- /dev/null +++ b/packages/cli/src/credential-store.ts @@ -0,0 +1,151 @@ +import { join } from "node:path"; +import { z } from "zod"; +import { + CLI_CREDENTIAL_PATTERN, + CLI_DEVICE_SECRET_PATTERN, +} from "@open-inspect/shared/types/cli-auth"; +import { readJsonFile, updateJsonFile } from "./atomic-json-file.js"; + +const storedSecretSchema = z.union([ + z.string().regex(CLI_CREDENTIAL_PATTERN), + z.string().regex(CLI_DEVICE_SECRET_PATTERN), +]); +const credentialFileSchema = z.record(z.string().min(1), storedSecretSchema); +const NATIVE_SERVICE = "open-inspect-cli"; + +export interface CredentialStore { + readonly kind: "native" | "file"; + get(reference: string): Promise<string | undefined>; + set(reference: string, credential: string): Promise<void>; + delete(reference: string): Promise<void>; +} + +export interface NativeCredentialBackend { + getPassword(service: string, account: string): Promise<string | null> | string | null; + setPassword(service: string, account: string, credential: string): Promise<void> | void; + deletePassword(service: string, account: string): boolean | void | Promise<boolean | void>; +} + +interface KeyringEntry { + getPassword(): string | null; + setPassword(credential: string): void; + deletePassword(): boolean | Promise<boolean>; +} + +interface KeyringEntryConstructor { + new (service: string, account: string): KeyringEntry; +} + +export class FileCredentialStore implements CredentialStore { + readonly kind = "file" as const; + + constructor(readonly filePath: string) {} + + async get(reference: string): Promise<string | undefined> { + return this.parse(await readJsonFile(this.filePath))[reference]; + } + + async set(reference: string, credential: string): Promise<void> { + storedSecretSchema.parse(credential); + await updateJsonFile( + this.filePath, + (value) => this.parse(value), + (values) => { + values[reference] = credential; + } + ); + } + + async delete(reference: string): Promise<void> { + await updateJsonFile( + this.filePath, + (value) => this.parse(value), + (values) => { + delete values[reference]; + } + ); + } + + private parse(value: unknown | undefined): Record<string, string> { + return value === undefined ? {} : credentialFileSchema.parse(value); + } +} + +class NativeCredentialStore implements CredentialStore { + readonly kind = "native" as const; + + constructor( + private readonly profile: string, + private readonly backend: NativeCredentialBackend + ) {} + + async get(reference: string): Promise<string | undefined> { + const value = await this.backend.getPassword(NATIVE_SERVICE, this.account(reference)); + return value === null ? undefined : storedSecretSchema.parse(value); + } + + async set(reference: string, credential: string): Promise<void> { + storedSecretSchema.parse(credential); + await this.backend.setPassword(NATIVE_SERVICE, this.account(reference), credential); + } + + async delete(reference: string): Promise<void> { + await this.backend.deletePassword(NATIVE_SERVICE, this.account(reference)); + } + + private account(reference: string): string { + return `${this.profile}:${reference}`; + } +} + +export async function selectCredentialStore( + directory: string, + options: { + platform?: NodeJS.Platform; + profile?: string; + nativeBackend?: NativeCredentialBackend; + loadNativeBackend?: () => Promise<NativeCredentialBackend | undefined>; + enableNative?: boolean; + } = {} +): Promise<CredentialStore> { + const currentPlatform = options.platform ?? process.platform; + const supportsNative = ["darwin", "linux", "win32"].includes(currentPlatform); + if (options.enableNative !== false && supportsNative) { + let backend = options.nativeBackend; + if (!backend) { + try { + backend = await (options.loadNativeBackend ?? loadNativeBackend)(); + } catch (cause) { + if (!isUnavailableNativeModule(cause)) throw cause; + } + } + if (backend) return new NativeCredentialStore(options.profile ?? directory, backend); + } + return new FileCredentialStore(join(directory, "credentials.json")); +} + +async function loadNativeBackend(): Promise<NativeCredentialBackend> { + const { Entry } = await import("@napi-rs/keyring"); + return createNativeCredentialBackend(Entry); +} + +export function createNativeCredentialBackend( + Entry: KeyringEntryConstructor +): NativeCredentialBackend { + return { + getPassword: (service, account) => new Entry(service, account).getPassword(), + setPassword: (service, account, credential) => + new Entry(service, account).setPassword(credential), + deletePassword: (service, account) => new Entry(service, account).deletePassword(), + }; +} + +export function isUnavailableNativeModule(cause: unknown): boolean { + const code = (cause as NodeJS.ErrnoException).code; + if (code === "ERR_MODULE_NOT_FOUND" || code === "MODULE_NOT_FOUND") return true; + return ( + cause instanceof Error && + (cause.message === "Failed to load native binding" || + (cause.message.startsWith("Cannot find native binding.") && cause.cause instanceof Error)) + ); +} diff --git a/packages/cli/src/errors.ts b/packages/cli/src/errors.ts new file mode 100644 index 0000000000..76d01be9a8 --- /dev/null +++ b/packages/cli/src/errors.ts @@ -0,0 +1,101 @@ +const CLI_EXIT_CODES = { + general: 1, + auth: 2, + validation: 3, + conflict: 4, + timeout: 5, + transport: 6, + service: 7, + not_found: 8, + expired: 9, + rate_limited: 10, + forbidden: 11, + session_failed: 12, + incompatible_client: 13, +} as const; + +export type CliErrorKind = keyof typeof CLI_EXIT_CODES; + +export class CliError extends Error { + constructor( + readonly kind: CliErrorKind, + message: string, + readonly status?: number, + readonly context?: Record<string, string>, + options?: ErrorOptions + ) { + super(message, options); + this.name = "CliError"; + } +} + +export function classifyError(cause: unknown): CliError { + if (cause instanceof CliError) return cause; + if (cause instanceof Error && cause.name === "CommanderError") + return new CliError("validation", safeErrorMessage(cause), undefined, undefined, { cause }); + if (cause instanceof Error && cause.name === "ZodError") + return new CliError("validation", "Input or response validation failed", undefined, undefined, { + cause, + }); + return new CliError("general", safeErrorMessage(cause), undefined, undefined, { + cause, + }); +} + +export function withErrorContext(cause: unknown, context: Record<string, string>): CliError { + const error = classifyError(cause); + return new CliError( + error.kind, + error.message, + error.status, + { ...error.context, ...context }, + { + cause: error, + } + ); +} + +export function errorEnvelope(cause: unknown) { + const error = classifyError(cause); + return { + error: { + code: publicErrorCode(error), + message: error.message, + ...(error.status !== undefined ? { status: error.status } : {}), + ...(error.context ? { context: error.context } : {}), + }, + }; +} + +export function publicErrorCode(error: CliError): string { + if (error.context?.code) return error.context.code; + const codes: Record<CliErrorKind, string> = { + general: "service_unavailable", + auth: "unauthenticated", + validation: "invalid_request", + conflict: "conflict", + timeout: "timeout", + transport: "service_unavailable", + service: "service_unavailable", + not_found: "not_found", + expired: "checkpoint_expired", + rate_limited: "rate_limited", + forbidden: "forbidden", + session_failed: "session_failed", + incompatible_client: "incompatible_client", + }; + return codes[error.kind]; +} + +function safeErrorMessage(cause: unknown): string { + const message = cause instanceof Error ? cause.message : "Unexpected CLI failure"; + const bounded = message + .replace(/[\r\n\t]+/g, " ") + .trim() + .slice(0, 512); + return bounded || "Unexpected CLI failure"; +} + +export function exitCodeFor(cause: unknown): number { + return CLI_EXIT_CODES[classifyError(cause).kind]; +} diff --git a/packages/cli/src/mcp-server.test.ts b/packages/cli/src/mcp-server.test.ts new file mode 100644 index 0000000000..a3b506b8e8 --- /dev/null +++ b/packages/cli/src/mcp-server.test.ts @@ -0,0 +1,429 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { describe, expect, it, vi } from "vitest"; +import { CliError } from "./errors.js"; +import { createMcpServer, MAX_MCP_RESULT_BYTES, toolResult } from "./mcp-server.js"; + +async function connectedClient(operations: object) { + const server = createMcpServer(operations as never); + const client = new Client({ name: "test", version: "1.0.0" }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]); + return { server, client }; +} + +describe("MCP server", () => { + it("exposes the complete V1 tool set", async () => { + const operations = { + listSessions: vi.fn().mockResolvedValue({ sessions: [], hasMore: false }), + }; + const { server, client } = await connectedClient(operations); + + const tools = await client.listTools(); + expect(tools.tools).toHaveLength(19); + expect(tools.tools.map((tool) => tool.name).sort()).toEqual([ + "environment_get", + "environment_list", + "model_list", + "provider_account_list", + "repository_list", + "session_artifacts", + "session_child_prompt", + "session_children", + "session_create", + "session_diff", + "session_events", + "session_get", + "session_list", + "session_messages", + "session_prompt", + "session_pull_requests", + "session_stop", + "session_wait", + "skill_list", + ]); + expect( + (await client.callTool({ name: "session_list", arguments: { limit: 25, offset: 50 } })) + .structuredContent + ).toEqual({ + sessions: [], + hasMore: false, + }); + expect(operations.listSessions).toHaveBeenCalledWith({ limit: 25, offset: 50 }); + expect(tools.tools.every((tool) => tool.outputSchema)).toBe(true); + await Promise.all([client.close(), server.close()]); + }); + + it("rejects ungranted and over-limit path attachments before side effects", async () => { + const operations = { + createSession: vi.fn(), + promptSession: vi.fn(), + uploadAttachment: vi.fn(), + }; + const { server, client } = await connectedClient(operations); + + const noRoots = await client.callTool({ + name: "session_create", + arguments: { + idempotencyKey: "create-with-file", + attachmentPaths: ["/tmp/image.png"], + }, + }); + expect(noRoots.isError).toBe(true); + expect(operations.createSession).not.toHaveBeenCalled(); + + const tooMany = await client.callTool({ + name: "session_prompt", + arguments: { + sessionId: "s1", + clientRequestId: "prompt-with-files", + attachments: Array.from({ length: 6 }, (_, index) => ({ + attachmentId: `attachment-${index}`, + name: `${index}.png`, + })), + attachmentPaths: ["/tmp/extra.png"], + }, + }); + expect(tooMany.isError).toBe(true); + expect(operations.uploadAttachment).not.toHaveBeenCalled(); + expect(operations.promptSession).not.toHaveBeenCalled(); + await Promise.all([client.close(), server.close()]); + }); + + it("accepts valid outputs from both modes of the polymorphic read tools", async () => { + const artifact = { + contentType: "image/png", + contentBase64: "AQI=", + offset: 0, + hasMore: false, + }; + const pullRequest = { + id: "pr-1", + provider: "github", + repoOwner: "open-inspect", + repoName: "app", + number: 7, + url: "https://github.com/open-inspect/app/pull/7", + state: "open", + headBranch: "feature", + baseBranch: "main", + }; + const child = { + id: "child-1", + title: "Child", + status: "active", + model: "openai/gpt-5.6-sol", + reasoningEffort: null, + repoOwner: "open-inspect", + repoName: "app", + environmentId: null, + parentSessionId: "s1", + createdAt: 1, + updatedAt: 2, + }; + const diff = { + version: 1, + current: null, + lastError: null, + unavailableReason: null, + hasMore: false, + }; + const diffContent = { content: "@@ -1 +1 @@", truncated: false, hasMore: false }; + const operations = { + artifactContent: vi.fn().mockResolvedValue(artifact), + artifacts: vi.fn().mockResolvedValue({ artifacts: [], hasMore: false }), + diff: vi.fn().mockResolvedValue(diff), + diffFile: vi.fn().mockResolvedValue(diffContent), + pullRequest: vi.fn().mockResolvedValue(pullRequest), + pullRequests: vi.fn().mockResolvedValue({ pullRequests: [], hasMore: false }), + child: vi.fn().mockResolvedValue(child), + children: vi.fn().mockResolvedValue({ children: [], hasMore: false }), + }; + const { server, client } = await connectedClient(operations); + + await expect( + client.callTool({ + name: "session_artifacts", + arguments: { sessionId: "s1", artifactId: "artifact-1" }, + }) + ).resolves.toMatchObject({ structuredContent: artifact }); + await expect( + client.callTool({ + name: "session_pull_requests", + arguments: { sessionId: "s1", pullRequestId: "pr-1" }, + }) + ).resolves.toMatchObject({ structuredContent: pullRequest }); + await expect( + client.callTool({ name: "session_artifacts", arguments: { sessionId: "s1" } }) + ).resolves.toMatchObject({ structuredContent: { artifacts: [], hasMore: false } }); + await expect( + client.callTool({ name: "session_diff", arguments: { sessionId: "s1" } }) + ).resolves.toMatchObject({ structuredContent: diff }); + await expect( + client.callTool({ + name: "session_diff", + arguments: { sessionId: "s1", revisionId: "r1", fileId: "f1" }, + }) + ).resolves.toMatchObject({ structuredContent: diffContent }); + await expect( + client.callTool({ name: "session_pull_requests", arguments: { sessionId: "s1" } }) + ).resolves.toMatchObject({ structuredContent: { pullRequests: [], hasMore: false } }); + await expect( + client.callTool({ name: "session_children", arguments: { sessionId: "s1" } }) + ).resolves.toMatchObject({ structuredContent: { children: [], hasMore: false } }); + await expect( + client.callTool({ + name: "session_children", + arguments: { sessionId: "s1", childId: "child-1" }, + }) + ).resolves.toMatchObject({ structuredContent: child }); + expect(operations.artifactContent).toHaveBeenCalledWith("s1", "artifact-1", { + offset: undefined, + limit: undefined, + }); + expect(operations.pullRequest).toHaveBeenCalledWith("s1", "pr-1"); + await Promise.all([client.close(), server.close()]); + }); + + it("rejects empty and mixed polymorphic outputs", async () => { + const pullRequest = { + id: "pr-1", + provider: "github", + repoOwner: "open-inspect", + repoName: "app", + number: 7, + url: "https://github.com/open-inspect/app/pull/7", + state: "open", + headBranch: "feature", + baseBranch: "main", + }; + const child = { + id: "child-1", + title: null, + status: "active", + model: "openai/gpt-5.6-sol", + reasoningEffort: null, + repoOwner: null, + repoName: null, + environmentId: null, + parentSessionId: "s1", + createdAt: 1, + updatedAt: 2, + }; + const operations = { + artifacts: vi.fn().mockResolvedValue({}), + artifactContent: vi.fn().mockResolvedValue({ + artifacts: [], + contentType: "image/png", + contentBase64: "AQI=", + offset: 0, + hasMore: false, + }), + diff: vi.fn().mockResolvedValue({}), + diffFile: vi.fn().mockResolvedValue({ + version: 1, + current: null, + lastError: null, + unavailableReason: null, + content: "patch", + truncated: false, + hasMore: false, + }), + pullRequests: vi.fn().mockResolvedValue({}), + pullRequest: vi.fn().mockResolvedValue({ ...pullRequest, pullRequests: [], hasMore: false }), + children: vi.fn().mockResolvedValue({}), + child: vi.fn().mockResolvedValue({ ...child, children: [], hasMore: false }), + }; + const { server, client } = await connectedClient(operations); + + const results = await Promise.all([ + client.callTool({ name: "session_artifacts", arguments: { sessionId: "s1" } }), + client.callTool({ + name: "session_artifacts", + arguments: { sessionId: "s1", artifactId: "artifact-1" }, + }), + client.callTool({ name: "session_diff", arguments: { sessionId: "s1" } }), + client.callTool({ + name: "session_diff", + arguments: { sessionId: "s1", revisionId: "r1", fileId: "f1" }, + }), + client.callTool({ name: "session_pull_requests", arguments: { sessionId: "s1" } }), + client.callTool({ + name: "session_pull_requests", + arguments: { sessionId: "s1", pullRequestId: "pr-1" }, + }), + client.callTool({ name: "session_children", arguments: { sessionId: "s1" } }), + client.callTool({ + name: "session_children", + arguments: { sessionId: "s1", childId: "child-1" }, + }), + ]); + + expect(results.every((result) => result.isError)).toBe(true); + await Promise.all([client.close(), server.close()]); + }); + + it("requires and preserves retry identifiers for create and prompt tools", async () => { + const operations = { + createSession: vi + .fn() + .mockResolvedValueOnce({ sessionId: "s1", status: "created" }) + .mockResolvedValueOnce({ sessionId: "s2", messageId: "m2", status: "queued" }), + promptSession: vi.fn().mockResolvedValue({ messageId: "m1", status: "queued" }), + }; + const { server, client } = await connectedClient(operations); + + const invalidCreate = await client.callTool({ + name: "session_create", + arguments: { title: "T", model: "model", reasoningEffort: "high" }, + }); + const invalidPrompt = await client.callTool({ + name: "session_prompt", + arguments: { sessionId: "s1", content: "P" }, + }); + expect(invalidCreate.isError).toBe(true); + expect(invalidPrompt.isError).toBe(true); + expect(operations.createSession).not.toHaveBeenCalled(); + expect(operations.promptSession).not.toHaveBeenCalled(); + const created = await client.callTool({ + name: "session_create", + arguments: { + title: "T", + model: "openai/gpt-5.6-sol", + idempotencyKey: "retry-create", + }, + }); + const queued = await client.callTool({ + name: "session_create", + arguments: { + title: "Queued", + model: "openai/gpt-5.6-sol", + idempotencyKey: "retry-create-queued", + }, + }); + await client.callTool({ + name: "session_prompt", + arguments: { sessionId: "s1", content: "P", clientRequestId: "retry-prompt" }, + }); + + expect(operations.createSession).toHaveBeenCalledWith( + expect.not.objectContaining({ reasoningEffort: expect.anything() }) + ); + expect(created.structuredContent).toEqual({ sessionId: "s1", status: "created" }); + expect(queued.structuredContent).toEqual({ + sessionId: "s2", + messageId: "m2", + status: "queued", + }); + expect(operations.promptSession).toHaveBeenCalledWith( + "s1", + expect.objectContaining({ clientRequestId: "retry-prompt" }) + ); + await Promise.all([client.close(), server.close()]); + }); + + it("rejects incoherent session create outputs", async () => { + const operations = { + createSession: vi + .fn() + .mockResolvedValueOnce({ sessionId: "s1", status: "queued" }) + .mockResolvedValueOnce({ sessionId: "s2", messageId: "m2", status: "created" }), + }; + const { server, client } = await connectedClient(operations); + + const missingMessage = await client.callTool({ + name: "session_create", + arguments: { idempotencyKey: "missing-message" }, + }); + const impossibleMessage = await client.callTool({ + name: "session_create", + arguments: { idempotencyKey: "impossible-message" }, + }); + + expect(missingMessage.isError).toBe(true); + expect(impossibleMessage.isError).toBe(true); + await Promise.all([client.close(), server.close()]); + }); + + it("rejects session list pagination outside the shared bounds", async () => { + const operations = { listSessions: vi.fn() }; + const { server, client } = await connectedClient(operations); + + const result = await client.callTool({ + name: "session_list", + arguments: { limit: 201, offset: -1 }, + }); + + expect(result.isError).toBe(true); + expect(operations.listSessions).not.toHaveBeenCalled(); + await Promise.all([client.close(), server.close()]); + }); + + it("returns centralized MCP error details", async () => { + const { server, client } = await connectedClient({ + listSessions: vi.fn().mockRejectedValue(new CliError("auth", "Authentication required", 401)), + }); + const result = await client.callTool({ name: "session_list", arguments: {} }); + expect(result).toMatchObject({ + isError: true, + structuredContent: { + error: { code: "unauthenticated", message: "Authentication required", status: 401 }, + }, + }); + await Promise.all([client.close(), server.close()]); + }); + + it("forwards ordered event journal queries and tombstones", async () => { + const page = { + changes: [{ kind: "delete", revision: 8, eventId: "old-name" }], + checkpoint: 8, + hasMore: false, + }; + const operations = { events: vi.fn().mockResolvedValue(page) }; + const { server, client } = await connectedClient(operations); + const result = await client.callTool({ + name: "session_events", + arguments: { sessionId: "s1", after: 7, limit: 50 }, + }); + expect(operations.events).toHaveBeenCalledWith("s1", { after: 7, limit: 50 }); + expect(result.structuredContent).toEqual(page); + await Promise.all([client.close(), server.close()]); + }); + + it("rejects wait timeouts above five minutes before dispatch", async () => { + const operations = { wait: vi.fn() }; + const { server, client } = await connectedClient(operations); + const result = await client.callTool({ + name: "session_wait", + arguments: { sessionId: "s1", timeoutMs: 300_001 }, + }); + expect(result.isError).toBe(true); + expect(operations.wait).not.toHaveBeenCalled(); + await Promise.all([client.close(), server.close()]); + }); + + it("returns typed isError before constructing an oversized MCP result", async () => { + const { server, client } = await connectedClient({ + listSessions: vi.fn().mockResolvedValue({ value: "x".repeat(1024 * 1024) }), + }); + const result = await client.callTool({ name: "session_list", arguments: {} }); + expect(result).toMatchObject({ + isError: true, + structuredContent: { error: { code: "service_unavailable" } }, + }); + expect(JSON.stringify(result)).not.toContain("xxx"); + expect(Buffer.byteLength(JSON.stringify(result))).toBeLessThanOrEqual(MAX_MCP_RESULT_BYTES); + await Promise.all([client.close(), server.close()]); + }); + + it("accepts an exact 1 MiB final result envelope and rejects one additional byte", () => { + const emptyEnvelopeBytes = Buffer.byteLength( + JSON.stringify({ content: [], structuredContent: { value: "" } }) + ); + const exact = toolResult({ value: "x".repeat(MAX_MCP_RESULT_BYTES - emptyEnvelopeBytes) }); + expect(Buffer.byteLength(JSON.stringify(exact))).toBe(MAX_MCP_RESULT_BYTES); + expect(() => + toolResult({ value: "x".repeat(MAX_MCP_RESULT_BYTES - emptyEnvelopeBytes + 1) }) + ).toThrow("exceeded"); + }); +}); diff --git a/packages/cli/src/mcp-server.ts b/packages/cli/src/mcp-server.ts new file mode 100644 index 0000000000..da9c77ce0f --- /dev/null +++ b/packages/cli/src/mcp-server.ts @@ -0,0 +1,518 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { readFile, realpath } from "node:fs/promises"; +import { basename, isAbsolute, relative } from "node:path"; +import { fileURLToPath } from "node:url"; +import { z } from "zod"; +import { + externalCreateSessionRequestSchema, + externalEventPageSchema, + externalEventFeedQuerySchema, + externalFollowUpRequestSchema, + externalFollowUpResponseSchema, + externalSessionListQuerySchema, + externalSessionListResponseSchema, + externalSessionSchema, + externalSessionWaitResponseSchema, + externalStopSessionResponseSchema, +} from "@open-inspect/shared/types/external-session-api"; +import { + externalChildPromptRequestSchema, + externalEnvironmentListResponseSchema, + externalEnvironmentResponseSchema, + externalKeysetListQuerySchema, + externalListQuerySchema, + externalMessageListResponseSchema, + externalArtifactListResponseSchema, + externalArtifactContentResponseSchema, + externalDiffStateResponseSchema, + externalDiffContentResponseSchema, + externalModelListResponseSchema, + externalProviderAccountListResponseSchema, + externalPullRequestListResponseSchema, + externalPullRequestSchema, + externalRepositoryListResponseSchema, + externalSkillListResponseSchema, + externalChildSessionListResponseSchema, + externalChildSessionSchema, +} from "@open-inspect/shared/types/external-resources-api"; +import { classifyError, CliError, publicErrorCode, withErrorContext } from "./errors.js"; +import type { Operations } from "./operations.js"; +import { validateAttachmentBytes } from "./attachments.js"; + +const sessionId = z.string().min(1).describe("Open Inspect session ID"); +const MAX_WAIT_TIMEOUT_MS = 300_000; +export const MAX_MCP_RESULT_BYTES = 1024 * 1024; +const polling = { + pollIntervalMs: z.number().int().min(100).max(30_000).optional(), + timeoutMs: z.number().int().nonnegative().max(MAX_WAIT_TIMEOUT_MS).optional(), +}; +const createToolOutputSchema = z + .strictObject({ + sessionId: z.string().min(1), + status: z.enum(["created", "queued"]), + messageId: z.string().min(1).optional(), + url: z.string().optional(), + }) + .superRefine((result, ctx) => { + if ((result.status === "queued") !== Boolean(result.messageId)) { + ctx.addIssue({ + code: "custom", + message: "messageId must be present exactly when status is queued", + path: ["messageId"], + }); + } + }); +const artifactToolOutputSchema = coherentOutputSchema( + { + ...externalArtifactListResponseSchema.shape, + ...externalArtifactContentResponseSchema.shape, + }, + [externalArtifactListResponseSchema, externalArtifactContentResponseSchema] +); +const diffToolOutputSchema = coherentOutputSchema( + { + ...externalDiffStateResponseSchema.shape, + ...externalDiffContentResponseSchema.shape, + }, + [externalDiffStateResponseSchema, externalDiffContentResponseSchema] +); +const pullRequestToolOutputSchema = coherentOutputSchema( + { + ...externalPullRequestListResponseSchema.shape, + ...externalPullRequestSchema.shape, + }, + [externalPullRequestListResponseSchema, externalPullRequestSchema] +); +const childToolOutputSchema = coherentOutputSchema( + { + ...externalChildSessionListResponseSchema.shape, + ...externalChildSessionSchema.shape, + }, + [externalChildSessionListResponseSchema, externalChildSessionSchema] +); + +/** Creates the full V1 MCP surface over the shared operations layer. */ +export function createMcpServer(operations: Operations): McpServer { + const server = new McpServer({ name: "open-inspect", version: "0.1.0" }); + + server.registerTool( + "session_create", + { + description: "Create an asynchronous session. Use session_prompt for later follow-ups.", + inputSchema: { + ...externalCreateSessionRequestSchema.shape, + attachmentPaths: z.array(z.string()).max(6).optional(), + }, + outputSchema: createToolOutputSchema, + }, + async ({ attachmentPaths, ...input }) => + runTool(async () => { + const attachmentCount = + (attachmentPaths?.length ?? 0) + (input.initialAttachments?.length ?? 0); + if (attachmentCount > 6) + throw new CliError("validation", "A prompt may include at most 6 attachments"); + const parsed = externalCreateSessionRequestSchema.parse({ + ...input, + ...(attachmentPaths?.length + ? { + initialPrompt: undefined, + initialAttachments: undefined, + initialAttachmentCount: attachmentCount, + } + : {}), + }); + const localFiles = await resolveMcpAttachments(server, attachmentPaths ?? []); + const created = await operations.createSession(parsed); + if (!attachmentPaths?.length) return created; + try { + const uploadedAttachments = await uploadMcpAttachments( + operations, + created.sessionId, + localFiles, + input.idempotencyKey + ); + const attachments = [...(input.initialAttachments ?? []), ...uploadedAttachments]; + if (!input.initialPrompt?.trim() && attachments.length === 0) return created; + const prompted = await operations.promptSession(created.sessionId, { + content: input.initialPrompt, + attachments, + clientRequestId: `external-create:${input.idempotencyKey}`, + model: input.model, + reasoningEffort: input.reasoningEffort, + }); + return { sessionId: created.sessionId, ...prompted }; + } catch (cause) { + throw withErrorContext(cause, { + sessionId: created.sessionId, + failedStage: "attachment_or_prompt", + idempotencyKey: input.idempotencyKey, + }); + } + }) + ); + registerDiscoveryTools(server, operations); + server.registerTool( + "session_list", + { + description: "List sessions visible to the authenticated workspace user", + inputSchema: { ...externalSessionListQuerySchema.shape }, + outputSchema: externalSessionListResponseSchema, + }, + async (query) => + runTool(() => operations.listSessions(externalSessionListQuerySchema.parse(query))) + ); + server.registerTool( + "session_get", + { + description: "Get a canonical session summary and related resource identifiers", + inputSchema: { sessionId }, + outputSchema: externalSessionSchema, + }, + async ({ sessionId }) => runTool(() => operations.getSession(sessionId)) + ); + server.registerTool( + "session_prompt", + { + description: "Send an idempotent follow-up; use session_create only for new sessions", + inputSchema: { + sessionId, + ...externalFollowUpRequestSchema.shape, + attachmentPaths: z.array(z.string()).max(6).optional(), + }, + outputSchema: externalFollowUpResponseSchema, + }, + async ({ sessionId, attachmentPaths, ...input }) => + runTool(async () => { + if ((attachmentPaths?.length ?? 0) + (input.attachments?.length ?? 0) > 6) { + throw new CliError("validation", "A prompt may include at most 6 attachments"); + } + const files = await resolveMcpAttachments(server, attachmentPaths ?? []); + const local = files.length + ? await uploadMcpAttachments(operations, sessionId, files, input.clientRequestId) + : []; + return operations.promptSession( + sessionId, + externalFollowUpRequestSchema.parse({ + ...input, + attachments: [...(input.attachments ?? []), ...local], + }) + ); + }) + ); + server.registerTool( + "session_stop", + { + description: "Stop a session", + inputSchema: { sessionId }, + outputSchema: externalStopSessionResponseSchema, + }, + async ({ sessionId }) => runTool(() => operations.stopSession(sessionId)) + ); + server.registerTool( + "session_events", + { + description: "Read a bounded trajectory snapshot or changes after a checkpoint", + inputSchema: { sessionId, ...externalEventFeedQuerySchema.shape }, + outputSchema: externalEventPageSchema, + }, + async ({ sessionId, ...query }) => + runTool(() => operations.events(sessionId, externalEventFeedQuerySchema.parse(query))) + ); + server.registerTool( + "session_wait", + { + description: "Wait for terminal session state; use session_events for progress", + inputSchema: { sessionId, ...polling }, + outputSchema: externalSessionWaitResponseSchema, + }, + async ({ sessionId, ...options }) => runTool(() => operations.wait(sessionId, options)) + ); + registerSessionReadTools(server, operations); + + return server; +} + +function registerDiscoveryTools(server: McpServer, operations: Operations): void { + const page = { ...externalListQuerySchema.shape }; + server.registerTool( + "repository_list", + { + description: "Discover usable repositories", + inputSchema: page, + outputSchema: externalRepositoryListResponseSchema, + }, + (input) => runTool(() => operations.listRepositories(externalListQuerySchema.parse(input))) + ); + server.registerTool( + "environment_list", + { + description: "Discover saved environments", + inputSchema: page, + outputSchema: externalEnvironmentListResponseSchema, + }, + (input) => runTool(() => operations.listEnvironments(externalListQuerySchema.parse(input))) + ); + server.registerTool( + "environment_get", + { + description: "Read one saved environment", + inputSchema: { environmentId: z.string().min(1) }, + outputSchema: externalEnvironmentResponseSchema, + }, + ({ environmentId }) => runTool(() => operations.getEnvironment(environmentId)) + ); + server.registerTool( + "model_list", + { + description: "List enabled models and reasoning options", + outputSchema: externalModelListResponseSchema, + }, + () => runTool(() => operations.listModels()) + ); + server.registerTool( + "skill_list", + { + description: "Discover managed skills and owned profiles", + inputSchema: page, + outputSchema: externalSkillListResponseSchema, + }, + (input) => runTool(() => operations.listSkills(externalListQuerySchema.parse(input))) + ); + server.registerTool( + "provider_account_list", + { + description: "Discover selectable non-secret provider accounts", + inputSchema: page, + outputSchema: externalProviderAccountListResponseSchema, + }, + (input) => runTool(() => operations.listProviderAccounts(externalListQuerySchema.parse(input))) + ); +} + +function registerSessionReadTools(server: McpServer, operations: Operations): void { + server.registerTool( + "session_messages", + { + description: "Read persisted conversation messages", + inputSchema: { + sessionId, + limit: z.number().int().min(1).max(100).optional(), + cursor: z.string().optional(), + }, + outputSchema: externalMessageListResponseSchema, + }, + ({ sessionId, ...options }) => runTool(() => operations.messages(sessionId, options)) + ); + server.registerTool( + "session_artifacts", + { + description: "List bounded typed artifacts or retrieve screenshot/video content by ID", + inputSchema: { + sessionId, + artifactId: z.string().min(1).optional(), + contentOffset: z.number().int().nonnegative().optional(), + contentLimit: z + .number() + .int() + .min(1) + .max(512 * 1024) + .optional(), + ...externalKeysetListQuerySchema.shape, + }, + outputSchema: artifactToolOutputSchema, + }, + ({ sessionId, artifactId, contentOffset, contentLimit, ...page }) => + runTool(() => + artifactId + ? operations.artifactContent(sessionId, artifactId, { + offset: contentOffset, + limit: contentLimit, + }) + : operations.artifacts(sessionId, externalKeysetListQuerySchema.parse(page)) + ) + ); + server.registerTool( + "session_diff", + { + description: "Read bounded diff state or one revision-pinned file patch", + inputSchema: { + sessionId, + revisionId: z.string().optional(), + fileId: z.string().optional(), + contentOffset: z.number().int().nonnegative().optional(), + contentLimit: z + .number() + .int() + .min(1) + .max(512 * 1024) + .optional(), + ...externalListQuerySchema.shape, + }, + outputSchema: diffToolOutputSchema, + }, + ({ sessionId, revisionId, fileId, contentOffset, contentLimit, ...page }) => + runTool(() => { + if (Boolean(revisionId) !== Boolean(fileId)) { + throw new CliError("validation", "revisionId and fileId must be provided together"); + } + return revisionId && fileId + ? operations.diffFile(sessionId, revisionId, fileId, { + offset: contentOffset, + limit: contentLimit, + }) + : operations.diff(sessionId, externalListQuerySchema.parse(page)); + }) + ); + server.registerTool( + "session_pull_requests", + { + description: "List bounded pull requests or retrieve one by its session-scoped ID", + inputSchema: { + sessionId, + pullRequestId: z.string().min(1).optional(), + ...externalListQuerySchema.shape, + }, + outputSchema: pullRequestToolOutputSchema, + }, + ({ sessionId, pullRequestId, ...page }) => + runTool(() => + pullRequestId + ? operations.pullRequest(sessionId, pullRequestId) + : operations.pullRequests(sessionId, externalListQuerySchema.parse(page)) + ) + ); + server.registerTool( + "session_children", + { + description: "List bounded direct children or inspect one child", + inputSchema: { sessionId, childId: z.string().optional(), ...externalListQuerySchema.shape }, + outputSchema: childToolOutputSchema, + }, + ({ sessionId, childId, ...page }) => + runTool(() => + childId + ? operations.child(sessionId, childId) + : operations.children(sessionId, externalListQuerySchema.parse(page)) + ) + ); + server.registerTool( + "session_child_prompt", + { + description: "Send a canonical-user follow-up to an existing direct child", + inputSchema: { + sessionId, + childId: z.string().min(1), + ...externalChildPromptRequestSchema.shape, + }, + outputSchema: externalFollowUpResponseSchema, + }, + ({ sessionId, childId, ...input }) => + runTool(() => + operations.promptChild(sessionId, childId, externalChildPromptRequestSchema.parse(input)) + ) + ); +} + +async function resolveMcpAttachments( + server: McpServer, + paths: string[] +): Promise<Array<{ name: string; bytes: Uint8Array }>> { + if (paths.length === 0) return []; + if (!server.server.getClientCapabilities()?.roots) { + throw new CliError("validation", "MCP client did not negotiate filesystem roots"); + } + const roots = (await server.server.listRoots()).roots + .filter(({ uri }) => uri.startsWith("file:")) + .map(({ uri }) => fileURLToPath(uri)); + if (roots.length === 0) throw new CliError("validation", "No MCP filesystem roots are available"); + const realRoots = await Promise.all(roots.map((root) => realpath(root))); + const files = []; + for (const path of paths) { + const resolved = await realpath(path); + if ( + !realRoots.some((root) => { + const child = relative(root, resolved); + return child === "" || (!child.startsWith("..") && !isAbsolute(child)); + }) + ) { + throw new CliError("validation", `Attachment is outside negotiated roots: ${basename(path)}`); + } + const name = basename(resolved); + const bytes = await readFile(resolved); + validateAttachmentBytes(bytes, name); + files.push({ name, bytes }); + } + return files; +} + +async function uploadMcpAttachments( + operations: Operations, + sessionIdValue: string, + files: Array<{ name: string; bytes: Uint8Array }>, + idempotencyKey: string +): Promise<Array<{ attachmentId: string; name: string }>> { + const uploaded = []; + for (const [index, { name, bytes }] of files.entries()) { + const result = await operations.uploadAttachment( + sessionIdValue, + new Blob([bytes]), + name, + `${idempotencyKey}:${index}` + ); + uploaded.push({ attachmentId: result.attachmentId, name }); + } + return uploaded; +} + +/** Starts stdio transport without writing non-protocol data to stdout. */ +export async function serveMcp(operations: Operations): Promise<void> { + await createMcpServer(operations).connect(new StdioServerTransport()); +} + +export function toolResult(value: unknown) { + const result = { content: [], structuredContent: asRecord(value) }; + if (Buffer.byteLength(JSON.stringify(result)) > MAX_MCP_RESULT_BYTES) + throw new CliError("service", `MCP result exceeded ${MAX_MCP_RESULT_BYTES} bytes`); + return result; +} + +async function runTool(action: () => Promise<unknown>) { + try { + return toolResult(await action()); + } catch (cause) { + const error = classifyError(cause); + return { + isError: true, + content: [{ type: "text" as const, text: error.message }], + structuredContent: { + error: { + code: publicErrorCode(error), + message: error.message, + ...(error.status ? { status: error.status } : {}), + ...(error.context ? { context: error.context } : {}), + }, + }, + }; + } +} + +function asRecord(value: unknown): Record<string, unknown> { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? (value as Record<string, unknown>) + : { result: value }; +} + +function coherentOutputSchema( + shape: z.ZodRawShape, + variants: readonly z.ZodType[] +): z.ZodObject<z.ZodRawShape> { + return z + .looseObject(shape) + .partial() + .superRefine((result, ctx) => { + if (variants.filter((variant) => variant.safeParse(result).success).length !== 1) { + ctx.addIssue({ code: "custom", message: "Output must match exactly one operation mode" }); + } + }); +} diff --git a/packages/cli/src/operations.test.ts b/packages/cli/src/operations.test.ts new file mode 100644 index 0000000000..ac869f4989 --- /dev/null +++ b/packages/cli/src/operations.test.ts @@ -0,0 +1,309 @@ +import type { + ExternalEvent, + ExternalEventChange, +} from "@open-inspect/shared/types/external-session-api"; +import { describe, expect, it, vi } from "vitest"; +import { CliError } from "./errors.js"; +import { Operations } from "./operations.js"; + +function event(id: string, createdAt: number, text = id): ExternalEvent { + return { + id, + type: "token", + messageId: null, + createdAt, + data: { text }, + }; +} + +function upsert(revision: number, value: ExternalEvent): ExternalEventChange { + return { kind: "upsert", revision, event: value }; +} + +function remove(revision: number, eventId: string): ExternalEventChange { + return { kind: "delete", revision, eventId }; +} + +describe("Operations", () => { + it("preserves caller-supplied idempotency and client request IDs", async () => { + const api = { + createSession: vi.fn((input: unknown) => Promise.resolve(input)), + promptSession: vi.fn((_id: string, input: unknown) => Promise.resolve(input)), + }; + const operations = new Operations(api as never); + + await operations.createSession({ + title: "T", + model: "openai/gpt-5.6-sol", + reasoningEffort: "high", + idempotencyKey: "caller-create", + }); + await operations.promptSession("s1", { content: "P", clientRequestId: "caller-prompt" }); + + expect(api.createSession).toHaveBeenCalledWith( + expect.objectContaining({ idempotencyKey: "caller-create" }) + ); + expect(api.promptSession).toHaveBeenCalledWith( + "s1", + expect.objectContaining({ clientRequestId: "caller-prompt" }) + ); + }); + + it("passes session list pagination through unchanged", async () => { + const api = { listSessions: vi.fn().mockResolvedValue({ sessions: [], hasMore: false }) }; + const operations = new Operations(api as never); + + await operations.listSessions({ limit: 40, offset: 80 }); + + expect(api.listSessions).toHaveBeenCalledWith({ limit: 40, offset: 80 }); + }); + + it("preserves snapshot order and resumes strict journal changes with tombstones and rename", async () => { + const initial = [ + upsert(2, event("event-1", 1)), + upsert(3, event("event-2", 2)), + upsert(1, event("event-3", 3)), + ]; + const incremental = [ + upsert(4, event("event-1", 1, "updated older event")), + upsert(5, event("event-4", 4)), + remove(6, "event-3"), + upsert(7, event("event-1", 1, "second update")), + remove(8, "event-4"), + upsert(9, event("event-4-renamed", 4)), + ]; + const api = { + events: vi + .fn() + .mockResolvedValueOnce({ + changes: initial.slice(0, 2), + checkpoint: 3, + cursor: "initial:2", + hasMore: true, + }) + .mockResolvedValueOnce({ changes: initial.slice(2), checkpoint: 3, hasMore: false }) + .mockResolvedValueOnce({ + changes: incremental.slice(0, 2), + checkpoint: 9, + cursor: "delta:2", + hasMore: true, + }) + .mockRejectedValueOnce(new CliError("transport", "connection reset")) + .mockResolvedValueOnce({ + changes: incremental.slice(0, 2), + checkpoint: 9, + cursor: "delta:2-retry", + hasMore: true, + }) + .mockResolvedValueOnce({ changes: incremental.slice(2), checkpoint: 9, hasMore: false }), + }; + const sleep = vi.fn(() => Promise.resolve()); + const operations = new Operations(api as never, { sleep }); + const controller = new AbortController(); + const seen: ExternalEventChange[] = []; + + for await (const change of operations.followEvents("s1", { + pollIntervalMs: 250, + timeoutMs: 5_000, + signal: controller.signal, + })) { + seen.push(change); + if (seen.length === initial.length + incremental.length) controller.abort(); + } + + expect(seen).toEqual([...initial, ...incremental]); + expect(seen.map(({ revision }) => revision)).toEqual([2, 3, 1, 4, 5, 6, 7, 8, 9]); + expect( + seen + .slice(initial.length) + .every((change, index, changes) => + index === 0 ? change.revision > 3 : change.revision > changes[index - 1]!.revision + ) + ).toBe(true); + expect(seen.slice(-2)).toEqual([remove(8, "event-4"), upsert(9, event("event-4-renamed", 4))]); + expect(api.events.mock.calls.map((call) => call[1])).toEqual([ + { after: undefined, signal: controller.signal }, + { cursor: "initial:2", signal: controller.signal }, + { after: 3, signal: controller.signal }, + { cursor: "delta:2", signal: controller.signal }, + { after: 3, signal: controller.signal }, + { cursor: "delta:2-retry", signal: controller.signal }, + ]); + expect(sleep).toHaveBeenCalledTimes(2); + }); + + it("retries from the prior checkpoint when pages disagree or revisions regress", async () => { + const change = upsert(6, event("event-1", 1)); + const api = { + events: vi + .fn() + .mockResolvedValueOnce({ changes: [], checkpoint: 5, hasMore: false }) + .mockResolvedValueOnce({ changes: [change], checkpoint: 7, cursor: "next", hasMore: true }) + .mockResolvedValueOnce({ changes: [remove(7, "event-2")], checkpoint: 8, hasMore: false }) + .mockResolvedValueOnce({ changes: [change], checkpoint: 7, cursor: "retry", hasMore: true }) + .mockResolvedValueOnce({ changes: [remove(6, "event-2")], checkpoint: 7, hasMore: false }) + .mockResolvedValueOnce({ + changes: [change, remove(7, "event-2")], + checkpoint: 7, + hasMore: false, + }), + }; + const controller = new AbortController(); + const operations = new Operations(api as never, { sleep: () => Promise.resolve() }); + const iterator = operations.followEvents("s1", { signal: controller.signal }); + + await expect(iterator.next()).resolves.toMatchObject({ value: change }); + controller.abort(); + await iterator.return(undefined); + expect(api.events.mock.calls.slice(1).map((call) => call[1]?.after)).toEqual([ + 5, + undefined, + 5, + undefined, + 5, + ]); + }); + + it("deduplicates replayed event IDs unless their revision is greater", async () => { + const first = upsert(5, event("event-1", 1, "new")); + const api = { + events: vi + .fn() + .mockResolvedValueOnce({ changes: [first], checkpoint: 5, hasMore: false }) + .mockRejectedValueOnce(new CliError("expired", "checkpoint expired")) + .mockResolvedValueOnce({ + changes: [upsert(4, event("event-1", 1, "old")), upsert(6, event("event-1", 1, "newer"))], + checkpoint: 6, + hasMore: false, + }), + }; + const controller = new AbortController(); + const seen: ExternalEventChange[] = []; + const operations = new Operations(api as never, { sleep: () => Promise.resolve() }); + + for await (const change of operations.followEvents("s1", { signal: controller.signal })) { + seen.push(change); + if (seen.length === 2) controller.abort(); + } + + expect(seen).toEqual([first, upsert(6, event("event-1", 1, "newer"))]); + expect(api.events.mock.calls.map((call) => call[1]?.after)).toEqual([undefined, 5, undefined]); + }); + + it("waits until settled", async () => { + const api = { + waitStatus: vi + .fn() + .mockResolvedValueOnce({ sessionId: "s1", status: "running", settled: false }) + .mockResolvedValueOnce({ sessionId: "s1", status: "completed", settled: true }), + }; + const operations = new Operations(api as never, { sleep: () => Promise.resolve() }); + await expect( + operations.wait("s1", { pollIntervalMs: 1, timeoutMs: 100 }) + ).resolves.toMatchObject({ settled: true }); + }); + + it("caps polling sleeps and returns the latest observed status on timeout", async () => { + let now = 0; + const sleep = vi.fn(async (milliseconds: number) => { + now += milliseconds; + }); + const waitStatus = vi.fn().mockResolvedValue({ + sessionId: "s1", + status: "running", + settled: false, + latestAssistantMessage: { id: "m1", content: "working", completedAt: null }, + }); + const operations = new Operations({ waitStatus } as never, { now: () => now, sleep }); + + await expect(operations.wait("s1", { pollIntervalMs: 1_000, timeoutMs: 75 })).resolves.toEqual({ + sessionId: "s1", + status: "running", + settled: false, + timedOut: true, + latestAssistantMessage: { id: "m1", content: "working", completedAt: null }, + }); + expect(sleep).toHaveBeenCalledWith(75, undefined); + expect(waitStatus).toHaveBeenCalledTimes(1); + }); + + it("cancels a hanging status fetch at the deadline without a final fetch", async () => { + vi.useFakeTimers(); + try { + const waitStatus = vi + .fn() + .mockResolvedValueOnce({ sessionId: "s1", status: "running", settled: false }) + .mockImplementationOnce( + (_id: string, signal: AbortSignal) => + new Promise((_resolve, reject) => { + signal.addEventListener("abort", () => reject(signal.reason), { once: true }); + }) + ); + const operations = new Operations({ waitStatus } as never); + + const waiting = operations.wait("s1", { pollIntervalMs: 100, timeoutMs: 250 }); + await vi.advanceTimersByTimeAsync(250); + + await expect(waiting).resolves.toEqual({ + sessionId: "s1", + status: "running", + settled: false, + timedOut: true, + }); + expect(waitStatus).toHaveBeenCalledTimes(2); + expect(waitStatus.mock.calls[1]?.[1].aborted).toBe(true); + } finally { + vi.useRealTimers(); + } + }); + + it("times out a hanging initial fetch when no status was observed", async () => { + vi.useFakeTimers(); + try { + const waitStatus = vi.fn( + (_id: string, signal: AbortSignal) => + new Promise((_resolve, reject) => { + signal.addEventListener("abort", () => reject(signal.reason), { once: true }); + }) + ); + const waiting = new Operations({ waitStatus } as never).wait("s1", { timeoutMs: 50 }); + const assertion = expect(waiting).rejects.toMatchObject({ kind: "timeout" }); + + await vi.advanceTimersByTimeAsync(50); + + await assertion; + expect(waitStatus).toHaveBeenCalledTimes(1); + } finally { + vi.useRealTimers(); + } + }); + + it("preserves caller abort reasons during a deferred status fetch", async () => { + const controller = new AbortController(); + const reason = new Error("caller stopped waiting"); + const waitStatus = vi.fn( + (_id: string, signal: AbortSignal) => + new Promise((_resolve, reject) => { + signal.addEventListener("abort", () => reject(signal.reason), { once: true }); + }) + ); + const waiting = new Operations({ waitStatus } as never).wait("s1", { + timeoutMs: 10_000, + signal: controller.signal, + }); + + controller.abort(reason); + + await expect(waiting).rejects.toBe(reason); + }); + + it("treats a zero timeout as immediate and does not start a status fetch", async () => { + const waitStatus = vi.fn(); + const operations = new Operations({ waitStatus } as never); + + await expect(operations.wait("s1", { timeoutMs: 0 })).rejects.toMatchObject({ + kind: "timeout", + }); + expect(waitStatus).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/cli/src/operations.ts b/packages/cli/src/operations.ts new file mode 100644 index 0000000000..a0e22739c7 --- /dev/null +++ b/packages/cli/src/operations.ts @@ -0,0 +1,311 @@ +import type { + ExternalCreateSessionRequest, + ExternalEventChange, + ExternalEventFeedQuery, + ExternalFollowUpRequest, + ExternalSessionListQuery, +} from "@open-inspect/shared/types/external-session-api"; +import type { + ExternalDiffListQuery, + ExternalKeysetListQuery, + ExternalListQuery, +} from "@open-inspect/shared/types/external-resources-api"; +import type { ApiClient } from "./api-client.js"; +import { CliError } from "./errors.js"; + +interface PollOptions { + after?: number; + pollIntervalMs?: number; + timeoutMs?: number; + signal?: AbortSignal; +} +interface OperationsDependencies { + now?: () => number; + sleep?: (milliseconds: number, signal?: AbortSignal) => Promise<void>; +} + +const DEFAULT_POLL_INTERVAL_MS = 1_000; +const DEFAULT_TIMEOUT_MS = 60_000; +const MIN_POLL_INTERVAL_MS = 100; +const MAX_POLL_INTERVAL_MS = 30_000; + +/** Implements session behavior once for both command and MCP adapters. */ +export class Operations { + private readonly now: () => number; + private readonly sleep: (milliseconds: number, signal?: AbortSignal) => Promise<void>; + + constructor( + private readonly api: ApiClient, + dependencies: OperationsDependencies = {} + ) { + this.now = dependencies.now ?? Date.now; + this.sleep = dependencies.sleep ?? abortableSleep; + } + + createSession(input: ExternalCreateSessionRequest) { + return this.api.createSession(input); + } + + listRepositories(options?: ExternalListQuery) { + return this.api.listRepositories(options); + } + + listEnvironments(options?: ExternalListQuery) { + return this.api.listEnvironments(options); + } + + getEnvironment(id: string) { + return this.api.getEnvironment(id); + } + + listModels() { + return this.api.listModels(); + } + + listSkills(options?: ExternalListQuery) { + return this.api.listSkills(options); + } + + listProviderAccounts(options?: ExternalListQuery) { + return this.api.listProviderAccounts(options); + } + + listSessions(options?: ExternalSessionListQuery & { signal?: AbortSignal }) { + return this.api.listSessions(options); + } + getSession(id: string, signal?: AbortSignal) { + return this.api.getSession(id, signal); + } + + promptSession(id: string, input: ExternalFollowUpRequest) { + return this.api.promptSession(id, input); + } + + uploadAttachment(id: string, file: Blob, name: string, idempotencyKey?: string) { + return this.api.uploadAttachment(id, file, name, idempotencyKey); + } + + messages(id: string, options?: { limit?: number; cursor?: string }) { + return this.api.messages(id, options); + } + + artifacts(id: string, options?: ExternalKeysetListQuery) { + return this.api.artifacts(id, options); + } + + artifactContent(id: string, artifactId: string, options?: { offset?: number; limit?: number }) { + return this.api.artifactContent(id, artifactId, options); + } + + diff(id: string, options?: ExternalDiffListQuery) { + return this.api.diff(id, options); + } + + diffFile( + id: string, + revisionId: string, + fileId: string, + options?: { offset?: number; limit?: number } + ) { + return this.api.diffFile(id, revisionId, fileId, options); + } + + pullRequests(id: string, options?: ExternalListQuery) { + return this.api.pullRequests(id, options); + } + + pullRequest(id: string, pullRequestId: string) { + return this.api.pullRequest(id, pullRequestId); + } + + children(id: string, options?: ExternalListQuery) { + return this.api.children(id, options); + } + + child(id: string, childId: string) { + return this.api.child(id, childId); + } + + promptChild(id: string, childId: string, input: { content: string; clientRequestId: string }) { + return this.api.promptChild(id, childId, input); + } + + stopSession(id: string) { + return this.api.stopSession(id); + } + events(id: string, options?: ExternalEventFeedQuery & { signal?: AbortSignal }) { + return this.api.events(id, options); + } + + async *followEvents(id: string, options: PollOptions = {}): AsyncGenerator<ExternalEventChange> { + const { interval, deadline } = this.pollSettings(options); + let checkpoint = options.after; + const revisions = new Map<string, number>(); + while (!options.signal?.aborted && this.now() <= deadline) { + let snapshot; + try { + snapshot = await this.readEventSnapshot(id, checkpoint, options.signal); + } catch (cause) { + if (cause instanceof CliError && cause.kind === "expired") { + checkpoint = undefined; + continue; + } + if (!isRetryableFeedError(cause)) throw cause; + await this.sleep(retryDelayMs(cause, interval), options.signal); + continue; + } + checkpoint = snapshot.checkpoint; + for (const change of snapshot.changes) { + const eventId = change.kind === "upsert" ? change.event.id : change.eventId; + const priorRevision = revisions.get(eventId); + if (priorRevision !== undefined && change.revision <= priorRevision) continue; + revisions.set(eventId, change.revision); + yield change; + } + if (options.signal?.aborted) break; + await this.sleep(interval, options.signal); + } + if (!options.signal?.aborted && this.now() > deadline) + throw new CliError("timeout", "Event polling timed out"); + } + + private async readEventSnapshot( + id: string, + after?: number, + signal?: AbortSignal + ): Promise<{ changes: ExternalEventChange[]; checkpoint: number }> { + const changes: ExternalEventChange[] = []; + const visitedCursors = new Set<string>(); + let cursor: string | undefined; + let checkpoint: number | undefined; + let revision = after; + do { + const page = await this.api.events(id, cursor ? { cursor, signal } : { after, signal }); + if (checkpoint !== undefined && page.checkpoint !== checkpoint) + throw new CliError("service", "Event pagination checkpoint changed between pages"); + checkpoint = page.checkpoint; + if (page.changes.some((change) => change.revision > page.checkpoint)) + throw new CliError("service", "Event change revision exceeded the pinned checkpoint"); + if (after !== undefined) { + for (const change of page.changes) { + if (revision !== undefined && change.revision <= revision) + throw new CliError( + "service", + "Incremental event revisions were not strictly increasing" + ); + revision = change.revision; + } + } + changes.push(...page.changes); + if (!page.hasMore) return { changes, checkpoint: page.checkpoint }; + cursor = page.cursor; + if (!cursor || visitedCursors.has(cursor)) + throw new CliError("service", "Event pagination returned a repeated or missing cursor"); + visitedCursors.add(cursor); + } while (!signal?.aborted); + throw signal?.reason ?? new CliError("timeout", "Event polling was aborted"); + } + + async wait(id: string, options: PollOptions = {}) { + const { interval, deadline } = this.pollSettings(options); + let latest: Awaited<ReturnType<ApiClient["waitStatus"]>> | undefined; + while (true) { + if (options.signal?.aborted) throw abortReason(options.signal); + const remaining = deadline - this.now(); + if (remaining <= 0) return timedOut(latest); + + const result = await withDeadline( + (signal) => this.api.waitStatus(id, signal), + remaining, + options.signal + ).catch((cause) => { + if (options.signal?.aborted) throw abortReason(options.signal); + if (cause instanceof WaitDeadlineError) return timedOut(latest); + throw cause; + }); + if (result.timedOut) return result; + latest = result; + if (result.settled) return result; + + const sleepMs = Math.min(interval, Math.max(0, deadline - this.now())); + if (sleepMs === 0) return timedOut(latest); + await this.sleep(sleepMs, options.signal); + } + } + + private pollSettings(options: PollOptions): { interval: number; deadline: number } { + const interval = Math.min( + MAX_POLL_INTERVAL_MS, + Math.max(MIN_POLL_INTERVAL_MS, options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS) + ); + const timeout = Math.max(0, options.timeoutMs ?? DEFAULT_TIMEOUT_MS); + return { interval, deadline: this.now() + timeout }; + } +} + +function abortableSleep(milliseconds: number, signal?: AbortSignal): Promise<void> { + return new Promise((resolve, reject) => { + if (signal?.aborted) return reject(signal.reason ?? new Error("Operation aborted")); + const timer = setTimeout(resolve, milliseconds); + signal?.addEventListener( + "abort", + () => { + clearTimeout(timer); + reject(signal.reason ?? new Error("Operation aborted")); + }, + { once: true } + ); + }); +} + +function withDeadline<T>( + operation: (signal: AbortSignal) => Promise<T>, + milliseconds: number, + callerSignal?: AbortSignal +): Promise<T> { + const controller = new AbortController(); + const aborted = new Promise<never>((_resolve, reject) => { + controller.signal.addEventListener("abort", () => reject(abortReason(controller.signal)), { + once: true, + }); + }); + const abortFromCaller = () => { + if (callerSignal) controller.abort(abortReason(callerSignal)); + }; + callerSignal?.addEventListener("abort", abortFromCaller, { once: true }); + if (callerSignal?.aborted) abortFromCaller(); + const timer = setTimeout( + () => controller.abort(new WaitDeadlineError()), + Math.ceil(milliseconds) + ); + timer.unref(); + + return Promise.race([operation(controller.signal), aborted]).finally(() => { + clearTimeout(timer); + callerSignal?.removeEventListener("abort", abortFromCaller); + }); +} + +class WaitDeadlineError extends Error {} + +function timedOut<T extends { settled: boolean }>(latest: T | undefined): T & { timedOut: true } { + if (!latest) throw new CliError("timeout", "Session wait timed out before a status was observed"); + return { ...latest, settled: false, timedOut: true }; +} + +function abortReason(signal: AbortSignal): unknown { + return signal.reason ?? new Error("Operation aborted"); +} + +function isRetryableFeedError(cause: unknown): boolean { + return ( + cause instanceof CliError && + (cause.kind === "transport" || cause.kind === "service" || cause.kind === "rate_limited") + ); +} + +function retryDelayMs(cause: unknown, fallback: number): number { + if (!(cause instanceof CliError) || cause.kind !== "rate_limited") return fallback; + const seconds = Number(cause.context?.retryAfter); + return Number.isFinite(seconds) && seconds > 0 ? seconds * 1_000 : fallback; +} diff --git a/packages/cli/src/output.test.ts b/packages/cli/src/output.test.ts new file mode 100644 index 0000000000..4d0187ac9c --- /dev/null +++ b/packages/cli/src/output.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import { Output } from "./output.js"; +import { CliError } from "./errors.js"; + +describe("Output", () => { + it("emits one JSON object per line for stream-json and keeps diagnostics off stdout", () => { + const stdout: string[] = []; + const stderr: string[] = []; + const output = new Output("stream-json", { + stdout: (value) => stdout.push(value), + stderr: (value) => stderr.push(value), + }); + + output.result({ id: 1 }); + output.result({ id: 2 }); + output.error("diagnostic"); + + expect(stdout).toEqual(['{"id":1}\n', '{"id":2}\n']); + expect(stderr).toEqual(["diagnostic\n"]); + }); + + it.each(["json", "stream-json"] as const)( + "emits a stable structured error envelope for %s", + (format) => { + const stderr: string[] = []; + const output = new Output(format, { stderr: (value) => stderr.push(value) }); + output.failure(new CliError("service", "Unavailable", 503, { idempotencyKey: "retry-id" })); + expect(JSON.parse(stderr.join(""))).toEqual({ + error: { + code: "service_unavailable", + message: "Unavailable", + status: 503, + context: { idempotencyKey: "retry-id" }, + }, + }); + } + ); + + it("renders the complete error envelope in text mode, including retry context", () => { + const stderr: string[] = []; + new Output("text", { stderr: (value) => stderr.push(value) }).failure( + new CliError("transport", "Request outcome unknown", undefined, { + clientRequestId: "retry-id", + }) + ); + expect(stderr).toEqual([ + 'error: {"code":"service_unavailable","message":"Request outcome unknown","context":{"clientRequestId":"retry-id"}}\n', + ]); + }); +}); diff --git a/packages/cli/src/output.ts b/packages/cli/src/output.ts new file mode 100644 index 0000000000..1663615c2d --- /dev/null +++ b/packages/cli/src/output.ts @@ -0,0 +1,56 @@ +export type OutputFormat = "text" | "json" | "stream-json"; + +interface OutputWriters { + stdout?: (value: string) => void; + stderr?: (value: string) => void; +} + +/** Keeps result output separate from diagnostics, including MCP-safe stderr logging. */ +export class Output { + private readonly stdout: (value: string) => void; + private readonly stderr: (value: string) => void; + + constructor( + readonly format: OutputFormat, + writers: OutputWriters = {} + ) { + this.stdout = writers.stdout ?? ((value) => process.stdout.write(value)); + this.stderr = writers.stderr ?? ((value) => process.stderr.write(value)); + } + + result(value: unknown): void { + if (this.format === "text") { + this.stdout(`${formatText(value)}\n`); + return; + } + this.stdout(`${JSON.stringify(value, null, this.format === "json" ? 2 : undefined)}\n`); + } + + error(message: string): void { + this.stderr(`${message}\n`); + } + + failure(cause: unknown): void { + const envelope = errorEnvelope(cause); + this.stderr( + this.format === "text" + ? `${formatText(envelope)}\n` + : `${JSON.stringify(envelope, null, this.format === "json" ? 2 : undefined)}\n` + ); + } +} + +function formatText(value: unknown): string { + if (typeof value === "string") return value; + if (value === undefined) return "OK"; + if (value !== null && typeof value === "object") { + return Object.entries(value) + .map( + ([key, child]) => + `${key}: ${typeof child === "object" ? JSON.stringify(child) : String(child)}` + ) + .join("\n"); + } + return String(value); +} +import { errorEnvelope } from "./errors.js"; diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json new file mode 100644 index 0000000000..dff27a6a47 --- /dev/null +++ b/packages/cli/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "types": ["node"], + "strict": true, + "skipLibCheck": true, + "isolatedModules": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/cli/vitest.config.ts b/packages/cli/vitest.config.ts new file mode 100644 index 0000000000..c1433e6ef3 --- /dev/null +++ b/packages/cli/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + include: ["src/**/*.test.ts"], + }, +}); diff --git a/packages/control-plane/src/auth/authenticate.test.ts b/packages/control-plane/src/auth/authenticate.test.ts index a28eef7337..dbe9148373 100644 --- a/packages/control-plane/src/auth/authenticate.test.ts +++ b/packages/control-plane/src/auth/authenticate.test.ts @@ -308,6 +308,52 @@ describe("authenticate — service credentials", () => { }); }); +describe("authenticate — direct CLI bearer", () => { + const credential = `oi_cli_${"a".repeat(64)}`; + + it("resolves a CLI credential directly to its canonical user principal", async () => { + const ctx = createCtx({ + id: "credential-id", + user_id: "11111111111111111111111111111111", + expires_at: Date.now() + 60_000, + }); + const request = new Request("https://cp.test.local/external/v1/cli/me", { + headers: { + Authorization: `Bearer ${credential}`, + "X-Open-Inspect-API-Version": "1", + "X-Open-Inspect-Client-Version": "0.1.0-test", + "X-Open-Inspect-Client-Surface": "cli", + }, + }); + + const result = await authenticate(request, createEnv(), ctx, { userCredential: "cli" }); + + expect(isAuthError(result)).toBe(false); + if (isAuthError(result)) return; + expect(result.principal).toEqual({ + kind: "user", + userId: "11111111111111111111111111111111", + }); + expect(result.authentication).toMatchObject({ + mechanism: "cli_credential", + credentialId: "credential-id", + channel: { kind: "direct_bearer" }, + }); + }); + + it("does not enable a CLI bearer on browser-only routes", async () => { + const request = new Request("https://cp.test.local/sessions", { + headers: { Authorization: `Bearer ${credential}` }, + }); + + await expect(authenticate(request, createEnv(), createCtx())).resolves.toEqual({ + reason: "Unauthorized", + status: 401, + failedScheme: "none", + }); + }); +}); + describe("authenticate — compound browser credentials", () => { function createUserAuthContext( session: { diff --git a/packages/control-plane/src/auth/authenticate.ts b/packages/control-plane/src/auth/authenticate.ts index c237dbdd34..c4f2233a0b 100644 --- a/packages/control-plane/src/auth/authenticate.ts +++ b/packages/control-plane/src/auth/authenticate.ts @@ -3,17 +3,24 @@ * typed `Principal` before any handler runs. * * A `sig1` service signature is verified against that service's own secret. - * User requests additionally require a Better Auth session. Anything else - * is not a recognized credential. + * Browser users require a Better Auth session. Explicit external-user routes + * instead accept a revocable CLI bearer. Anything else is not recognized. * * Sandbox tokens stay router-verified (they need the session id from the * path and a DO round-trip), so they are not dispatched here. */ import { SERVICE_SIGNATURE_HEADER } from "@open-inspect/shared/service-auth"; +import { + CLI_API_VERSION_HEADER, + CLI_CLIENT_SURFACE_HEADER, + CLI_CLIENT_VERSION_HEADER, + CLI_EXTERNAL_API_VERSION, +} from "@open-inspect/shared/types/cli-auth"; import { authenticateSession, SessionIntegrityError } from "./user/session-authenticator"; import { isAuthError, type AuthResult } from "./result"; import { authenticateServiceRequest } from "./service/request-authenticator"; +import { authenticateCliBearer } from "./cli-bearer-authenticator"; import { createLogger } from "../logger"; import type { RequestContext } from "../routes/shared"; import type { Env } from "../types"; @@ -29,6 +36,8 @@ export interface AuthenticationRequirement { * as a user through a Better Auth session. */ readonly webService?: "service" | "user"; + /** Direct CLI bearer authentication is enabled only for explicit external routes. */ + readonly userCredential?: "cli"; } export async function authenticate( @@ -38,6 +47,43 @@ export async function authenticate( requirement: AuthenticationRequirement = {} ): Promise<AuthResult> { const signatureHeader = request.headers.get(SERVICE_SIGNATURE_HEADER); + if (requirement.userCredential === "cli") { + if (signatureHeader !== null) { + return { reason: "Unauthorized", status: 401, failedScheme: "per-service" }; + } + const apiVersion = request.headers.get(CLI_API_VERSION_HEADER); + const clientVersion = request.headers.get(CLI_CLIENT_VERSION_HEADER); + const clientSurface = request.headers.get(CLI_CLIENT_SURFACE_HEADER); + if (apiVersion !== CLI_EXTERNAL_API_VERSION) { + return { reason: "Incompatible client version", status: 426, failedScheme: "cli-bearer" }; + } + if (!clientVersion?.trim() || !["cli", "mcp"].includes(clientSurface ?? "")) { + return { reason: "Invalid client metadata", status: 426, failedScheme: "cli-bearer" }; + } + try { + const result = await authenticateCliBearer(request, ctx); + logger.info("auth.cli.client", { + event: "auth.cli.client", + client_version: clientVersion, + client_surface: clientSurface, + request_id: ctx.request_id, + trace_id: ctx.trace_id, + }); + return result; + } catch (cause) { + logger.error("CLI credential validation failed", { + event: "auth.cli.failed", + error: cause, + request_id: ctx.request_id, + trace_id: ctx.trace_id, + }); + return { + reason: "CLI authentication failed", + status: 500, + failedScheme: "cli-bearer", + }; + } + } if (signatureHeader !== null) { const channel = await authenticateServiceRequest(request, env, ctx, signatureHeader); if ( diff --git a/packages/control-plane/src/auth/cli-bearer-authenticator.ts b/packages/control-plane/src/auth/cli-bearer-authenticator.ts new file mode 100644 index 0000000000..6416a05ef3 --- /dev/null +++ b/packages/control-plane/src/auth/cli-bearer-authenticator.ts @@ -0,0 +1,34 @@ +import { CLI_CREDENTIAL_PATTERN } from "@open-inspect/shared/types/cli-auth"; +import { hashToken } from "./crypto"; +import { CliAuthStore } from "../db/cli-auth-store"; +import type { RequestContext } from "../routes/shared"; +import type { AuthResult } from "./result"; + +/** Authenticates a direct CLI bearer as its canonical human user, never as a service. */ +export async function authenticateCliBearer( + request: Request, + ctx: RequestContext +): Promise<AuthResult> { + const header = request.headers.get("Authorization"); + const token = header?.startsWith("Bearer ") ? header.slice(7) : ""; + if (!CLI_CREDENTIAL_PATTERN.test(token)) { + return { reason: "Unauthorized", status: 401, failedScheme: "cli-bearer" }; + } + const credential = await new CliAuthStore(ctx.db).getActiveCredential( + await hashToken(token), + Date.now() + ); + if (!credential) { + return { reason: "Unauthorized", status: 401, failedScheme: "cli-bearer" }; + } + return { + principal: { kind: "user", userId: credential.userId }, + authentication: { + mechanism: "cli_credential", + credentialId: credential.id, + expiresAt: credential.expiresAt, + channel: { kind: "direct_bearer" }, + }, + request, + }; +} diff --git a/packages/control-plane/src/auth/crypto.test.ts b/packages/control-plane/src/auth/crypto.test.ts index c34f298ca8..10d7ef0e0d 100644 --- a/packages/control-plane/src/auth/crypto.test.ts +++ b/packages/control-plane/src/auth/crypto.test.ts @@ -1,6 +1,13 @@ import { describe, it, expect } from "vitest"; import { timingSafeEqual } from "@open-inspect/shared/auth"; -import { encryptToken, decryptToken, generateEncryptionKey, generateId, hashToken } from "./crypto"; +import { + encryptToken, + decryptToken, + generateEncryptionKey, + generateId, + hashToken, + hmacToken, +} from "./crypto"; describe("crypto", () => { describe("generateEncryptionKey", () => { @@ -153,6 +160,18 @@ describe("crypto", () => { }); }); + describe("hmacToken", () => { + it("is deterministic for one installation key and separated across keys", async () => { + const value = "external-session\0user-1\0request-1"; + const key = generateEncryptionKey(); + const first = await hmacToken(value, key); + + expect(first).toMatch(/^[0-9a-f]{64}$/); + expect(await hmacToken(value, key)).toBe(first); + expect(await hmacToken(value, generateEncryptionKey())).not.toBe(first); + }); + }); + describe("timingSafeEqual", () => { it("returns true for equal strings", () => { expect(timingSafeEqual("abc", "abc")).toBe(true); diff --git a/packages/control-plane/src/auth/crypto.ts b/packages/control-plane/src/auth/crypto.ts index bc673a9900..1aa503b1e4 100644 --- a/packages/control-plane/src/auth/crypto.ts +++ b/packages/control-plane/src/auth/crypto.ts @@ -108,4 +108,17 @@ export async function hashToken(token: string): Promise<string> { .join(""); } +/** Derive a deterministic opaque value from installation-scoped key material. */ +export async function hmacToken(value: string, keyBase64: string): Promise<string> { + const key = await crypto.subtle.importKey( + "raw", + Uint8Array.from(atob(keyBase64), (character) => character.charCodeAt(0)), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"] + ); + const signature = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(value)); + return [...new Uint8Array(signature)].map((byte) => byte.toString(16).padStart(2, "0")).join(""); +} + // timingSafeEqual is exported from @open-inspect/shared — use that instead. diff --git a/packages/control-plane/src/auth/principal.ts b/packages/control-plane/src/auth/principal.ts index cc31cadec7..f02fab0501 100644 --- a/packages/control-plane/src/auth/principal.ts +++ b/packages/control-plane/src/auth/principal.ts @@ -26,15 +26,19 @@ export interface ResolvedIdentity { participantUserId: string; } -/** Provider-independent evidence used to authenticate a browser request. */ -export interface AuthenticationContext { - mechanism: "browser_session"; - credentialId: string; - channel: { - kind: "sig1"; - service: "web"; - }; -} +/** Provider-independent evidence describing how a human principal authenticated. */ +export type AuthenticationContext = + | { + mechanism: "browser_session"; + credentialId: string; + channel: { kind: "sig1"; service: "web" }; + } + | { + mechanism: "cli_credential"; + credentialId: string; + expiresAt: number; + channel: { kind: "direct_bearer" }; + }; export type Principal = | { kind: "user"; userId: string } diff --git a/packages/control-plane/src/auth/result.ts b/packages/control-plane/src/auth/result.ts index 957db7cfb6..92e72a3fcd 100644 --- a/packages/control-plane/src/auth/result.ts +++ b/packages/control-plane/src/auth/result.ts @@ -3,13 +3,13 @@ import type { AuthenticationContext, Principal } from "./principal"; export interface AuthError { /** Response body message (also the log detail). Never carries token material. */ reason: string; - status: 401 | 413 | 500; + status: 401 | 413 | 426 | 500; /** * Which scheme was attempted and failed. A per-service attempt is terminal; * "none" means no recognized credential was presented at all, and the * router may still try sandbox auth on sandbox routes. */ - failedScheme: "per-service" | "browser-session" | "none"; + failedScheme: "per-service" | "browser-session" | "cli-bearer" | "none"; } export type AuthResult = diff --git a/packages/control-plane/src/cli-auth/device-authorization-service.test.ts b/packages/control-plane/src/cli-auth/device-authorization-service.test.ts new file mode 100644 index 0000000000..cff38e58fd --- /dev/null +++ b/packages/control-plane/src/cli-auth/device-authorization-service.test.ts @@ -0,0 +1,127 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { hashToken } from "../auth/crypto"; +import { + CLI_CREDENTIAL_RETENTION_MS, + CLI_CREDENTIAL_LIFETIME_MS, + CLI_DEVICE_ATTEMPT_RETENTION_MS, + CLI_DEVICE_AUTHORIZATION_LIFETIME_MS, + CliDeviceAuthorizationError, + CliDeviceAuthorizationService, +} from "./device-authorization-service"; + +describe("CliDeviceAuthorizationService", () => { + afterEach(() => vi.restoreAllMocks()); + + it("creates separate human and device secrets and persists only their hashes", async () => { + vi.spyOn(Date, "now").mockReturnValue(1000); + const store = { + createAttempt: vi.fn(async () => undefined), + pruneExpired: vi.fn(async () => undefined), + }; + const service = new CliDeviceAuthorizationService(store as never); + + const started = await service.start("dev laptop"); + expect(started).toEqual({ + deviceSecret: expect.stringMatching(/^[0-9a-f]{64}$/), + userCode: expect.stringMatching(/^[A-Z2-9]{4}-[A-Z2-9]{4}$/), + expiresAt: 1000 + CLI_DEVICE_AUTHORIZATION_LIFETIME_MS, + }); + expect(store.createAttempt).toHaveBeenCalledWith({ + id: expect.stringMatching(/^[0-9a-f]{32}$/), + deviceName: "dev laptop", + deviceSecretHash: await hashToken(started.deviceSecret), + userCodeHash: await hashToken(started.userCode), + createdAt: 1000, + expiresAt: 1000 + CLI_DEVICE_AUTHORIZATION_LIFETIME_MS, + }); + expect(store.pruneExpired).toHaveBeenCalledWith({ + now: 1000, + attemptRetentionMs: CLI_DEVICE_ATTEMPT_RETENTION_MS, + credentialRetentionMs: CLI_CREDENTIAL_RETENTION_MS, + limit: 100, + }); + }); + + it.each([ + [ + { status: "pending", deviceName: "dev laptop", expiresAt: 5000 }, + { deviceName: "dev laptop", expiresAt: 5000 }, + ], + [{ status: "not_found" }, 404], + [{ status: "expired" }, 410], + [{ status: "unavailable" }, 409], + ] as const)( + "maps pending lookup state %# without exposing stored fields", + async (outcome, expected) => { + vi.spyOn(Date, "now").mockReturnValue(2000); + const service = new CliDeviceAuthorizationService({ + getPendingAuthorization: vi.fn(async () => outcome), + } as never); + if (typeof expected === "number") { + await expect(service.getPendingAuthorization("ABCD-EFGH")).rejects.toMatchObject({ + status: expected, + }); + } else { + await expect(service.getPendingAuthorization("ABCD-EFGH")).resolves.toEqual(expected); + } + } + ); + + it("issues a 30-day credential only to the atomic exchange winner", async () => { + vi.spyOn(Date, "now").mockReturnValue(2000); + const store = { + exchangeApprovedAttempt: vi.fn(async () => ({ status: "issued" })), + }; + const service = new CliDeviceAuthorizationService(store as never); + + const exchanged = await service.exchange("a".repeat(64)); + expect(exchanged).toEqual({ + status: "authorized", + credential: expect.stringMatching(/^oi_cli_[0-9a-f]{64}$/), + credentialId: expect.stringMatching(/^[0-9a-f]{32}$/), + expiresAt: 2000 + CLI_CREDENTIAL_LIFETIME_MS, + }); + if (exchanged.status !== "authorized") throw new Error("Expected an issued credential"); + expect(store.exchangeApprovedAttempt).toHaveBeenCalledWith( + expect.objectContaining({ + claimId: expect.stringMatching(/^[0-9a-f]{32}$/), + credentialId: exchanged.credentialId, + credentialHash: await hashToken(exchanged.credential), + credentialExpiresAt: 2000 + CLI_CREDENTIAL_LIFETIME_MS, + }) + ); + }); + + it("returns pending but rejects expired or already exchanged attempts", async () => { + vi.spyOn(Date, "now").mockReturnValue(2000); + const store = { + exchangeApprovedAttempt: vi + .fn() + .mockResolvedValueOnce({ status: "pending", expiresAt: 5000 }) + .mockResolvedValueOnce({ status: "expired" }) + .mockResolvedValueOnce({ status: "consumed" }), + }; + const service = new CliDeviceAuthorizationService(store as never); + + await expect(service.exchange("a".repeat(64))).resolves.toEqual({ + status: "pending", + expiresAt: 5000, + }); + await expect(service.exchange("a".repeat(64))).rejects.toMatchObject({ status: 410 }); + await expect(service.exchange("a".repeat(64))).rejects.toBeInstanceOf( + CliDeviceAuthorizationError + ); + }); + + it("revokes by the hashed device-secret capability without exposing attempt state", async () => { + vi.spyOn(Date, "now").mockReturnValue(3000); + const store = { revokeIssuedCredentialByDeviceSecret: vi.fn(async () => undefined) }; + const service = new CliDeviceAuthorizationService(store as never); + + await expect(service.revokeIssuedCredential("a".repeat(64))).resolves.toBeUndefined(); + expect(store.revokeIssuedCredentialByDeviceSecret).toHaveBeenCalledWith( + await hashToken("a".repeat(64)), + 3000 + ); + }); +}); diff --git a/packages/control-plane/src/cli-auth/device-authorization-service.ts b/packages/control-plane/src/cli-auth/device-authorization-service.ts new file mode 100644 index 0000000000..eeab54d855 --- /dev/null +++ b/packages/control-plane/src/cli-auth/device-authorization-service.ts @@ -0,0 +1,129 @@ +import type { + CliDeviceAuthorizationExchangeResponse, + PendingCliDeviceAuthorizationResponse, +} from "@open-inspect/shared/types/cli-auth"; +import { generateId, hashToken } from "../auth/crypto"; +import type { CliAuthStore } from "../db/cli-auth-store"; + +export const CLI_DEVICE_AUTHORIZATION_LIFETIME_MS = 10 * 60 * 1000; +export const CLI_CREDENTIAL_LIFETIME_MS = 30 * 24 * 60 * 60 * 1000; +export const CLI_DEVICE_ATTEMPT_RETENTION_MS = CLI_CREDENTIAL_LIFETIME_MS; +export const CLI_CREDENTIAL_RETENTION_MS = 30 * 24 * 60 * 60 * 1000; +const CLI_AUTH_PRUNE_LIMIT = 100; +const USER_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; + +function generateUserCode(): string { + const bytes = crypto.getRandomValues(new Uint8Array(8)); + const code = Array.from( + bytes, + (byte) => USER_CODE_ALPHABET[byte % USER_CODE_ALPHABET.length] + ).join(""); + return `${code.slice(0, 4)}-${code.slice(4)}`; +} + +export class CliDeviceAuthorizationError extends Error { + constructor( + message: string, + readonly status: number + ) { + super(message); + this.name = "CliDeviceAuthorizationError"; + } +} + +/** Coordinates hash-only, single-use CLI device authorization and credential issuance. */ +export class CliDeviceAuthorizationService { + constructor( + private readonly store: Pick< + CliAuthStore, + | "createAttempt" + | "approve" + | "exchangeApprovedAttempt" + | "getPendingAuthorization" + | "pruneExpired" + | "revokeIssuedCredentialByDeviceSecret" + > + ) {} + + async start(deviceName: string): Promise<{ + deviceSecret: string; + userCode: string; + expiresAt: number; + }> { + const now = Date.now(); + const deviceSecret = generateId(32); + const userCode = generateUserCode(); + const expiresAt = now + CLI_DEVICE_AUTHORIZATION_LIFETIME_MS; + await this.store.pruneExpired({ + now, + attemptRetentionMs: CLI_DEVICE_ATTEMPT_RETENTION_MS, + credentialRetentionMs: CLI_CREDENTIAL_RETENTION_MS, + limit: CLI_AUTH_PRUNE_LIMIT, + }); + await this.store.createAttempt({ + id: generateId(), + deviceName, + deviceSecretHash: await hashToken(deviceSecret), + userCodeHash: await hashToken(userCode), + createdAt: now, + expiresAt, + }); + return { deviceSecret, userCode, expiresAt }; + } + + async getPendingAuthorization( + userCode: string + ): Promise<Omit<PendingCliDeviceAuthorizationResponse, "installation">> { + const outcome = await this.store.getPendingAuthorization(await hashToken(userCode), Date.now()); + if (outcome.status === "pending") { + return { deviceName: outcome.deviceName, expiresAt: outcome.expiresAt }; + } + if (outcome.status === "expired") { + throw new CliDeviceAuthorizationError("Authorization expired", 410); + } + if (outcome.status === "not_found") { + throw new CliDeviceAuthorizationError("Authorization not found", 404); + } + throw new CliDeviceAuthorizationError("Authorization is no longer available", 409); + } + + async approve(userCode: string, userId: string): Promise<void> { + const outcome = await this.store.approve(await hashToken(userCode), userId, Date.now()); + if (outcome === "approved") return; + if (outcome === "expired") throw new CliDeviceAuthorizationError("Authorization expired", 410); + if (outcome === "not_found") + throw new CliDeviceAuthorizationError("Authorization not found", 404); + throw new CliDeviceAuthorizationError("Authorization is no longer available", 409); + } + + async exchange(deviceSecret: string): Promise<CliDeviceAuthorizationExchangeResponse> { + const now = Date.now(); + const credentialId = generateId(); + const claimId = generateId(); + const credential = `oi_cli_${generateId(32)}`; + const expiresAt = now + CLI_CREDENTIAL_LIFETIME_MS; + const outcome = await this.store.exchangeApprovedAttempt({ + deviceSecretHash: await hashToken(deviceSecret), + claimId, + credentialId, + credentialHash: await hashToken(credential), + now, + credentialExpiresAt: expiresAt, + }); + if (outcome.status === "issued") { + return { status: "authorized", credential, credentialId, expiresAt }; + } + if (outcome.status === "pending") return outcome; + if (outcome.status === "not_found") { + throw new CliDeviceAuthorizationError("Authorization not found", 404); + } + throw new CliDeviceAuthorizationError("Authorization is no longer available", 410); + } + + async revokeIssuedCredential(deviceSecret: string): Promise<void> { + await this.store.revokeIssuedCredentialByDeviceSecret( + await hashToken(deviceSecret), + Date.now() + ); + } +} diff --git a/packages/control-plane/src/db/cli-auth-store.ts b/packages/control-plane/src/db/cli-auth-store.ts new file mode 100644 index 0000000000..60851770c2 --- /dev/null +++ b/packages/control-plane/src/db/cli-auth-store.ts @@ -0,0 +1,278 @@ +import type { SqlDatabase } from "./sql-database"; + +export interface CliDeviceAuthorizationAttemptInput { + id: string; + deviceName: string; + deviceSecretHash: string; + userCodeHash: string; + createdAt: number; + expiresAt: number; +} + +export type CliApprovalOutcome = "approved" | "not_found" | "expired" | "unavailable"; +export type CliPendingAuthorizationOutcome = + | { status: "pending"; deviceName: string; expiresAt: number } + | { status: "not_found" | "expired" | "unavailable" }; +export type CliExchangeOutcome = + | { status: "issued" } + | { status: "pending"; expiresAt: number } + | { status: "expired" | "consumed" | "not_found" }; + +interface AttemptStateRow { + approved_user_id: string | null; + expires_at: number; + exchanged_at: number | null; + capability_revoked_at: number | null; +} + +interface PendingAttemptRow extends AttemptStateRow { + device_name: string; +} + +interface RateLimitRow { + request_count: number; +} + +export interface ActiveCliCredential { + id: string; + userId: string; + expiresAt: number; +} + +interface CredentialRow { + id: string; + user_id: string; + expires_at: number; +} + +/** Persists hash-only CLI device attempts and revocable bearer credentials. */ +export class CliAuthStore { + constructor(private readonly db: SqlDatabase) {} + + async createAttempt(input: CliDeviceAuthorizationAttemptInput): Promise<void> { + await this.db + .prepare( + `INSERT INTO cli_device_authorization_attempts + (id, device_name, device_secret_hash, user_code_hash, created_at, expires_at) + VALUES (?, ?, ?, ?, ?, ?)` + ) + .bind( + input.id, + input.deviceName, + input.deviceSecretHash, + input.userCodeHash, + input.createdAt, + input.expiresAt + ) + .run(); + } + + async approve(userCodeHash: string, userId: string, now: number): Promise<CliApprovalOutcome> { + const result = await this.db + .prepare( + `UPDATE cli_device_authorization_attempts + SET approved_user_id = ?, approved_at = ? + WHERE user_code_hash = ? AND approved_user_id IS NULL + AND exchanged_at IS NULL AND capability_revoked_at IS NULL AND expires_at > ?` + ) + .bind(userId, now, userCodeHash, now) + .run(); + if (result.meta.changes === 1) return "approved"; + + const row = await this.db + .prepare( + `SELECT approved_user_id, expires_at, exchanged_at, capability_revoked_at + FROM cli_device_authorization_attempts WHERE user_code_hash = ?` + ) + .bind(userCodeHash) + .first<AttemptStateRow>(); + if (!row) return "not_found"; + if (row.expires_at <= now) return "expired"; + return "unavailable"; + } + + async getPendingAuthorization( + userCodeHash: string, + now: number + ): Promise<CliPendingAuthorizationOutcome> { + const row = await this.db + .prepare( + `SELECT device_name, approved_user_id, expires_at, exchanged_at, capability_revoked_at + FROM cli_device_authorization_attempts WHERE user_code_hash = ?` + ) + .bind(userCodeHash) + .first<PendingAttemptRow>(); + if (!row) return { status: "not_found" }; + if (row.expires_at <= now) return { status: "expired" }; + if ( + row.approved_user_id !== null || + row.exchanged_at !== null || + row.capability_revoked_at !== null + ) + return { status: "unavailable" }; + return { status: "pending", deviceName: row.device_name, expiresAt: row.expires_at }; + } + + async consumeRateLimit(input: { + key: string; + now: number; + windowMs: number; + limit: number; + }): Promise<{ allowed: boolean; retryAfterMs: number }> { + const windowStartedAt = Math.floor(input.now / input.windowMs) * input.windowMs; + const expiresAt = windowStartedAt + input.windowMs; + const row = await this.db + .prepare( + `INSERT INTO cli_auth_rate_limits (rate_key, window_started_at, request_count, expires_at) + VALUES (?, ?, 1, ?) + ON CONFLICT(rate_key, window_started_at) + DO UPDATE SET request_count = request_count + 1 + RETURNING request_count` + ) + .bind(input.key, windowStartedAt, expiresAt) + .first<RateLimitRow>(); + if (!row) throw new Error("CLI auth rate-limit counter did not return a row"); + return { + allowed: row.request_count <= input.limit, + retryAfterMs: Math.max(1, expiresAt - input.now), + }; + } + + async pruneExpired(input: { + now: number; + attemptRetentionMs: number; + credentialRetentionMs: number; + limit: number; + }): Promise<void> { + await this.db.batch([ + this.db + .prepare( + `DELETE FROM cli_device_authorization_attempts + WHERE id IN ( + SELECT id FROM cli_device_authorization_attempts + WHERE expires_at <= ? ORDER BY expires_at LIMIT ? + )` + ) + .bind(input.now - input.attemptRetentionMs, input.limit), + this.db + .prepare( + `DELETE FROM cli_credentials + WHERE id IN ( + SELECT id FROM cli_credentials + WHERE expires_at <= ? OR revoked_at <= ? + ORDER BY expires_at LIMIT ? + )` + ) + .bind( + input.now - input.credentialRetentionMs, + input.now - input.credentialRetentionMs, + input.limit + ), + this.db + .prepare( + `DELETE FROM cli_auth_rate_limits + WHERE rowid IN ( + SELECT rowid FROM cli_auth_rate_limits + WHERE expires_at <= ? ORDER BY expires_at LIMIT ? + )` + ) + .bind(input.now, input.limit), + ]); + } + + async exchangeApprovedAttempt(input: { + deviceSecretHash: string; + claimId: string; + credentialId: string; + credentialHash: string; + now: number; + credentialExpiresAt: number; + }): Promise<CliExchangeOutcome> { + const results = await this.db.batch([ + this.db + .prepare( + `UPDATE cli_device_authorization_attempts + SET exchange_claim_id = ?, exchanged_at = ?, issued_credential_id = ? + WHERE device_secret_hash = ? AND approved_user_id IS NOT NULL + AND exchanged_at IS NULL AND capability_revoked_at IS NULL AND expires_at > ?` + ) + .bind(input.claimId, input.now, input.credentialId, input.deviceSecretHash, input.now), + this.db + .prepare( + `INSERT INTO cli_credentials (id, token_hash, user_id, created_at, expires_at) + SELECT ?, ?, approved_user_id, ?, ? + FROM cli_device_authorization_attempts + WHERE device_secret_hash = ? AND exchange_claim_id = ?` + ) + .bind( + input.credentialId, + input.credentialHash, + input.now, + input.credentialExpiresAt, + input.deviceSecretHash, + input.claimId + ), + ]); + if (results[1].meta.changes === 1) { + return { status: "issued" }; + } + + const row = await this.db + .prepare( + `SELECT approved_user_id, expires_at, exchanged_at, capability_revoked_at + FROM cli_device_authorization_attempts WHERE device_secret_hash = ?` + ) + .bind(input.deviceSecretHash) + .first<AttemptStateRow>(); + if (!row) return { status: "not_found" }; + if (row.expires_at <= input.now) return { status: "expired" }; + if (row.capability_revoked_at !== null) return { status: "consumed" }; + if (row.exchanged_at !== null) return { status: "consumed" }; + return { status: "pending", expiresAt: row.expires_at }; + } + + async revokeIssuedCredentialByDeviceSecret(deviceSecretHash: string, now: number): Promise<void> { + await this.db.batch([ + this.db + .prepare( + `UPDATE cli_credentials SET revoked_at = COALESCE(revoked_at, ?) + WHERE id = ( + SELECT issued_credential_id FROM cli_device_authorization_attempts + WHERE device_secret_hash = ? + )` + ) + .bind(now, deviceSecretHash), + this.db + .prepare( + `UPDATE cli_device_authorization_attempts + SET capability_revoked_at = COALESCE(capability_revoked_at, ?) + WHERE device_secret_hash = ?` + ) + .bind(now, deviceSecretHash), + ]); + } + + async getActiveCredential(tokenHash: string, now: number): Promise<ActiveCliCredential | null> { + const row = await this.db + .prepare( + `UPDATE cli_credentials SET last_seen_at = ? + WHERE token_hash = ? AND revoked_at IS NULL AND expires_at > ? + RETURNING id, user_id, expires_at` + ) + .bind(now, tokenHash, now) + .first<CredentialRow>(); + if (!row) return null; + return { id: row.id, userId: row.user_id, expiresAt: row.expires_at }; + } + + async revoke(credentialId: string, userId: string, now: number): Promise<boolean> { + const result = await this.db + .prepare( + `UPDATE cli_credentials SET revoked_at = ? + WHERE id = ? AND user_id = ? AND revoked_at IS NULL` + ) + .bind(now, credentialId, userId) + .run(); + return result.meta.changes === 1; + } +} diff --git a/packages/control-plane/src/db/environment-secrets.ts b/packages/control-plane/src/db/environment-secrets.ts index d6914c083b..a12d1d6357 100644 --- a/packages/control-plane/src/db/environment-secrets.ts +++ b/packages/control-plane/src/db/environment-secrets.ts @@ -24,6 +24,7 @@ import type { SecretsWriteResult } from "./scoped-secrets"; import { normalizeKey, validateKey } from "./secrets-validation"; import type { SecretMetadata } from "./secrets-validation"; import type { SqlDatabase, SqlStatement } from "./sql-database"; +import { archiveManagedSecretStatements } from "./managed-secret-redaction-history"; const log = createLogger("environment-secrets"); @@ -54,9 +55,20 @@ export class EnvironmentSecretsStore { const statements = entries.map((entry) => this.bindUpsert(environmentId, entry.key, entry.encryptedValue, now) ); + const existing = await this.db + .prepare( + `SELECT key, encrypted_value FROM environment_secrets + WHERE environment_id = ?` + ) + .bind(environmentId) + .all<{ key: string; encrypted_value: string }>(); + const replaced = (existing.results ?? []).filter(({ key }) => key in normalized); if (statements.length > 0) { - await this.db.batch(statements); + await this.db.batch([ + ...archiveManagedSecretStatements(this.db, replaced, now), + ...statements, + ]); } return { created, updated, keys: incomingKeys }; @@ -95,10 +107,20 @@ export class EnvironmentSecretsStore { } async deleteSecret(environmentId: string, key: string): Promise<boolean> { - const result = await this.db - .prepare("DELETE FROM environment_secrets WHERE environment_id = ? AND key = ?") - .bind(environmentId, normalizeKey(key)) - .run(); + const normalized = normalizeKey(key); + const existing = ( + await this.db + .prepare("SELECT key, encrypted_value FROM environment_secrets WHERE environment_id = ?") + .bind(environmentId) + .all<{ key: string; encrypted_value: string }>() + ).results?.find(({ key: candidate }) => candidate === normalized); + if (!existing) return false; + const [, result] = await this.db.batch([ + ...archiveManagedSecretStatements(this.db, [existing], Date.now()), + this.db + .prepare("DELETE FROM environment_secrets WHERE environment_id = ? AND key = ?") + .bind(environmentId, normalized), + ]); return (result.meta?.changes ?? 0) > 0; } @@ -151,8 +173,23 @@ export class EnvironmentSecretsStore { else created++; return this.bindUpsert(environmentId, row.key, row.encrypted_value, now); }); - - await this.db.batch(statements); + const replaced = await this.db + .prepare( + `SELECT key, encrypted_value FROM environment_secrets + WHERE environment_id = ?` + ) + .bind(environmentId) + .all<{ key: string; encrypted_value: string }>(); + const incomingKeys = new Set(rows.map(({ key }) => key)); + + await this.db.batch([ + ...archiveManagedSecretStatements( + this.db, + (replaced.results ?? []).filter(({ key }) => incomingKeys.has(key)), + now + ), + ...statements, + ]); return { created, updated, keys: rows.map((r) => r.key) }; } diff --git a/packages/control-plane/src/db/global-secrets.test.ts b/packages/control-plane/src/db/global-secrets.test.ts index acb916e0d1..ae56a45c3d 100644 --- a/packages/control-plane/src/db/global-secrets.test.ts +++ b/packages/control-plane/src/db/global-secrets.test.ts @@ -16,6 +16,7 @@ const QUERY_PATTERNS = { SELECT_KEYS_WITH_VALUES: /^SELECT key, encrypted_value FROM global_secrets$/, UPSERT_SECRET: /^INSERT INTO global_secrets/, DELETE_SECRET: /^DELETE FROM global_secrets/, + ARCHIVE_SECRET: /^INSERT OR IGNORE INTO managed_secret_redaction_history/, } as const; function normalizeQuery(query: string): string { @@ -78,6 +79,8 @@ class FakeD1Database { return { meta: { changes: existed ? 1 : 0 } }; } + if (QUERY_PATTERNS.ARCHIVE_SECRET.test(normalized)) return { meta: { changes: 1 } }; + throw new Error(`Unexpected mutation query: ${query}`); } diff --git a/packages/control-plane/src/db/global-secrets.ts b/packages/control-plane/src/db/global-secrets.ts index 0c30d5677f..37c312aa28 100644 --- a/packages/control-plane/src/db/global-secrets.ts +++ b/packages/control-plane/src/db/global-secrets.ts @@ -11,6 +11,7 @@ import type { SecretsWriteResult } from "./scoped-secrets"; import { normalizeKey } from "./secrets-validation"; import type { SecretMetadata } from "./secrets-validation"; import type { SqlDatabase } from "./sql-database"; +import { archiveManagedSecretStatements } from "./managed-secret-redaction-history"; const log = createLogger("global-secrets"); @@ -25,8 +26,8 @@ export class GlobalSecretsStore { const normalized = prepareSecretsForWrite(secrets); const existingKeys = await this.db - .prepare("SELECT key FROM global_secrets") - .all<{ key: string }>(); + .prepare("SELECT key, encrypted_value FROM global_secrets") + .all<{ key: string; encrypted_value: string }>(); const existingKeySet = new Set((existingKeys.results || []).map((r) => r.key)); const incomingKeys = Object.keys(normalized); @@ -49,9 +50,13 @@ export class GlobalSecretsStore { ) .bind(entry.key, entry.encryptedValue, now, now) ); + const replaced = (existingKeys.results ?? []).filter(({ key }) => key in normalized); if (statements.length > 0) { - await this.db.batch(statements); + await this.db.batch([ + ...archiveManagedSecretStatements(this.db, replaced, now), + ...statements, + ]); } return { created, updated, keys: incomingKeys }; @@ -85,10 +90,17 @@ export class GlobalSecretsStore { } async deleteSecret(key: string): Promise<boolean> { - const result = await this.db - .prepare("DELETE FROM global_secrets WHERE key = ?") - .bind(normalizeKey(key)) - .run(); + const normalized = normalizeKey(key); + const existing = ( + await this.db + .prepare("SELECT key, encrypted_value FROM global_secrets") + .all<{ key: string; encrypted_value: string }>() + ).results?.find(({ key: candidate }) => candidate === normalized); + if (!existing) return false; + const [, result] = await this.db.batch([ + ...archiveManagedSecretStatements(this.db, [existing], Date.now()), + this.db.prepare("DELETE FROM global_secrets WHERE key = ?").bind(normalized), + ]); return (result.meta?.changes ?? 0) > 0; } diff --git a/packages/control-plane/src/db/managed-secret-redaction-history.ts b/packages/control-plane/src/db/managed-secret-redaction-history.ts new file mode 100644 index 0000000000..cfbeb7b9f6 --- /dev/null +++ b/packages/control-plane/src/db/managed-secret-redaction-history.ts @@ -0,0 +1,51 @@ +import { decryptToken } from "../auth/crypto"; +import type { SqlDatabase, SqlStatement } from "./sql-database"; + +interface SecretValueRow { + encrypted_value: string; +} + +export function archiveManagedSecretStatements( + db: SqlDatabase, + rows: SecretValueRow[], + now: number +): SqlStatement[] { + return rows.map(({ encrypted_value }) => + db + .prepare( + `INSERT OR IGNORE INTO managed_secret_redaction_history (encrypted_value, created_at) + VALUES (?, ?)` + ) + .bind(encrypted_value, now) + ); +} + +export async function listCurrentManagedSecretValues( + db: SqlDatabase, + encryptionKey: string +): Promise<string[]> { + const result = await db + .prepare( + `SELECT encrypted_value FROM global_secrets + UNION ALL SELECT encrypted_value FROM repo_secrets + UNION ALL SELECT encrypted_value FROM environment_secrets` + ) + .all<SecretValueRow>(); + return decryptRows(result.results ?? [], encryptionKey); +} + +export async function listManagedSecretHistory( + db: SqlDatabase, + encryptionKey: string +): Promise<string[]> { + const result = await db + .prepare("SELECT encrypted_value FROM managed_secret_redaction_history") + .all<SecretValueRow>(); + return decryptRows(result.results ?? [], encryptionKey); +} + +function decryptRows(rows: SecretValueRow[], encryptionKey: string): Promise<string[]> { + return Promise.all( + rows.map(({ encrypted_value }) => decryptToken(encrypted_value, encryptionKey)) + ); +} diff --git a/packages/control-plane/src/db/model-provider-account-atomic-writer.ts b/packages/control-plane/src/db/model-provider-account-atomic-writer.ts index d9bcb1fa8f..7242f697b1 100644 --- a/packages/control-plane/src/db/model-provider-account-atomic-writer.ts +++ b/packages/control-plane/src/db/model-provider-account-atomic-writer.ts @@ -151,7 +151,7 @@ export class D1ModelProviderAccountAtomicWriter implements ModelProviderAccountA encryptedPayload: prepared.encryptedPayload, }), ]); - return results[0].meta.changes === 1 && results[1].meta.changes === 1; + return results[0].meta.changes >= 1 && results[1].meta.changes === 1; } async completeVerificationCredentialAndAccount( @@ -166,7 +166,7 @@ export class D1ModelProviderAccountAtomicWriter implements ModelProviderAccountA encryptedPayload: prepared.encryptedPayload, }), ]); - return results[0].meta.changes === 1 && results[1].meta.changes === 1; + return results[0].meta.changes >= 1 && results[1].meta.changes === 1; } async finalizeDeviceAuthorizationCreate( @@ -331,7 +331,13 @@ export class D1ModelProviderAccountAtomicWriter implements ModelProviderAccountA reconnectedExisting: true, }), ]); - if (results.every((result) => result.meta.changes === 1)) return { type: "connected" }; + if ( + results[0].meta.changes === 1 && + results[1].meta.changes >= 1 && + results[2].meta.changes === 1 + ) { + return { type: "connected" }; + } if (results.some((result) => result.meta.changes !== 0)) { throw new Error("Provider authorization reconnect finalization violated atomic invariants"); } diff --git a/packages/control-plane/src/db/repo-secrets.test.ts b/packages/control-plane/src/db/repo-secrets.test.ts index 04a884360b..fac2b43905 100644 --- a/packages/control-plane/src/db/repo-secrets.test.ts +++ b/packages/control-plane/src/db/repo-secrets.test.ts @@ -25,6 +25,7 @@ const QUERY_PATTERNS = { SELECT_KEYS_WITH_VALUES: /^SELECT key, encrypted_value FROM repo_secrets/, UPSERT_SECRET: /^INSERT INTO repo_secrets/, DELETE_SECRET: /^DELETE FROM repo_secrets/, + ARCHIVE_SECRET: /^INSERT OR IGNORE INTO managed_secret_redaction_history/, } as const; function normalizeQuery(query: string): string { @@ -105,6 +106,8 @@ class FakeD1Database { return { meta: { changes: existed ? 1 : 0 } }; } + if (QUERY_PATTERNS.ARCHIVE_SECRET.test(normalized)) return { meta: { changes: 1 } }; + throw new Error(`Unexpected mutation query: ${query}`); } diff --git a/packages/control-plane/src/db/repo-secrets.ts b/packages/control-plane/src/db/repo-secrets.ts index 0441f9fad3..70a39b3705 100644 --- a/packages/control-plane/src/db/repo-secrets.ts +++ b/packages/control-plane/src/db/repo-secrets.ts @@ -11,6 +11,7 @@ import type { SecretsWriteResult } from "./scoped-secrets"; import { normalizeKey } from "./secrets-validation"; import type { SecretMetadata } from "./secrets-validation"; import type { SqlDatabase } from "./sql-database"; +import { archiveManagedSecretStatements } from "./managed-secret-redaction-history"; export type { SecretMetadata } from "./secrets-validation"; @@ -34,9 +35,9 @@ export class RepoSecretsStore { const normalized = prepareSecretsForWrite(secrets); const existingKeys = await this.db - .prepare("SELECT key FROM repo_secrets WHERE repo_id = ?") + .prepare("SELECT key, encrypted_value FROM repo_secrets WHERE repo_id = ?") .bind(repoId) - .all<{ key: string }>(); + .all<{ key: string; encrypted_value: string }>(); const existingKeySet = new Set((existingKeys.results || []).map((r) => r.key)); const incomingKeys = Object.keys(normalized); @@ -62,9 +63,13 @@ export class RepoSecretsStore { ) .bind(repoId, owner, name, entry.key, entry.encryptedValue, now, now) ); + const replaced = (existingKeys.results ?? []).filter(({ key }) => key in normalized); if (statements.length > 0) { - await this.db.batch(statements); + await this.db.batch([ + ...archiveManagedSecretStatements(this.db, replaced, now), + ...statements, + ]); } return { created, updated, keys: incomingKeys }; @@ -103,10 +108,20 @@ export class RepoSecretsStore { } async deleteSecret(repoId: number, key: string): Promise<boolean> { - const result = await this.db - .prepare("DELETE FROM repo_secrets WHERE repo_id = ? AND key = ?") - .bind(repoId, normalizeKey(key)) - .run(); + const normalized = normalizeKey(key); + const existing = ( + await this.db + .prepare("SELECT key, encrypted_value FROM repo_secrets WHERE repo_id = ?") + .bind(repoId) + .all<{ key: string; encrypted_value: string }>() + ).results?.find(({ key: candidate }) => candidate === normalized); + if (!existing) return false; + const [, result] = await this.db.batch([ + ...archiveManagedSecretStatements(this.db, [existing], Date.now()), + this.db + .prepare("DELETE FROM repo_secrets WHERE repo_id = ? AND key = ?") + .bind(repoId, normalized), + ]); return (result.meta?.changes ?? 0) > 0; } diff --git a/packages/control-plane/src/db/session-index.test.ts b/packages/control-plane/src/db/session-index.test.ts index 0018b8e8df..48bb039efc 100644 --- a/packages/control-plane/src/db/session-index.test.ts +++ b/packages/control-plane/src/db/session-index.test.ts @@ -25,6 +25,8 @@ type SessionRow = { message_count: number; pr_count: number; environment_id: string | null; + external_request_fingerprint: string | null; + external_bootstrap_snapshot: string | null; created_at: number; updated_at: number; }; @@ -210,6 +212,8 @@ class FakeD1Database { scmLogin, userId, environmentId, + externalRequestFingerprint, + externalBootstrapSnapshot, createdAt, updatedAt, ] = args as [ @@ -232,6 +236,8 @@ class FakeD1Database { string | null, string | null, string | null, + string | null, + string | null, number, number, ]; @@ -263,6 +269,8 @@ class FakeD1Database { message_count: 0, pr_count: 0, environment_id: environmentId, + external_request_fingerprint: externalRequestFingerprint, + external_bootstrap_snapshot: externalBootstrapSnapshot, created_at: createdAt, updated_at: updatedAt, }); @@ -503,6 +511,8 @@ describe("SessionIndexStore", () => { const result = await store.get("test-id"); expect(result).toEqual({ ...session, + externalRequestFingerprint: null, + externalBootstrapSnapshot: null, // Defaults applied for missing optional fields parentSessionId: null, spawnSource: "user", diff --git a/packages/control-plane/src/db/session-index.ts b/packages/control-plane/src/db/session-index.ts index b1d19f9644..4bc2ec70a7 100644 --- a/packages/control-plane/src/db/session-index.ts +++ b/packages/control-plane/src/db/session-index.ts @@ -101,6 +101,10 @@ export interface SessionEntry { skillManifestSourceSessionId?: string; /** Complete immutable model-provider authentication snapshot. */ providerAuth?: SessionModelProviderAuthInput[]; + /** Canonical external create request reserved atomically with the session row. */ + externalRequestFingerprint?: string | null; + /** Resolved external bootstrap state reserved atomically for deterministic retries. */ + externalBootstrapSnapshot?: string | null; } interface SessionRow { @@ -125,6 +129,8 @@ interface SessionRow { message_count: number; pr_count: number; environment_id: string | null; + external_request_fingerprint: string | null; + external_bootstrap_snapshot: string | null; created_at: number; updated_at: number; } @@ -146,6 +152,7 @@ export interface ListSessionsOptions { limit?: number; offset?: number; viewerUserId?: string; + repositorylessOnly?: boolean; } /** Paginated session index entries. */ @@ -178,6 +185,8 @@ function toEntry(row: SessionRow): SessionEntry { messageCount: row.message_count, prCount: row.pr_count, environmentId: row.environment_id, + externalRequestFingerprint: row.external_request_fingerprint, + externalBootstrapSnapshot: row.external_bootstrap_snapshot, createdAt: row.created_at, updatedAt: row.updated_at, }; @@ -267,8 +276,8 @@ export class SessionIndexStore { const sessionStmt = this.db .prepare( - `INSERT INTO sessions (id, title, repo_owner, repo_name, model, reasoning_effort, base_branch, status, parent_session_id, root_session_id, spawn_source, spawn_depth, automation_id, automation_run_id, scm_login, user_id, environment_id, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, CASE WHEN ? IS NULL THEN ? ELSE (SELECT root_session_id FROM sessions WHERE id = ?) END, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + `INSERT INTO sessions (id, title, repo_owner, repo_name, model, reasoning_effort, base_branch, status, parent_session_id, root_session_id, spawn_source, spawn_depth, automation_id, automation_run_id, scm_login, user_id, environment_id, external_request_fingerprint, external_bootstrap_snapshot, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, CASE WHEN ? IS NULL THEN ? ELSE (SELECT root_session_id FROM sessions WHERE id = ?) END, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` ) .bind( session.id, @@ -290,6 +299,8 @@ export class SessionIndexStore { session.scmLogin ?? null, session.userId ?? null, session.environmentId ?? null, + session.externalRequestFingerprint ?? null, + session.externalBootstrapSnapshot ?? null, session.createdAt, session.updatedAt ); @@ -513,11 +524,14 @@ export class SessionIndexStore { limit = DEFAULT_SESSION_LIST_LIMIT, offset = DEFAULT_SESSION_LIST_OFFSET, viewerUserId, + repositorylessOnly, } = options; const conditions: string[] = []; const params: unknown[] = []; + if (repositorylessOnly) conditions.push("repo_owner IS NULL AND repo_name IS NULL"); + if (status) { conditions.push("status = ?"); params.push(status); diff --git a/packages/control-plane/src/db/session-pull-request-store.ts b/packages/control-plane/src/db/session-pull-request-store.ts index 8441dbd20f..07cf70ae04 100644 --- a/packages/control-plane/src/db/session-pull-request-store.ts +++ b/packages/control-plane/src/db/session-pull-request-store.ts @@ -178,6 +178,20 @@ export class SessionPullRequestStore { return row ? toRecord(row) : null; } + /** List one session's pull requests, newest activity first. */ + async listBySession(sessionId: string): Promise<SessionPullRequestRecord[]> { + const result = await this.db + .prepare( + `SELECT * FROM session_pull_requests + WHERE session_id = ? + ORDER BY updated_at DESC, artifact_id ASC` + ) + .bind(sessionId) + .all<SessionPullRequestRow>(); + + return (result.results ?? []).map(toRecord); + } + /** * The single PR identity boundary. Callers pass everything they know about * the PR's identity; the layering lives here, not at call sites: diff --git a/packages/control-plane/src/db/user-merge.ts b/packages/control-plane/src/db/user-merge.ts index 4977e04f80..b801d23d23 100644 --- a/packages/control-plane/src/db/user-merge.ts +++ b/packages/control-plane/src/db/user-merge.ts @@ -23,9 +23,9 @@ import type { SqlDatabase, SqlResult, SqlStatement } from "./sql-database"; * - Idempotent: re-running a completed merge is a zero-count no-op. The * execute path requires an atomic SqlDatabase batch so no partial graph can * become externally visible. - * - Browser sessions (`auth_sessions`) issued to the loser are deleted. An - * issued bearer credential is never rewritten to authenticate as another - * canonical user. + * - Browser sessions (`auth_sessions`) issued to the loser are deleted. CLI + * credentials transfer to the canonical survivor without changing their + * active/revoked state. * - Verification never transfers to an unproven address: the loser's email * (and its `email_verified` flag) backfills the survivor only when the * survivor has no email of its own. @@ -62,6 +62,8 @@ const USER_MERGE_COUNT_KEYS = [ "readStatesRepointed", "sessionsRepointed", "authSessionsDeleted", + "cliCredentialsRepointed", + "cliApprovedAttemptsRepointed", "automationsOwnedRepointed", "automationsCreatedRepointed", "scmTokensRepointed", @@ -175,6 +177,12 @@ const BEFORE_SKILL_PROFILE_OPERATIONS = [ }), regularRepoint("sessionsRepointed", "sessions"), regularDelete("authSessionsDeleted", "auth_sessions", "userId"), + regularRepoint("cliCredentialsRepointed", "cli_credentials"), + regularRepoint( + "cliApprovedAttemptsRepointed", + "cli_device_authorization_attempts", + "approved_user_id" + ), regularRepoint("automationsOwnedRepointed", "automations"), regularRepoint("automationsCreatedRepointed", "automations", "created_by"), regularRepoint("scmTokensRepointed", "user_scm_tokens"), diff --git a/packages/control-plane/src/env-validation.test.ts b/packages/control-plane/src/env-validation.test.ts index fd238b03aa..38dfa08451 100644 --- a/packages/control-plane/src/env-validation.test.ts +++ b/packages/control-plane/src/env-validation.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "vitest"; import { generateEncryptionKey } from "./auth/crypto"; -import { requireRepoSecretsEncryptionKey, requireTokenEncryptionKey } from "./env-validation"; +import { + requireExternalSessionIdSecret, + requireRepoSecretsEncryptionKey, + requireTokenEncryptionKey, +} from "./env-validation"; import type { Env } from "./types"; function envWith(key: string | undefined): Env { @@ -61,3 +65,13 @@ describe("requireTokenEncryptionKey", () => { ).toThrow(/TOKEN_ENCRYPTION_KEY must decode to 32 bytes/); }); }); + +describe("requireExternalSessionIdSecret", () => { + it("requires dedicated 32-byte installation key material", () => { + const key = generateEncryptionKey(); + expect(requireExternalSessionIdSecret({ EXTERNAL_SESSION_ID_SECRET: key } as Env)).toBe(key); + expect(() => requireExternalSessionIdSecret({} as Env)).toThrow( + /EXTERNAL_SESSION_ID_SECRET is not configured/ + ); + }); +}); diff --git a/packages/control-plane/src/env-validation.ts b/packages/control-plane/src/env-validation.ts index 52e1682f73..6240ce97d3 100644 --- a/packages/control-plane/src/env-validation.ts +++ b/packages/control-plane/src/env-validation.ts @@ -56,3 +56,11 @@ export function requireRepoSecretsEncryptionKey(env: Env): string { export function requireTokenEncryptionKey(env: Env): string { return requireEncryptionKey(env.TOKEN_ENCRYPTION_KEY, "TOKEN_ENCRYPTION_KEY", "OAuth tokens"); } + +export function requireExternalSessionIdSecret(env: Env): string { + return requireEncryptionKey( + env.EXTERNAL_SESSION_ID_SECRET, + "EXTERNAL_SESSION_ID_SECRET", + "deterministic external session identities" + ); +} diff --git a/packages/control-plane/src/external-api/event-projection.test.ts b/packages/control-plane/src/external-api/event-projection.test.ts new file mode 100644 index 0000000000..8151360e76 --- /dev/null +++ b/packages/control-plane/src/external-api/event-projection.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it } from "vitest"; +import { projectExternalEventPage } from "./event-projection"; + +function page(type: string, data: Record<string, unknown>) { + return { + changes: [ + { + kind: "upsert", + revision: 1, + event: { id: "event-1", type, messageId: "message-1", createdAt: 1, data }, + }, + ], + checkpoint: 1, + hasMore: false, + }; +} + +describe("external event projection", () => { + it("preserves useful tool-call data while removing credential fields and managed secrets", () => { + const projected = projectExternalEventPage( + page("tool_call", { + type: "tool_call", + sandboxId: "sandbox-identity", + timestamp: 1, + messageId: "message-1", + tool: "shell", + callId: "call-1", + status: "running", + isSubtask: true, + childSessionId: "child-1", + args: { command: "deploy", authorization: "historical-secret" }, + output: "rotated-secret", + }), + new Set(["historical-secret", "rotated-secret"]) + ); + + expect(projected.changes[0]).toMatchObject({ + kind: "upsert", + event: { + data: { + type: "tool_call", + timestamp: 1, + status: "running", + isSubtask: true, + tool: "shell", + callId: "call-1", + childSessionId: "child-1", + args: { command: "deploy" }, + output: "[REDACTED]", + }, + }, + }); + expect(JSON.stringify(projected)).not.toContain("historical-secret"); + expect(JSON.stringify(projected)).not.toContain("rotated-secret"); + expect(JSON.stringify(projected)).not.toContain("sandbox-identity"); + }); + + it.each([ + ["token", { content: "deleted-global-secret" }], + ["tool_result", { callId: "call-1", result: "non-global-secret", error: "secret-error" }], + ["error", { error: "historical-secret" }], + ["execution_complete", { success: false, error: "rotated-secret" }], + ["push_error", { error: "deleted-global-secret" }], + ["warning", { scope: "secrets", message: "non-global-secret" }], + ["session_title", { title: "historical-secret" }], + ["user_message", { content: "rotated-secret" }], + ])("redacts managed secrets from %s events", (type, fields) => { + const projected = projectExternalEventPage( + page(type, { + type, + timestamp: 1, + sandboxId: "sandbox-1", + messageId: "message-1", + ...fields, + }), + new Set([ + "historical-secret", + "rotated-secret", + "deleted-global-secret", + "non-global-secret", + "secret-error", + ]) + ); + const serialized = JSON.stringify(projected); + expect(serialized).not.toContain("historical-secret"); + expect(serialized).not.toContain("rotated-secret"); + expect(serialized).not.toContain("deleted-global-secret"); + expect(serialized).not.toContain("non-global-secret"); + expect(serialized).not.toContain("secret-error"); + }); + + it("preserves numeric usage, fixed statuses, and tombstones", () => { + const finish = projectExternalEventPage( + page("step_finish", { + type: "step_finish", + sandboxId: "sandbox-1", + timestamp: 4, + messageId: "message-1", + cost: 0.25, + tokens: { + total: 20, + input: 12, + output: 8, + cache: { read: 3, providerSecret: "never" }, + providerSecret: "never", + }, + reason: "contains-secret", + }) + ); + if (finish.changes[0].kind !== "upsert") throw new Error("Expected upsert"); + expect(finish.changes[0].event.data).toEqual({ + type: "step_finish", + timestamp: 4, + messageId: "message-1", + cost: 0.25, + tokens: { total: 20, input: 12, output: 8, cache: { read: 3 } }, + reason: "contains-secret", + }); + + expect( + projectExternalEventPage({ + changes: [{ kind: "delete", revision: 2, eventId: "event-1" }], + checkpoint: 2, + hasMore: false, + }).changes[0] + ).toEqual({ kind: "delete", revision: 2, eventId: "event-1" }); + }); +}); diff --git a/packages/control-plane/src/external-api/event-projection.ts b/packages/control-plane/src/external-api/event-projection.ts new file mode 100644 index 0000000000..c20fb74056 --- /dev/null +++ b/packages/control-plane/src/external-api/event-projection.ts @@ -0,0 +1,76 @@ +import { + externalEventPageSchema, + type ExternalJsonValue, + type ExternalEventPage, +} from "@open-inspect/shared/types/external-session-api"; +import { sandboxEventSchema } from "@open-inspect/shared/types/sandbox-events"; +import { sessionEventChangePageSchema } from "../session/contracts"; + +const OMITTED_FIELDS = new Set([ + "sandboxId", + "ackId", + "callbackContext", + "scmToken", + "scmAccessToken", + "scmRefreshToken", +]); +const CREDENTIAL_FIELD = + /(?:access[_-]?token|refresh[_-]?token|secret|password|authorization|cookie|credential)/i; + +function redactString(value: string, secrets: ReadonlySet<string>): string { + let redacted = value; + for (const secret of secrets) { + if (secret) redacted = redacted.split(secret).join("[REDACTED]"); + } + return redacted; +} + +function safeJson(value: unknown, secrets: ReadonlySet<string>): ExternalJsonValue | undefined { + if (value === null || typeof value === "boolean" || typeof value === "number") return value; + if (typeof value === "string") return redactString(value, secrets); + if (Array.isArray(value)) { + return value.flatMap((entry) => { + const projected = safeJson(entry, secrets); + return projected === undefined ? [] : [projected]; + }); + } + if (typeof value !== "object") return undefined; + const projected: Record<string, ExternalJsonValue> = {}; + for (const [key, entry] of Object.entries(value)) { + if (OMITTED_FIELDS.has(key) || CREDENTIAL_FIELD.test(key)) continue; + const safe = safeJson(entry, secrets); + if (safe !== undefined) projected[key] = safe; + } + return projected; +} + +/** Parses internal events and emits an envelope safe without credential material. */ +export function projectExternalEventPage( + page: unknown, + managedSecretValues: ReadonlySet<string> = new Set() +): ExternalEventPage { + const parsed = sessionEventChangePageSchema.parse(page); + return externalEventPageSchema.parse({ + changes: parsed.changes.map((change) => { + if (change.kind === "delete") return change; + const data = sandboxEventSchema.parse(change.event.data); + if (data.type !== change.event.type) { + throw new Error("Event envelope type does not match event data"); + } + return { + kind: change.kind, + revision: change.revision, + event: { + id: change.event.id, + type: change.event.type, + messageId: change.event.messageId, + createdAt: change.event.createdAt, + data: safeJson(data, managedSecretValues) as Record<string, ExternalJsonValue>, + }, + }; + }), + checkpoint: parsed.checkpoint, + ...(parsed.cursor === undefined ? {} : { cursor: parsed.cursor }), + hasMore: parsed.hasMore, + }); +} diff --git a/packages/control-plane/src/external-api/runtime-response.test.ts b/packages/control-plane/src/external-api/runtime-response.test.ts new file mode 100644 index 0000000000..083de27ca6 --- /dev/null +++ b/packages/control-plane/src/external-api/runtime-response.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { adaptExternalRuntimeFailure } from "./runtime-response"; + +describe("external runtime response adapter", () => { + it.each([ + [400, 400, "runtime_bad_request"], + [404, 404, "runtime_not_found"], + [409, 409, "runtime_conflict"], + [410, 410, "event_checkpoint_expired"], + [429, 429, "runtime_busy"], + [500, 503, "runtime_unavailable"], + [418, 502, "runtime_error"], + ])("maps internal status %s to a bounded external error", async (internal, external, code) => { + const secret = "database password=hunter2"; + const result = adaptExternalRuntimeFailure( + new Response(JSON.stringify({ error: secret, stack: secret }), { status: internal }) + ); + + expect(result?.status).toBe(external); + const body = await result!.text(); + expect(JSON.parse(body)).toMatchObject({ code }); + expect(body).not.toContain(secret); + }); + + it("returns null for successful internal responses", () => { + expect(adaptExternalRuntimeFailure(Response.json({ internal: true }))).toBeNull(); + }); +}); diff --git a/packages/control-plane/src/external-api/runtime-response.ts b/packages/control-plane/src/external-api/runtime-response.ts new file mode 100644 index 0000000000..aebfcb7ca8 --- /dev/null +++ b/packages/control-plane/src/external-api/runtime-response.ts @@ -0,0 +1,27 @@ +import { externalApiErrorResponseSchema } from "@open-inspect/shared/types/external-session-api"; + +const FAILURE_BY_STATUS: Record<number, { status: number; error: string; code: string }> = { + 400: { status: 400, error: "Invalid session runtime request", code: "runtime_bad_request" }, + 404: { status: 404, error: "Session runtime not found", code: "runtime_not_found" }, + 409: { status: 409, error: "Session runtime conflict", code: "runtime_conflict" }, + 410: { + status: 410, + error: "Event checkpoint expired", + code: "event_checkpoint_expired", + }, + 429: { status: 429, error: "Session runtime is busy", code: "runtime_busy" }, +}; + +/** Maps an internal runtime failure without inspecting or forwarding its body. */ +export function adaptExternalRuntimeFailure(response: Response): Response | null { + if (response.ok) return null; + const failure = + FAILURE_BY_STATUS[response.status] ?? + (response.status >= 500 + ? { status: 503, error: "Session runtime unavailable", code: "runtime_unavailable" } + : { status: 502, error: "Session runtime request failed", code: "runtime_error" }); + return Response.json( + externalApiErrorResponseSchema.parse({ error: failure.error, code: failure.code }), + { status: failure.status } + ); +} diff --git a/packages/control-plane/src/router.policy.test.ts b/packages/control-plane/src/router.policy.test.ts index 378fb379e4..4eaceb0959 100644 --- a/packages/control-plane/src/router.policy.test.ts +++ b/packages/control-plane/src/router.policy.test.ts @@ -35,16 +35,19 @@ describe("route policy table", () => { authentication ); } else if (authorization.kind === "authenticated" || authorization.kind === "active-self") { - expect(authentication).toBe("user"); + expect(["user", "external-user"]).toContain(authentication); } else if (authorization.kind === "service") { expect(authentication).toBe("service"); expect(authorization.services.length).toBeGreaterThan(0); } else if (authorization.kind === "active-global") { - expect(["user", "user-or-service"]).toContain(authentication); + expect(["user", "external-user", "user-or-service"]).toContain(authentication); } else { - expect(["user", "user-or-service", "user-or-service-with-sandbox-fallback"]).toContain( - authentication - ); + expect([ + "user", + "external-user", + "user-or-service", + "user-or-service-with-sandbox-fallback", + ]).toContain(authentication); expect(authorization.allOf.length).toBeGreaterThan(0); for (const requirement of authorization.allOf) { if (requirement.kind === "automation") { @@ -243,6 +246,13 @@ describe("route policy table", () => { it.each([ ["GET", "/health", "public"], + ["POST", "/external/v1/cli/device-authorizations", "public"], + ["POST", "/external/v1/cli/device-authorizations/exchange", "public"], + ["POST", "/external/v1/cli/device-authorizations/revoke", "public"], + ["GET", "/external/v1/cli/device-authorizations/pending", "user"], + ["POST", "/external/v1/cli/device-authorizations/approve", "user"], + ["GET", "/external/v1/cli/me", "external-user"], + ["DELETE", "/external/v1/cli/credentials/current", "external-user"], ["POST", "/webhooks/sentry/automation-1", "handler-authenticated"], ["POST", "/webhooks/automation/automation-1", "handler-authenticated"], ["POST", "/image-builds/build-complete", "handler-authenticated"], @@ -265,6 +275,39 @@ describe("route policy table", () => { expect(routeFor(method, path)?.authentication.kind).toBe(expectedKind); }); + it("applies active-user policy to browser approval and CLI credential routes", () => { + expect( + routeFor("POST", "/external/v1/cli/device-authorizations/approve")?.authorization + ).toEqual({ kind: "active-self", auditAllowed: false }); + expect(routeFor("GET", "/external/v1/cli/me")?.authorization).toEqual({ + kind: "active-self", + auditAllowed: false, + }); + expect(routeFor("DELETE", "/external/v1/cli/credentials/current")?.authorization).toEqual({ + kind: "active-self", + auditAllowed: false, + }); + }); + + it.each([ + ["POST", "/external/v1/sessions", "sessions.create"], + ["GET", "/external/v1/sessions", "sessions.read"], + ["GET", "/external/v1/sessions/session-1", "sessions.read"], + ["POST", "/external/v1/sessions/session-1/messages", "sessions.collaborate"], + ["POST", "/external/v1/sessions/session-1/stop", "sessions.lifecycle"], + ["GET", "/external/v1/sessions/session-1/events", "sessions.read"], + ["GET", "/external/v1/sessions/session-1/wait", "sessions.read"], + ])("keeps external v1 session route %s %s CLI-only", (method, path, permission) => { + const route = routeFor(method, path); + expect(route?.authentication).toEqual({ kind: "external-user" }); + expect(route?.supportedScmProviders).toBe("all"); + expect(route?.authorization).toMatchObject({ + kind: "active-user", + allOf: [{ kind: "permission", permission }], + service: { kind: "deny" }, + }); + }); + it.each([ ["POST", "/sessions/session-1/pr"], ["GET", "/sessions/session-1/tunnel-urls"], @@ -484,6 +527,25 @@ describe("route principal policy", () => { expect(enforceRoutePrincipal(authentication, principal)).toBeNull(); }); + it("requires CLI credential provenance for external users", () => { + expect( + enforceRoutePrincipal( + { kind: "external-user" }, + { kind: "user", userId: "user-1" }, + { + mechanism: "cli_credential", + credentialId: "credential-1", + expiresAt: Date.now() + 1_000, + channel: { kind: "direct_bearer" }, + } + ) + ).toBeNull(); + expect( + enforceRoutePrincipal({ kind: "external-user" }, { kind: "user", userId: "user-1" })?.response + .status + ).toBe(403); + }); + it.each([ [ { kind: "web-service" } as const, diff --git a/packages/control-plane/src/router.session-prompt.test.ts b/packages/control-plane/src/router.session-prompt.test.ts index 88fffb4996..e6c00ce24e 100644 --- a/packages/control-plane/src/router.session-prompt.test.ts +++ b/packages/control-plane/src/router.session-prompt.test.ts @@ -55,27 +55,71 @@ function userPromptRequest(body: Record<string, unknown>): Promise<Request> { }); } -function createEnv(sessionFetch: ReturnType<typeof vi.fn>): Record<string, unknown> { +function createEnv( + sessionFetch: ReturnType<typeof vi.fn>, + options: { sessionModel?: string; enabledModels?: string[] } = {} +): Record<string, unknown> { const statement = { bind: vi.fn(() => statement), - first: vi.fn(async () => ({ - user_id: "user-1", - suspended_at: null, - assigned: 1, - role_id: "role_builtin_administrator", - role_key: "administrator", - role_name: "Administrator", - })), + first: vi.fn(async () => { + const query = statementQuery; + if (query.includes("SELECT * FROM sessions")) { + return { + id: "session-1", + title: null, + repo_owner: null, + repo_name: null, + model: options.sessionModel ?? "openai/gpt-5.6-sol", + reasoning_effort: "high", + base_branch: null, + status: "active", + parent_session_id: null, + root_session_id: "session-1", + spawn_source: "user", + spawn_depth: 0, + automation_id: null, + automation_run_id: null, + scm_login: null, + user_id: "user-1", + total_cost: 0, + active_duration_ms: 0, + message_count: 0, + pr_count: 0, + environment_id: null, + external_request_fingerprint: null, + external_bootstrap_snapshot: null, + created_at: 1, + updated_at: 1, + }; + } + if (query.includes("SELECT enabled_models FROM model_preferences")) { + return options.enabledModels + ? { enabled_models: JSON.stringify(options.enabledModels) } + : null; + } + return { + user_id: "user-1", + suspended_at: null, + assigned: 1, + role_id: "role_builtin_administrator", + role_key: "administrator", + role_name: "Administrator", + }; + }), all: vi.fn(async () => ({ results: [{ permission_id: "sessions.collaborate" }], })), run: vi.fn(async () => ({ meta: { changes: 0 } })), }; + let statementQuery = ""; return { ...TEST_SERVICE_SECRETS, SCM_PROVIDER: "github", DB: { - prepare: vi.fn(() => statement), + prepare: vi.fn((query: string) => { + statementQuery = query; + return statement; + }), batch: vi.fn(), exec: vi.fn(), dump: vi.fn(), @@ -191,4 +235,28 @@ describe("session prompt identity enrichment", () => { }); expect(sessionFetch).not.toHaveBeenCalled(); }); + + it("applies canonical model enablement and reasoning policy before web dispatch", async () => { + const sessionFetch = vi.fn(async () => Response.json({ status: "queued" })); + const disabled = await handleRequest( + await userPromptRequest({ content: "Fix the bug" }), + createEnv(sessionFetch, { enabledModels: ["anthropic/claude-sonnet-4-6"] }) as never, + TEST_BACKGROUND_TASK_CONTEXT + ); + expect(disabled.status).toBe(400); + await expect(disabled.json()).resolves.toEqual({ + error: 'Model "openai/gpt-5.6-sol" is not enabled', + }); + + const invalidReasoning = await handleRequest( + await userPromptRequest({ content: "Fix the bug", reasoningEffort: "low" }), + createEnv(sessionFetch, { sessionModel: "anthropic/claude-haiku-4-5" }) as never, + TEST_BACKGROUND_TASK_CONTEXT + ); + expect(invalidReasoning.status).toBe(400); + await expect(invalidReasoning.json()).resolves.toEqual({ + error: 'Reasoning effort "low" is not supported by model "anthropic/claude-haiku-4-5"', + }); + expect(sessionFetch).not.toHaveBeenCalled(); + }); }); diff --git a/packages/control-plane/src/router.ts b/packages/control-plane/src/router.ts index 6184423ac4..405ce724ad 100644 --- a/packages/control-plane/src/router.ts +++ b/packages/control-plane/src/router.ts @@ -4,7 +4,7 @@ import type { Env } from "./types"; import { authenticate, isAuthError } from "./auth/authenticate"; -import type { Principal } from "./auth/principal"; +import type { AuthenticationContext, Principal } from "./auth/principal"; import { getUserAuth, getUserAuthRuntime } from "./auth/user/runtime"; import { resolveScmProviderFromEnv, @@ -17,6 +17,13 @@ import { createSessionRuntimeClient } from "./session/runtime-client"; import { createRequestMetrics, instrumentD1 } from "./db/instrumented-d1"; import { UserStore } from "./db/user-store"; import { AutomationStore } from "./db/automation-store"; +import { + listCurrentManagedSecretValues, + listManagedSecretHistory, +} from "./db/managed-secret-redaction-history"; +import { decryptToken } from "./auth/crypto"; +import { decryptProviderAccountPayload } from "./auth/provider-account-crypto"; +import type { ModelProviderId } from "./model-provider-accounts/provider-auth-contracts"; import { AuthorizationError, AuthorizationService } from "./authorization/service"; import { serviceAllowsPermission } from "./authorization/service-permissions"; import { @@ -68,6 +75,7 @@ import { keyboardShortcutRoutes } from "./routes/keyboard-shortcuts"; import { rbacRoutes } from "./routes/rbac"; import { sessionRoutes } from "./routes/sessions"; import { modelProviderAccountRoutes } from "./routes/model-provider-accounts"; +import { cliAuthRoutes } from "./routes/cli-auth"; import { handleSlackNotify } from "./routes/slack-notify"; import { webhookRoutes } from "./webhooks"; @@ -350,6 +358,7 @@ function resultForFailure( export function enforceRoutePrincipal( authentication: RouteAuthentication, principal: Principal, + authenticationContext?: AuthenticationContext, evidence: AuthorizationEvidence = { requirements: [], effectivePermissions: [] } ): AuthorizationFailure | null { if ( @@ -358,7 +367,10 @@ export function enforceRoutePrincipal( ) { return { response: error("Unauthorized", 401) }; } - if (authentication.kind === "user" && principal.kind !== "user") { + if ( + (authentication.kind === "user" || authentication.kind === "external-user") && + principal.kind !== "user" + ) { return authorizationDenial( error("Human user authentication required", 403), evidence, @@ -367,6 +379,18 @@ export function enforceRoutePrincipal( "Human user authentication required" ); } + if ( + authentication.kind === "external-user" && + authenticationContext?.mechanism !== "cli_credential" + ) { + return authorizationDenial( + error("CLI authentication required", 403), + evidence, + { kind: "principal-type" }, + "cli_credential_required", + "CLI authentication required" + ); + } if (authentication.kind === "service" && principal.kind !== "service") { return authorizationDenial( error("Service authentication required", 403), @@ -715,7 +739,12 @@ async function enforceRouteAuthorization( }; } - const principalFailure = enforceRoutePrincipal(route.authentication, principal, evidence); + const principalFailure = enforceRoutePrincipal( + route.authentication, + principal, + ctx.authentication, + evidence + ); if (principalFailure) return resultForFailure(principalFailure); if ( @@ -793,6 +822,7 @@ export const routes: Route[] = [ ...browserAuthRoutes, ...signInProviderRoutes, + ...cliAuthRoutes, // Session management ...sessionRoutes, @@ -931,6 +961,15 @@ export async function handleRequest( return withCorsAndTraceHeaders(error("Not found", 404), ctx); } + const finalize = async (response: Response): Promise<Response> => + withCorsAndTraceHeaders( + withRouteCachePolicy( + await withExternalErrorContract(response, matchedRoute.route.authentication.kind, env, ctx), + matchedRoute.route + ), + ctx + ); + const authentication = matchedRoute.route.authentication; if (authentication.kind !== "public" && authentication.kind !== "handler-authenticated") { let authError: Response | null; @@ -951,6 +990,7 @@ export async function handleRequest( authentication.kind === "web-service" || authentication.kind === "service" ? "service" : "user", + ...(authentication.kind === "external-user" ? { userCredential: "cli" } : {}), }); if (isAuthError(authResult)) { @@ -979,7 +1019,7 @@ export async function handleRequest( logPrincipal(ctx.principal, ctx, path); logRequest(authError, ctx, method, path, startTime); } - return withCorsAndTraceHeaders(withRouteCachePolicy(authError, matchedRoute.route), ctx); + return finalize(authError); } if (ctx.principal) { @@ -1003,15 +1043,12 @@ export async function handleRequest( }); } logRequest(authorizationResult.response, ctx, method, path, startTime); - return withCorsAndTraceHeaders( - withRouteCachePolicy(authorizationResult.response, matchedRoute.route), - ctx - ); + return finalize(authorizationResult.response); } const providerCheck = enforceImplementedScmProvider(matchedRoute.route, path, env, ctx); if (providerCheck) { - return withRouteCachePolicy(providerCheck, matchedRoute.route); + return finalize(providerCheck); } let response: Response; @@ -1044,7 +1081,7 @@ export async function handleRequest( decision: authorizationResult.decision, }); } - return withCorsAndTraceHeaders(withRouteCachePolicy(response, matchedRoute.route), ctx); + return finalize(response); } } @@ -1060,5 +1097,131 @@ export async function handleRequest( }); } - return withCorsAndTraceHeaders(withRouteCachePolicy(response, matchedRoute.route), ctx); + return finalize(response); +} + +async function withExternalErrorContract( + response: Response, + authentication: RouteAuthentication["kind"], + env: Env, + ctx: RequestContext +): Promise<Response> { + if (authentication !== "external-user" || response.ok) return response; + const payload: Record<string, unknown> = await response + .clone() + .json<Record<string, unknown>>() + .catch(() => ({}) as Record<string, unknown>); + const message = typeof payload.error === "string" ? payload.error : "Request failed"; + const code = typeof payload.code === "string" ? payload.code : externalErrorCode(response.status); + let redactedPayload = { ...payload, error: message, code, message, requestId: ctx.request_id }; + if (env.REPO_SECRETS_ENCRYPTION_KEY) { + const values = new Set([ + ...(await listCurrentManagedSecretValues(ctx.db, env.REPO_SECRETS_ENCRYPTION_KEY)), + ...(await listManagedSecretHistory(ctx.db, env.REPO_SECRETS_ENCRYPTION_KEY)), + ...(await externalCredentialRedactions(env, ctx)), + ]); + redactedPayload = redactExactStrings(redactedPayload, values) as typeof redactedPayload; + } + const headers = new Headers(response.headers); + headers.set("Content-Type", "application/json"); + return new Response(JSON.stringify(redactedPayload), { status: response.status, headers }); +} + +async function externalCredentialRedactions(env: Env, ctx: RequestContext): Promise<string[]> { + const values: string[] = []; + const mcpRows = await ctx.db + .prepare( + `SELECT env AS encrypted_env FROM mcp_servers + UNION ALL SELECT encrypted_env FROM mcp_credential_redaction_history` + ) + .all<{ encrypted_env: string }>(); + for (const { encrypted_env } of mcpRows.results ?? []) { + if (!encrypted_env || ["{}", "null"].includes(encrypted_env)) continue; + try { + collectRedactionStrings( + JSON.parse(await decryptToken(encrypted_env, env.REPO_SECRETS_ENCRYPTION_KEY!)), + values + ); + } catch { + collectRedactionStrings(JSON.parse(encrypted_env), values); + } + } + const scmRows = await ctx.db + .prepare( + `SELECT access_token_encrypted, refresh_token_encrypted FROM user_scm_tokens + UNION ALL + SELECT access_token_encrypted, refresh_token_encrypted + FROM scm_credential_redaction_history` + ) + .all<{ access_token_encrypted: string; refresh_token_encrypted: string }>(); + for (const row of scmRows.results ?? []) { + values.push( + await decryptToken(row.access_token_encrypted, env.TOKEN_ENCRYPTION_KEY), + await decryptToken(row.refresh_token_encrypted, env.TOKEN_ENCRYPTION_KEY) + ); + } + const providerRows = await ctx.db + .prepare( + `SELECT credentials.provider_account_id, accounts.provider, + credentials.credential_schema_version, credentials.encrypted_payload + FROM model_provider_account_credentials credentials + JOIN model_provider_accounts accounts ON accounts.id = credentials.provider_account_id + UNION ALL + SELECT provider_account_id, provider, credential_schema_version, encrypted_payload + FROM provider_credential_redaction_history` + ) + .all<{ + provider_account_id: string; + provider: ModelProviderId; + credential_schema_version: number; + encrypted_payload: string; + }>(); + for (const row of providerRows.results ?? []) { + collectRedactionStrings( + await decryptProviderAccountPayload( + row.encrypted_payload, + env.PROVIDER_ACCOUNTS_ENCRYPTION_KEY, + { + providerAccountId: row.provider_account_id, + provider: row.provider, + credentialSchemaVersion: row.credential_schema_version, + } + ), + values + ); + } + return values; +} + +function collectRedactionStrings(value: unknown, target: string[]): void { + if (typeof value === "string") target.push(value); + else if (Array.isArray(value)) value.forEach((entry) => collectRedactionStrings(entry, target)); + else if (value && typeof value === "object") + Object.values(value).forEach((entry) => collectRedactionStrings(entry, target)); +} + +function redactExactStrings(value: unknown, secrets: ReadonlySet<string>): unknown { + if (typeof value === "string") { + let redacted = value; + for (const secret of secrets) if (secret) redacted = redacted.split(secret).join("[REDACTED]"); + return redacted; + } + if (Array.isArray(value)) return value.map((entry) => redactExactStrings(entry, secrets)); + if (value && typeof value === "object") + return Object.fromEntries( + Object.entries(value).map(([key, entry]) => [key, redactExactStrings(entry, secrets)]) + ); + return value; +} + +function externalErrorCode(status: number): string { + if (status === 400 || status === 422) return "invalid_request"; + if (status === 401) return "unauthenticated"; + if (status === 403) return "forbidden"; + if (status === 404) return "not_found"; + if (status === 409) return "conflict"; + if (status === 410) return "checkpoint_expired"; + if (status === 429) return "rate_limited"; + if (status === 426) return "incompatible_client"; + return "service_unavailable"; } diff --git a/packages/control-plane/src/routes/cli-auth.ts b/packages/control-plane/src/routes/cli-auth.ts new file mode 100644 index 0000000000..98a2b24654 --- /dev/null +++ b/packages/control-plane/src/routes/cli-auth.ts @@ -0,0 +1,359 @@ +import { + CLI_EXTERNAL_API_VERSION, + CLI_EXTERNAL_API_V1_PATH, + approveCliDeviceAuthorizationRequestSchema, + cliDeviceAuthorizationExchangeRequestSchema, + revokeCliDeviceAuthorizationRequestSchema, + startCliDeviceAuthorizationRequestSchema, +} from "@open-inspect/shared/types/cli-auth"; +import { ZodError } from "zod"; +import { hashToken } from "../auth/crypto"; +import type { AuthenticationContext } from "../auth/principal"; +import { + CLI_DEVICE_AUTHORIZATION_LIFETIME_MS, + CliDeviceAuthorizationError, + CliDeviceAuthorizationService, +} from "../cli-auth/device-authorization-service"; +import { CliAuthStore } from "../db/cli-auth-store"; +import { UserStore } from "../db/user-store"; +import type { Env } from "../types"; +import { + ACTIVE_SELF, + NO_AUTHORIZATION, + SCM_AGNOSTIC_EXTERNAL_USER_ROUTE, + SCM_AGNOSTIC_HUMAN_USER_ROUTE, + defineRoutes, + error, + json, + parseJsonBody, + type RequestContext, + type Route, + type UserRouteContext, +} from "./shared"; + +const POLL_INTERVAL_MS = 1_000; +const MINUTE_MS = 60_000; +const EXCHANGE_POLL_MARGIN = 30; +const EXCHANGE_POLLS_PER_LIFETIME = + Math.ceil(CLI_DEVICE_AUTHORIZATION_LIFETIME_MS / POLL_INTERVAL_MS) + EXCHANGE_POLL_MARGIN; + +export const CLI_AUTH_RATE_LIMITS = { + startPerIp: { windowMs: MINUTE_MS, limit: 10 }, + exchangeBurstPerSecret: { windowMs: POLL_INTERVAL_MS, limit: 2 }, + exchangePerSecret: { + windowMs: CLI_DEVICE_AUTHORIZATION_LIFETIME_MS, + limit: EXCHANGE_POLLS_PER_LIFETIME, + }, + exchangePerIp: { + windowMs: CLI_DEVICE_AUTHORIZATION_LIFETIME_MS, + limit: EXCHANGE_POLLS_PER_LIFETIME * 2, + }, + lookupPerUser: { windowMs: 10 * MINUTE_MS, limit: 30 }, + approvalPerUser: { windowMs: 10 * MINUTE_MS, limit: 10 }, + capabilityRevokePerSecret: { windowMs: 10 * MINUTE_MS, limit: 10 }, + capabilityRevokePerIp: { windowMs: 10 * MINUTE_MS, limit: 100 }, +} as const; + +function deviceAuthorizationService(ctx: RequestContext): CliDeviceAuthorizationService { + return new CliDeviceAuthorizationService(new CliAuthStore(ctx.db)); +} + +function serviceError(cause: unknown): Response { + if (cause instanceof CliDeviceAuthorizationError) return error(cause.message, cause.status); + if (cause instanceof ZodError) return error("Invalid request body", 400); + throw cause; +} + +function clientIp(request: Request): string { + return request.headers.get("CF-Connecting-IP") ?? "unknown"; +} + +async function enforceRateLimits( + ctx: RequestContext, + limits: readonly { + scope: string; + identity: string; + windowMs: number; + limit: number; + }[] +): Promise<Response | null> { + const store = new CliAuthStore(ctx.db); + const now = Date.now(); + const outcomes = await Promise.all( + limits.map(async (limit) => + store.consumeRateLimit({ + key: `${limit.scope}:${await hashToken(limit.identity)}`, + now, + windowMs: limit.windowMs, + limit: limit.limit, + }) + ) + ); + const blocked = outcomes.filter((outcome) => !outcome.allowed); + if (blocked.length === 0) return null; + const retryAfterSeconds = Math.ceil( + Math.max(...blocked.map((outcome) => outcome.retryAfterMs)) / 1000 + ); + return new Response(JSON.stringify({ error: "Too many requests" }), { + status: 429, + headers: { + "Content-Type": "application/json", + "Retry-After": String(retryAfterSeconds), + }, + }); +} + +async function startAuthorization( + request: Request, + env: Env, + _match: RegExpMatchArray, + ctx: RequestContext +): Promise<Response> { + const limited = await enforceRateLimits(ctx, [ + { + scope: "start-ip", + identity: `${env.DEPLOYMENT_NAME}:${clientIp(request)}`, + ...CLI_AUTH_RATE_LIMITS.startPerIp, + }, + ]); + if (limited) return limited; + const body = await parseJsonBody<unknown>(request); + if (body instanceof Response) return body; + try { + const input = startCliDeviceAuthorizationRequestSchema.parse(body); + const started = await deviceAuthorizationService(ctx).start(input.deviceName); + const webBaseUrl = (env.WEB_APP_URL ?? new URL(request.url).origin).replace(/\/$/, ""); + return json( + { + ...started, + verificationUrl: `${webBaseUrl}/cli/authorize?user_code=${encodeURIComponent(started.userCode)}`, + pollIntervalMs: POLL_INTERVAL_MS, + }, + 201 + ); + } catch (cause) { + return serviceError(cause); + } +} + +async function exchangeAuthorization( + request: Request, + env: Env, + _match: RegExpMatchArray, + ctx: RequestContext +): Promise<Response> { + const body = await parseJsonBody<unknown>(request); + if (body instanceof Response) return body; + try { + const input = cliDeviceAuthorizationExchangeRequestSchema.parse(body); + const limited = await enforceRateLimits(ctx, [ + { + scope: "exchange-secret-burst", + identity: input.deviceSecret, + ...CLI_AUTH_RATE_LIMITS.exchangeBurstPerSecret, + }, + { + scope: "exchange-secret", + identity: input.deviceSecret, + ...CLI_AUTH_RATE_LIMITS.exchangePerSecret, + }, + { + scope: "exchange-ip", + identity: `${env.DEPLOYMENT_NAME}:${clientIp(request)}`, + ...CLI_AUTH_RATE_LIMITS.exchangePerIp, + }, + ]); + if (limited) return limited; + const exchanged = await deviceAuthorizationService(ctx).exchange(input.deviceSecret); + return json(exchanged, exchanged.status === "pending" ? 202 : 200); + } catch (cause) { + return serviceError(cause); + } +} + +async function revokeIssuedCredential( + request: Request, + env: Env, + _match: RegExpMatchArray, + ctx: RequestContext +): Promise<Response> { + const body = await parseJsonBody<unknown>(request); + if (body instanceof Response) return body; + try { + const input = revokeCliDeviceAuthorizationRequestSchema.parse(body); + const limited = await enforceRateLimits(ctx, [ + { + scope: "capability-revoke-secret", + identity: input.deviceSecret, + ...CLI_AUTH_RATE_LIMITS.capabilityRevokePerSecret, + }, + { + scope: "capability-revoke-ip", + identity: `${env.DEPLOYMENT_NAME}:${clientIp(request)}`, + ...CLI_AUTH_RATE_LIMITS.capabilityRevokePerIp, + }, + ]); + if (limited) return limited; + await deviceAuthorizationService(ctx).revokeIssuedCredential(input.deviceSecret); + return new Response(null, { status: 204 }); + } catch (cause) { + return serviceError(cause); + } +} + +async function getPendingAuthorization( + request: Request, + env: Env, + _match: RegExpMatchArray, + ctx: UserRouteContext +): Promise<Response> { + try { + const input = approveCliDeviceAuthorizationRequestSchema.parse({ + userCode: new URL(request.url).searchParams.get("user_code") ?? "", + }); + const limited = await enforceRateLimits(ctx, [ + { + scope: "lookup-user", + identity: ctx.principal.userId, + ...CLI_AUTH_RATE_LIMITS.lookupPerUser, + }, + ]); + if (limited) return limited; + const pending = await deviceAuthorizationService(ctx).getPendingAuthorization(input.userCode); + return json({ ...pending, installation: { name: env.DEPLOYMENT_NAME } }); + } catch (cause) { + return serviceError(cause); + } +} + +async function approveAuthorization( + request: Request, + _env: Env, + _match: RegExpMatchArray, + ctx: UserRouteContext +): Promise<Response> { + const body = await parseJsonBody<unknown>(request); + if (body instanceof Response) return body; + try { + const input = approveCliDeviceAuthorizationRequestSchema.parse(body); + const limited = await enforceRateLimits(ctx, [ + { + scope: "approval-user", + identity: ctx.principal.userId, + ...CLI_AUTH_RATE_LIMITS.approvalPerUser, + }, + ]); + if (limited) return limited; + await deviceAuthorizationService(ctx).approve(input.userCode, ctx.principal.userId); + return new Response(null, { status: 204 }); + } catch (cause) { + return serviceError(cause); + } +} + +function cliAuthentication( + ctx: UserRouteContext +): Extract<AuthenticationContext, { mechanism: "cli_credential" }> { + if (ctx.authentication?.mechanism !== "cli_credential") { + throw new Error("Missing CLI authentication context"); + } + return ctx.authentication; +} + +async function getMe( + _request: Request, + env: Env, + _match: RegExpMatchArray, + ctx: UserRouteContext +): Promise<Response> { + const authentication = cliAuthentication(ctx); + const user = await new UserStore(ctx.db).getUserById(ctx.principal.userId); + if (!user) return error("User not found", 404); + return json({ + installation: { name: env.DEPLOYMENT_NAME }, + user: { id: user.id, displayName: user.displayName, email: user.email }, + credential: { id: authentication.credentialId, expiresAt: authentication.expiresAt }, + serverVersion: CLI_EXTERNAL_API_VERSION, + }); +} + +async function revokeCurrent( + _request: Request, + _env: Env, + _match: RegExpMatchArray, + ctx: UserRouteContext +): Promise<Response> { + const authentication = cliAuthentication(ctx); + await new CliAuthStore(ctx.db).revoke( + authentication.credentialId, + ctx.principal.userId, + Date.now() + ); + return new Response(null, { status: 204 }); +} + +const publicRoutes: Route[] = [ + { + authentication: { kind: "public" }, + supportedScmProviders: "all", + method: "POST", + pattern: new RegExp(`^${CLI_EXTERNAL_API_V1_PATH}/device-authorizations$`), + authorization: NO_AUTHORIZATION, + cacheControl: "no-store", + handler: startAuthorization, + }, + { + authentication: { kind: "public" }, + supportedScmProviders: "all", + method: "POST", + pattern: new RegExp(`^${CLI_EXTERNAL_API_V1_PATH}/device-authorizations/exchange$`), + authorization: NO_AUTHORIZATION, + cacheControl: "no-store", + handler: exchangeAuthorization, + }, + { + authentication: { kind: "public" }, + supportedScmProviders: "all", + method: "POST", + pattern: new RegExp(`^${CLI_EXTERNAL_API_V1_PATH}/device-authorizations/revoke$`), + authorization: NO_AUTHORIZATION, + cacheControl: "no-store", + handler: revokeIssuedCredential, + }, +]; + +export const cliAuthRoutes: Route[] = [ + ...publicRoutes, + ...defineRoutes(SCM_AGNOSTIC_HUMAN_USER_ROUTE, [ + { + method: "GET", + pattern: new RegExp(`^${CLI_EXTERNAL_API_V1_PATH}/device-authorizations/pending$`), + authorization: ACTIVE_SELF, + cacheControl: "private, no-store", + handler: getPendingAuthorization, + }, + { + method: "POST", + pattern: new RegExp(`^${CLI_EXTERNAL_API_V1_PATH}/device-authorizations/approve$`), + authorization: ACTIVE_SELF, + cacheControl: "private, no-store", + handler: approveAuthorization, + }, + ]), + ...defineRoutes(SCM_AGNOSTIC_EXTERNAL_USER_ROUTE, [ + { + method: "GET", + pattern: new RegExp(`^${CLI_EXTERNAL_API_V1_PATH}/me$`), + authorization: ACTIVE_SELF, + cacheControl: "private, no-store", + handler: getMe, + }, + { + method: "DELETE", + pattern: new RegExp(`^${CLI_EXTERNAL_API_V1_PATH}/credentials/current$`), + authorization: ACTIVE_SELF, + cacheControl: "private, no-store", + handler: revokeCurrent, + }, + ]), +]; diff --git a/packages/control-plane/src/routes/external-discovery.test.ts b/packages/control-plane/src/routes/external-discovery.test.ts new file mode 100644 index 0000000000..1b96f1ea19 --- /dev/null +++ b/packages/control-plane/src/routes/external-discovery.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "vitest"; +import { externalDiscoveryRoutes } from "./external-discovery"; + +describe("external discovery route policy", () => { + it("defines only the six V1 read routes with external-user authentication", () => { + expect( + externalDiscoveryRoutes.map((route) => ({ + method: route.method, + pattern: route.pattern.source, + authentication: route.authentication.kind, + scm: route.supportedScmProviders, + cacheControl: route.cacheControl, + })) + ).toEqual([ + { + method: "GET", + pattern: "^\\/external\\/v1\\/repositories$", + authentication: "external-user", + scm: "all", + cacheControl: "private, no-store", + }, + { + method: "GET", + pattern: "^\\/external\\/v1\\/environments$", + authentication: "external-user", + scm: "all", + cacheControl: "private, no-store", + }, + { + method: "GET", + pattern: "^\\/external\\/v1\\/environments\\/(?<id>[^/]+)$", + authentication: "external-user", + scm: "all", + cacheControl: "private, no-store", + }, + { + method: "GET", + pattern: "^\\/external\\/v1\\/models$", + authentication: "external-user", + scm: "all", + cacheControl: "private, no-store", + }, + { + method: "GET", + pattern: "^\\/external\\/v1\\/skills$", + authentication: "external-user", + scm: "all", + cacheControl: "private, no-store", + }, + { + method: "GET", + pattern: "^\\/external\\/v1\\/provider-accounts$", + authentication: "external-user", + scm: "all", + cacheControl: "private, no-store", + }, + ]); + }); + + it("assigns the required RBAC policies", () => { + expect(externalDiscoveryRoutes.map((route) => route.authorization)).toMatchObject([ + { kind: "active-user", allOf: [{ permission: "repositories.read" }] }, + { kind: "active-user", allOf: [{ permission: "environments.read" }] }, + { kind: "active-user", allOf: [{ permission: "environments.read" }] }, + { kind: "active-global" }, + { + kind: "active-user", + allOf: [{ permission: "skills.read" }], + }, + { kind: "active-user", allOf: [{ permission: "provider_accounts.read" }] }, + ]); + }); +}); diff --git a/packages/control-plane/src/routes/external-discovery.ts b/packages/control-plane/src/routes/external-discovery.ts new file mode 100644 index 0000000000..e0f3bd3d67 --- /dev/null +++ b/packages/control-plane/src/routes/external-discovery.ts @@ -0,0 +1,303 @@ +import { + DEFAULT_ENABLED_MODELS, + MODEL_OPTIONS, + MODEL_REASONING_CONFIG, + normalizeValidModels, +} from "@open-inspect/shared/models"; +import type { Environment } from "@open-inspect/shared/types/environments"; +import type { EnrichedRepository } from "@open-inspect/shared/types/repository-catalog"; +import { EnvironmentStore, toEnvironment } from "../db/environments"; +import { ModelPreferencesStore } from "../db/model-preferences"; +import { ModelProviderAccountStore } from "../db/model-provider-accounts"; +import { ProviderDefaultStore } from "../db/provider-account-defaults"; +import { SkillProfileStore } from "../db/skill-profiles"; +import { SkillStore } from "../db/skills"; +import type { Env } from "../types"; +import { handleListRepos } from "./repos"; +import { + SCM_AGNOSTIC_EXTERNAL_USER_ROUTE, + activeGlobal, + defineRoutes, + error, + json, + parsePattern, + requirePermission, + type Route, + type UserRouteContext, +} from "./shared"; + +const EXTERNAL_V1_PATH = "/external/v1"; +const DEFAULT_LIST_LIMIT = 50; +const MAX_LIST_LIMIT = 100; +const PRIVATE_NO_STORE = "private, no-store" as const; + +interface ListQuery { + limit: number; + offset: number; +} + +function hasOnlyQueryParams(request: Request, allowed: readonly string[]): boolean { + const search = new URL(request.url).searchParams; + return [...search.keys()].every( + (key) => allowed.includes(key) && search.getAll(key).length === 1 + ); +} + +function listQuery(request: Request): ListQuery | Response { + const search = new URL(request.url).searchParams; + if (!hasOnlyQueryParams(request, ["limit", "offset"])) { + return error("Invalid list query", 400); + } + const limitValue = search.get("limit"); + const offsetValue = search.get("offset"); + const limit = limitValue === null ? DEFAULT_LIST_LIMIT : Number(limitValue); + const offset = offsetValue === null ? 0 : Number(offsetValue); + if ( + (limitValue !== null && !/^\d+$/.test(limitValue)) || + (offsetValue !== null && !/^\d+$/.test(offsetValue)) || + !Number.isSafeInteger(limit) || + limit < 1 || + limit > MAX_LIST_LIMIT || + !Number.isSafeInteger(offset) || + offset < 0 || + !Number.isSafeInteger(offset + limit) + ) { + return error("Invalid list query", 400); + } + return { limit, offset }; +} + +function page<T>( + items: T[], + query: ListQuery +): { + items: T[]; + hasMore: boolean; + continuationOffset?: number; +} { + const values = items.slice(query.offset, query.offset + query.limit); + const hasMore = query.offset + values.length < items.length; + return { + items: values, + hasMore, + ...(hasMore ? { continuationOffset: query.offset + values.length } : {}), + }; +} + +function projectEnvironment({ channelAssociations: _channels, ...environment }: Environment) { + return environment; +} + +async function listRepositories( + request: Request, + env: Env, + match: RegExpMatchArray, + ctx: UserRouteContext +): Promise<Response> { + const query = listQuery(request); + if (query instanceof Response) return query; + const response = await handleListRepos(request, env, match, ctx); + if (!response.ok) return response; + const result = (await response.json()) as { repos: EnrichedRepository[] }; + const repositories = page( + result.repos.map( + ({ + id, + owner, + name, + fullName, + description, + private: isPrivate, + defaultBranch, + archived, + }) => ({ + id, + owner, + name, + fullName, + description, + private: isPrivate, + defaultBranch, + archived, + }) + ), + query + ); + return json({ + repositories: repositories.items, + hasMore: repositories.hasMore, + ...(repositories.continuationOffset === undefined + ? {} + : { continuationOffset: repositories.continuationOffset }), + }); +} + +async function listEnvironments( + request: Request, + _env: Env, + _match: RegExpMatchArray, + ctx: UserRouteContext +): Promise<Response> { + const query = listQuery(request); + if (query instanceof Response) return query; + const store = new EnvironmentStore(ctx.db); + const result = await store.list(); + const rows = page(result.environments, query); + const repositories = await store.getRepositoriesForEnvironmentIds(rows.items.map(({ id }) => id)); + return json({ + environments: rows.items.map((row) => + projectEnvironment(toEnvironment(row, repositories.get(row.id) ?? [])) + ), + hasMore: rows.hasMore, + ...(rows.continuationOffset === undefined + ? {} + : { continuationOffset: rows.continuationOffset }), + }); +} + +async function getEnvironment( + request: Request, + _env: Env, + match: RegExpMatchArray, + ctx: UserRouteContext +): Promise<Response> { + if (!hasOnlyQueryParams(request, [])) return error("Invalid query", 400); + const id = match.groups?.id; + if (!id) return error("Environment ID required", 400); + const store = new EnvironmentStore(ctx.db); + const row = await store.getById(id); + if (!row) return error("Environment not found", 404); + return json({ + environment: projectEnvironment( + toEnvironment(row, await store.getRepositoriesForEnvironment(id)) + ), + }); +} + +async function listModels( + request: Request, + _env: Env, + _match: RegExpMatchArray, + ctx: UserRouteContext +): Promise<Response> { + if (!hasOnlyQueryParams(request, [])) return error("Invalid query", 400); + const configured = await new ModelPreferencesStore(ctx.db).getEnabledModels(); + const normalized = configured ? normalizeValidModels(configured) : []; + const enabled = new Set(normalized.length > 0 ? normalized : DEFAULT_ENABLED_MODELS); + return json({ + models: MODEL_OPTIONS.flatMap(({ category, models }) => + models + .filter(({ id }) => enabled.has(id)) + .map((model) => ({ + ...model, + category, + reasoning: MODEL_REASONING_CONFIG[model.id] ?? null, + })) + ), + }); +} + +async function listSkills( + request: Request, + _env: Env, + _match: RegExpMatchArray, + ctx: UserRouteContext +): Promise<Response> { + const query = listQuery(request); + if (query instanceof Response) return query; + const fetched = await new SkillStore(ctx.db).list({ + limit: query.offset + query.limit, + cursor: null, + }); + const profiles = ctx.authorization?.permissions.includes("skill_profiles.manage_own") + ? await new SkillProfileStore(ctx.db).list(ctx.principal.userId) + : []; + const combinedPage = page([...fetched.skills, ...profiles], query); + const hasMore = combinedPage.hasMore || fetched.hasMore; + return json({ + skills: combinedPage.items.filter((item) => "currentRevisionId" in item), + profiles: combinedPage.items.filter((item) => !("currentRevisionId" in item)), + hasMore, + ...(hasMore ? { continuationOffset: query.offset + combinedPage.items.length } : {}), + }); +} + +async function listProviderAccounts( + request: Request, + _env: Env, + _match: RegExpMatchArray, + ctx: UserRouteContext +): Promise<Response> { + const query = listQuery(request); + if (query instanceof Response) return query; + const [accounts, defaults] = await Promise.all([ + new ModelProviderAccountStore(ctx.db).list(), + new ProviderDefaultStore(ctx.db).list(), + ]); + const defaultByProvider = new Map(defaults.map((value) => [value.provider, value])); + const projected = accounts.map(({ id, provider, displayName, status }) => { + const providerDefault = defaultByProvider.get(provider); + return { + id, + provider, + displayName, + status, + isDefault: providerDefault?.providerAccountId === id, + unattendedMode: + providerDefault?.providerAccountId === id ? providerDefault.unattendedMode : null, + }; + }); + const accountsPage = page(projected, query); + return json({ + accounts: accountsPage.items, + hasMore: accountsPage.hasMore, + ...(accountsPage.continuationOffset === undefined + ? {} + : { continuationOffset: accountsPage.continuationOffset }), + }); +} + +export const externalDiscoveryRoutes: Route[] = defineRoutes(SCM_AGNOSTIC_EXTERNAL_USER_ROUTE, [ + { + method: "GET", + pattern: parsePattern(`${EXTERNAL_V1_PATH}/repositories`), + authorization: requirePermission("repositories.read", { service: "deny" }), + cacheControl: PRIVATE_NO_STORE, + handler: listRepositories, + }, + { + method: "GET", + pattern: parsePattern(`${EXTERNAL_V1_PATH}/environments`), + authorization: requirePermission("environments.read", { service: "deny" }), + cacheControl: PRIVATE_NO_STORE, + handler: listEnvironments, + }, + { + method: "GET", + pattern: parsePattern(`${EXTERNAL_V1_PATH}/environments/:id`), + authorization: requirePermission("environments.read", { service: "deny" }), + cacheControl: PRIVATE_NO_STORE, + handler: getEnvironment, + }, + { + method: "GET", + pattern: parsePattern(`${EXTERNAL_V1_PATH}/models`), + authorization: activeGlobal(), + cacheControl: PRIVATE_NO_STORE, + handler: listModels, + }, + { + method: "GET", + pattern: parsePattern(`${EXTERNAL_V1_PATH}/skills`), + authorization: requirePermission("skills.read", { service: "deny" }), + cacheControl: PRIVATE_NO_STORE, + handler: listSkills, + }, + { + method: "GET", + pattern: parsePattern(`${EXTERNAL_V1_PATH}/provider-accounts`), + authorization: requirePermission("provider_accounts.read", { service: "deny" }), + cacheControl: PRIVATE_NO_STORE, + handler: listProviderAccounts, + }, +]); diff --git a/packages/control-plane/src/routes/external-session-resources.test.ts b/packages/control-plane/src/routes/external-session-resources.test.ts new file mode 100644 index 0000000000..0d1643fda6 --- /dev/null +++ b/packages/control-plane/src/routes/external-session-resources.test.ts @@ -0,0 +1,645 @@ +import type { PermissionId } from "@open-inspect/shared/rbac"; +import { describe, expect, it, vi } from "vitest"; +import type { SqlDatabase } from "../db/sql-database"; +import { TEST_BACKGROUND_TASK_CONTEXT } from "../router.test-support"; +import { SessionInternalPaths } from "../session/contracts"; +import type { Env } from "../types"; +import { externalSessionResourceRoutes } from "./external-session-resources"; +import type { RequestContext } from "./shared"; + +interface TestData { + sessions?: Record<string, Record<string, unknown>>; + children?: Record<string, Array<Record<string, unknown>>>; + pullRequests?: Array<Record<string, unknown>>; + repositories?: Array<Record<string, unknown>>; +} + +function sessionRow(id: string, overrides: Record<string, unknown> = {}) { + return { + id, + title: id, + repo_owner: "group/subgroup", + repo_name: "repo", + model: "openai/gpt-5.6-sol", + reasoning_effort: "high", + base_branch: "main", + status: "active", + parent_session_id: null, + root_session_id: id, + spawn_source: "user", + spawn_depth: 0, + automation_id: null, + automation_run_id: null, + scm_login: null, + user_id: "user-1", + total_cost: 0, + active_duration_ms: 0, + message_count: 0, + pr_count: 0, + environment_id: null, + external_request_fingerprint: null, + external_bootstrap_snapshot: null, + created_at: 1, + updated_at: 2, + ...overrides, + }; +} + +function createDb(data: TestData): SqlDatabase { + return { + prepare: (query: string) => { + let params: unknown[] = []; + const statement = { + bind: (...values: unknown[]) => { + params = values; + return statement; + }, + first: async () => { + if (query.includes("FROM sessions WHERE id = ?")) { + return data.sessions?.[String(params[0])] ?? null; + } + if (query.includes("session_pull_requests WHERE artifact_id = ?")) { + return data.pullRequests?.find((row) => row.artifact_id === params[0]) ?? null; + } + return null; + }, + all: async () => { + if (query.includes("FROM sessions WHERE parent_session_id = ?")) { + return { results: data.children?.[String(params[0])] ?? [] }; + } + if (query.includes("FROM session_repositories")) { + return { results: data.repositories ?? [] }; + } + if (query.includes("FROM session_pull_requests") && query.includes("GROUP BY")) { + return { results: [] }; + } + if (query.includes("FROM session_pull_requests") && query.includes("session_id = ?")) { + return { + results: data.pullRequests?.filter((row) => row.session_id === params[0]) ?? [], + }; + } + return { results: [] }; + }, + }; + return statement; + }, + } as unknown as SqlDatabase; +} + +function createContext( + db: SqlDatabase, + permissions: PermissionId[] = ["sessions.read"] +): RequestContext { + return { + trace_id: "trace-1", + request_id: "request-1", + db, + executionCtx: TEST_BACKGROUND_TASK_CONTEXT, + principal: { kind: "user", userId: "user-1" }, + authorization: { + userId: "user-1", + suspendedAt: null, + role: { id: "role-1", key: "viewer", name: "Viewer" }, + permissions, + }, + metrics: { + d1Queries: [], + spans: {}, + time: async <T>(_name: string, fn: () => Promise<T>) => fn(), + summarize: () => ({}), + }, + }; +} + +function createEnv(fetch: (request: Request) => Promise<Response>): Env { + return { + SCM_PROVIDER: "gitlab", + SESSION: { + idFromName: vi.fn((name: string) => `do-${name}`), + get: vi.fn(() => ({ fetch })), + }, + } as unknown as Env; +} + +function route(method: string, path: string) { + for (const candidate of externalSessionResourceRoutes) { + const match = path.match(candidate.pattern); + if (candidate.method === method && match) return { candidate, match }; + } + throw new Error(`No route for ${method} ${path}`); +} + +describe("external session resource routes", () => { + it("exports the V1 read routes and scoped mutation routes", () => { + expect(externalSessionResourceRoutes).toHaveLength(12); + for (const candidate of externalSessionResourceRoutes) { + expect(candidate.authentication).toEqual({ kind: "external-user" }); + expect(candidate.supportedScmProviders).toBe("all"); + expect(candidate.authorization).toMatchObject({ + kind: "active-user", + allOf: [ + { + kind: "permission", + permission: candidate.method === "POST" ? "sessions.collaborate" : "sessions.read", + }, + ], + service: { kind: "deny" }, + }); + } + }); + + it("defaults message pages to 50, caps them at 100, and parses the runtime projection", async () => { + const requests: Request[] = []; + const env = createEnv(async (request) => { + requests.push(request); + return Response.json({ + messages: [ + { + id: "message-1", + authorId: "participant-1", + content: "Inspect this", + source: "extension", + attachments: null, + status: "completed", + createdAt: 1, + startedAt: 2, + completedAt: 3, + callbackContext: "must not escape", + }, + ], + hasMore: false, + }); + }); + const db = createDb({ sessions: { "session-1": sessionRow("session-1") } }); + const path = "/external/v1/sessions/session-1/messages"; + const { candidate, match } = route("GET", path); + const response = await candidate.handler( + new Request(`https://test.local${path}`), + env, + match, + createContext(db) + ); + + expect(response.status).toBe(200); + expect(new URL(requests[0].url).searchParams.get("limit")).toBe("50"); + expect(await response.json()).toEqual({ + messages: [ + { + id: "message-1", + authorId: "participant-1", + content: "Inspect this", + source: "extension", + attachments: null, + status: "completed", + createdAt: 1, + startedAt: 2, + completedAt: 3, + }, + ], + hasMore: false, + }); + + const rejected = await candidate.handler( + new Request(`https://test.local${path}?limit=101`), + env, + match, + createContext(db) + ); + expect(rejected.status).toBe(400); + expect(requests).toHaveLength(1); + }); + + it("rejects unknown and duplicate query parameters before reading resources", async () => { + const runtimeFetch = vi.fn(async () => Response.json({})); + const env = createEnv(runtimeFetch); + const db = createDb({ sessions: { "session-1": sessionRow("session-1") } }); + const cases = [ + ["/external/v1/sessions/session-1/messages", "?unknown=value"], + ["/external/v1/sessions/session-1/artifacts", "?limit=1&limit=2"], + ["/external/v1/sessions/session-1/diff", "?offset=0&offset=1"], + ["/external/v1/sessions/session-1/diff/revision-1/files/file-1", "?limit=1&extra=true"], + ["/external/v1/sessions/session-1/pull-requests/pr-1", "?offset=0"], + ["/external/v1/sessions/session-1/artifacts/shot-1/content", "?download=true"], + ] as const; + + for (const [path, query] of cases) { + const { candidate, match } = route("GET", path); + const response = await candidate.handler( + new Request(`https://test.local${path}${query}`), + env, + match, + createContext(db) + ); + expect(response.status, `${path}${query}`).toBe(400); + await expect(response.json()).resolves.toEqual({ error: "Invalid query parameters" }); + } + expect(runtimeFetch).not.toHaveBeenCalled(); + }); + + it("strips storage-only artifact metadata and pins diff file reads to the route revision", async () => { + const requests: Request[] = []; + const env = createEnv(async (request) => { + requests.push(request); + const url = new URL(request.url); + if (url.pathname === SessionInternalPaths.artifacts) { + return Response.json({ + artifacts: [ + { + id: "shot-1", + type: "screenshot", + url: "/media/shot-1", + metadata: { + objectKey: "private/session/shot-1.png", + mimeType: "image/png", + sizeBytes: 12, + caption: "Result", + encryptedToken: "secret", + }, + createdAt: 10, + }, + ], + hasMore: false, + }); + } + return new Response("diff --git a/file.ts b/file.ts", { + headers: { "Content-Type": "text/x-diff" }, + }); + }); + const db = createDb({ sessions: { "session-1": sessionRow("session-1") } }); + + const artifactsPath = "/external/v1/sessions/session-1/artifacts"; + const artifactsRoute = route("GET", artifactsPath); + const artifactsResponse = await artifactsRoute.candidate.handler( + new Request(`https://test.local${artifactsPath}`), + env, + artifactsRoute.match, + createContext(db) + ); + expect(await artifactsResponse.json()).toEqual({ + artifacts: [ + { + id: "shot-1", + type: "screenshot", + url: "/external/v1/sessions/session-1/artifacts/shot-1/content", + metadata: { mimeType: "image/png", sizeBytes: 12, caption: "Result" }, + createdAt: 10, + updatedAt: 10, + }, + ], + hasMore: false, + }); + + const diffPath = "/external/v1/sessions/session-1/diff/revision-1/files/file-1"; + const diffRoute = route("GET", diffPath); + const diffResponse = await diffRoute.candidate.handler( + new Request(`https://test.local${diffPath}`), + env, + diffRoute.match, + createContext(db) + ); + expect(diffResponse.headers.get("Content-Type")).toBe("application/json"); + await expect(diffResponse.json()).resolves.toEqual({ + content: "diff --git a/file.ts b/file.ts", + truncated: false, + hasMore: false, + }); + expect(new URL(requests[1].url).search).toBe("?revisionId=revision-1&fileId=file-1"); + }); + + it("continues artifacts with an opaque runtime cursor instead of reslicing a mutated list", async () => { + const requests: Request[] = []; + const env = createEnv(async (request) => { + requests.push(request); + const cursor = new URL(request.url).searchParams.get("cursor"); + return Response.json( + cursor + ? { + artifacts: [ + { + id: "artifact-a", + type: "branch", + url: null, + metadata: null, + createdAt: 1000, + updatedAt: 1000, + }, + ], + hasMore: false, + } + : { + artifacts: [ + { + id: "artifact-c", + type: "branch", + url: null, + metadata: null, + createdAt: 1000, + updatedAt: 1000, + }, + { + id: "artifact-b", + type: "branch", + url: null, + metadata: null, + createdAt: 1000, + updatedAt: 1000, + }, + ], + cursor: "opaque-artifact-cursor", + hasMore: true, + } + ); + }); + const db = createDb({ sessions: { "session-1": sessionRow("session-1") } }); + const path = "/external/v1/sessions/session-1/artifacts"; + const { candidate, match } = route("GET", path); + + const first = await candidate.handler( + new Request(`https://test.local${path}?limit=2`), + env, + match, + createContext(db) + ); + await expect(first.json()).resolves.toMatchObject({ + artifacts: [{ id: "artifact-c" }, { id: "artifact-b" }], + cursor: "opaque-artifact-cursor", + hasMore: true, + }); + + const second = await candidate.handler( + new Request(`https://test.local${path}?limit=2&cursor=opaque-artifact-cursor`), + env, + match, + createContext(db) + ); + await expect(second.json()).resolves.toMatchObject({ + artifacts: [{ id: "artifact-a" }], + hasMore: false, + }); + expect(new URL(requests[1].url).searchParams.get("cursor")).toBe("opaque-artifact-cursor"); + }); + + it("rejects a diff file-list continuation after the current revision changes", async () => { + let revision = 1; + const diffFile = (id: string) => ({ + id, + path: `${id}.ts`, + status: "modified", + additions: 1, + deletions: 1, + renderState: "renderable", + }); + const env = createEnv(async () => { + const currentRevision = revision; + return Response.json({ + version: 1, + current: { + version: 1, + revisionId: `revision-${currentRevision}`, + triggerMessageId: null, + capturedAt: currentRevision, + repositories: [ + { + status: "ready", + position: 0, + repoOwner: "acme", + repoName: "repo", + baseSha: "a".repeat(40), + headSha: "b".repeat(40), + truncated: false, + omittedFileCount: 0, + files: currentRevision === 1 ? [diffFile("file-b"), diffFile("file-a")] : [], + }, + ], + }, + lastError: null, + unavailableReason: null, + }); + }); + const db = createDb({ sessions: { "session-1": sessionRow("session-1") } }); + const path = "/external/v1/sessions/session-1/diff"; + const { candidate, match } = route("GET", path); + + const first = await candidate.handler( + new Request(`https://test.local${path}?limit=1`), + env, + match, + createContext(db) + ); + await expect(first.json()).resolves.toMatchObject({ + current: { revisionId: "revision-1" }, + hasMore: true, + continuationOffset: 1, + continuationRevisionId: "revision-1", + }); + + revision = 2; + const response = await candidate.handler( + new Request(`https://test.local${path}?limit=1&offset=1&revisionId=revision-1`), + env, + match, + createContext(db) + ); + + expect(response.status).toBe(409); + await expect(response.json()).resolves.toEqual({ + error: "Diff revision is stale", + code: "diff_revision_stale", + currentRevisionId: "revision-2", + }); + }); + + it("pages revision-pinned diff content by UTF-8 bytes with explicit continuation", async () => { + const defaultPage = "a".repeat(256 * 1024); + const patch = `${defaultPage}€tail`; + const requests: Request[] = []; + const env = createEnv(async (request) => { + requests.push(request); + return new Response(patch, { headers: { "Content-Type": "text/x-diff" } }); + }); + const db = createDb({ sessions: { "session-1": sessionRow("session-1") } }); + const path = "/external/v1/sessions/session-1/diff/revision-1/files/file-1"; + const { candidate, match } = route("GET", path); + + const firstResponse = await candidate.handler( + new Request(`https://test.local${path}`), + env, + match, + createContext(db) + ); + expect(await firstResponse.json()).toEqual({ + content: defaultPage, + truncated: true, + hasMore: true, + continuationOffset: 256 * 1024, + }); + + const secondResponse = await candidate.handler( + new Request(`https://test.local${path}?limit=524288&offset=${256 * 1024}`), + env, + match, + createContext(db) + ); + expect(await secondResponse.json()).toEqual({ + content: "€tail", + truncated: false, + hasMore: false, + }); + expect(requests.map((request) => new URL(request.url).search)).toEqual([ + "?revisionId=revision-1&fileId=file-1", + "?revisionId=revision-1&fileId=file-1", + ]); + + const invalidLimit = await candidate.handler( + new Request(`https://test.local${path}?limit=524289`), + env, + match, + createContext(db) + ); + expect(invalidLimit.status).toBe(400); + expect(requests).toHaveLength(2); + + const splitCodePoint = await candidate.handler( + new Request(`https://test.local${path}?limit=${256 * 1024 + 1}`), + env, + match, + createContext(db) + ); + await expect(splitCodePoint.json()).resolves.toMatchObject({ + content: defaultPage, + continuationOffset: 256 * 1024, + truncated: true, + hasMore: true, + }); + + const invalidOffset = await candidate.handler( + new Request(`https://test.local${path}?offset=${256 * 1024 + 1}`), + env, + match, + createContext(db) + ); + expect(invalidOffset.status).toBe(400); + await expect(invalidOffset.json()).resolves.toEqual({ error: "Invalid diff content offset" }); + }); + + it("preserves nested repository owners and enforces direct resource relationships", async () => { + const parent = sessionRow("parent"); + const child = sessionRow("child", { + parent_session_id: "parent", + root_session_id: "parent", + spawn_source: "agent", + spawn_depth: 1, + }); + const otherChild = sessionRow("other-child", { parent_session_id: "other-parent" }); + const pullRequest = { + artifact_id: "pr-1", + session_id: "parent", + repository_external_id: "repo-9001", + repo_owner: "group/subgroup", + repo_name: "repo", + pr_number: 7, + url: "https://gitlab.example/group/subgroup/repo/-/merge_requests/7", + lifecycle_state: "open", + is_draft: 0, + head_branch: "feature", + base_branch: "main", + head_sha: "abc", + provider_created_at: 1, + provider_updated_at: 2, + merged_at: null, + closed_at: null, + created_at: 1, + updated_at: 2, + }; + const db = createDb({ + sessions: { parent, child, "other-child": otherChild }, + children: { parent: [child] }, + repositories: [ + { + session_id: "child", + position: 0, + repo_owner: "group/subgroup", + repo_name: "repo", + repo_id: 9001, + base_branch: "main", + }, + ], + pullRequests: [pullRequest], + }); + const env = createEnv(async () => Response.json({})); + + const childrenPath = "/external/v1/sessions/parent/children"; + const childrenRoute = route("GET", childrenPath); + const childrenResponse = await childrenRoute.candidate.handler( + new Request(`https://test.local${childrenPath}`), + env, + childrenRoute.match, + createContext(db) + ); + const childrenBody = await childrenResponse.json<{ + children: Array<Record<string, unknown>>; + }>(); + expect(childrenBody.children[0]).toMatchObject({ + id: "child", + repoOwner: "group/subgroup", + repositories: [{ repoOwner: "group/subgroup", repoName: "repo" }], + }); + + const wrongChildPath = "/external/v1/sessions/parent/children/other-child"; + const wrongChildRoute = route("GET", wrongChildPath); + const wrongChildResponse = await wrongChildRoute.candidate.handler( + new Request(`https://test.local${wrongChildPath}`), + env, + wrongChildRoute.match, + createContext(db) + ); + expect(wrongChildResponse.status).toBe(404); + + const pullRequestsPath = "/external/v1/sessions/parent/pull-requests"; + const pullRequestsRoute = route("GET", pullRequestsPath); + const pullRequestsResponse = await pullRequestsRoute.candidate.handler( + new Request(`https://test.local${pullRequestsPath}`), + env, + pullRequestsRoute.match, + createContext(db) + ); + await expect(pullRequestsResponse.json()).resolves.toMatchObject({ + pullRequests: [ + { + id: "pr-1", + provider: "gitlab", + repoOwner: "group/subgroup", + repoName: "repo", + number: 7, + }, + ], + }); + + const wrongPrPath = "/external/v1/sessions/child/pull-requests/pr-1"; + const wrongPrRoute = route("GET", wrongPrPath); + const wrongPrResponse = await wrongPrRoute.candidate.handler( + new Request(`https://test.local${wrongPrPath}`), + env, + wrongPrRoute.match, + createContext(db) + ); + expect(wrongPrResponse.status).toBe(404); + + const pullRequestPath = "/external/v1/sessions/parent/pull-requests/pr-1"; + const pullRequestRoute = route("GET", pullRequestPath); + const pullRequestResponse = await pullRequestRoute.candidate.handler( + new Request(`https://test.local${pullRequestPath}`), + env, + pullRequestRoute.match, + createContext(db) + ); + expect(pullRequestResponse.status).toBe(200); + await expect(pullRequestResponse.json()).resolves.toMatchObject({ + id: "pr-1", + provider: "gitlab", + repoOwner: "group/subgroup", + repoName: "repo", + number: 7, + }); + }); +}); diff --git a/packages/control-plane/src/routes/external-session-resources.ts b/packages/control-plane/src/routes/external-session-resources.ts new file mode 100644 index 0000000000..3280206de3 --- /dev/null +++ b/packages/control-plane/src/routes/external-session-resources.ts @@ -0,0 +1,642 @@ +import { listArtifactsResponseSchema } from "@open-inspect/shared/types/artifacts"; +import { externalChildPromptRequestSchema } from "@open-inspect/shared/types/external-resources-api"; +import { sendPromptResponseSchema } from "@open-inspect/shared/types/session-api"; +import { + SESSION_DIFF_ID_PATTERN, + sessionDiffStateSchema, +} from "@open-inspect/shared/types/session-diffs"; +import { messageSourceSchema } from "@open-inspect/shared/types/sessions"; +import { resolvedSessionAttachmentsSchema } from "@open-inspect/shared/types/session-attachments"; +import { z } from "zod"; +import { SessionIndexStore, type SessionEntry } from "../db/session-index"; +import { + SessionPullRequestStore, + type SessionPullRequestRecord, +} from "../db/session-pull-request-store"; +import { adaptExternalRuntimeFailure } from "../external-api/runtime-response"; +import { SessionInternalPaths } from "../session/contracts"; +import { createSessionRuntimeClient } from "../session/runtime-client"; +import { resolveScmProviderFromEnv } from "../source-control"; +import type { Env } from "../types"; +import { + SCM_AGNOSTIC_EXTERNAL_USER_ROUTE, + defineRoutes, + error, + json, + parsePattern, + requirePermission, + type Route, +} from "./shared"; +import { sessionRoute, type SessionRouteContext, type SessionRouteHandler } from "./session-route"; +import { handleAttachmentGet, handleAttachmentPost } from "./session-attachments"; +import { handleMediaGet } from "./session-media-stream"; +import { dispatchSessionPrompt } from "./session-prompt"; +import { enforceExternalRateLimit } from "./external-sessions"; + +const EXTERNAL_SESSION_PATH = "/external/v1/sessions/:id"; +const DEFAULT_MESSAGE_LIMIT = 50; +const MAX_MESSAGE_LIMIT = 100; +const DEFAULT_DIFF_CONTENT_LIMIT_BYTES = 256 * 1024; +const MAX_DIFF_CONTENT_LIMIT_BYTES = 512 * 1024; +const messageStatusSchema = z.enum(["pending", "processing", "completed", "failed"]); + +const messagePageSchema = z + .object({ + messages: z.array( + z.object({ + id: z.string(), + authorId: z.string(), + content: z.string(), + source: messageSourceSchema, + attachments: resolvedSessionAttachmentsSchema.nullable(), + status: messageStatusSchema, + createdAt: z.number(), + startedAt: z.number().nullable(), + completedAt: z.number().nullable(), + }) + ), + cursor: z.string().min(1).optional(), + hasMore: z.boolean(), + }) + .refine((page) => !page.hasMore || page.cursor !== undefined, { + message: "cursor is required when hasMore is true", + path: ["cursor"], + }); + +const artifactPageSchema = z.object({ + artifacts: listArtifactsResponseSchema.shape.artifacts, + cursor: z.string().min(1).optional(), + hasMore: z.boolean(), +}); + +function offsetPage(request: Request): { limit: number; offset: number } | Response { + const search = new URL(request.url).searchParams; + const limit = search.has("limit") ? Number(search.get("limit")) : 50; + const offset = search.has("offset") ? Number(search.get("offset")) : 0; + return Number.isSafeInteger(limit) && + limit >= 1 && + limit <= 100 && + Number.isSafeInteger(offset) && + offset >= 0 + ? { limit, offset } + : error("Invalid list pagination", 400); +} + +function withStrictQuery( + handler: SessionRouteHandler, + allowedNames: readonly string[] = [] +): SessionRouteHandler { + const allowed = new Set(allowedNames); + return async (request, env, match, ctx) => { + const seen = new Set<string>(); + for (const name of new URL(request.url).searchParams.keys()) { + if (!allowed.has(name) || seen.has(name)) return error("Invalid query parameters", 400); + seen.add(name); + } + return handler(request, env, match, ctx); + }; +} + +function slicePage<T>(items: T[], options: { limit: number; offset: number }) { + const values = items.slice(options.offset, options.offset + options.limit); + const hasMore = options.offset + values.length < items.length; + return { + values, + hasMore, + ...(hasMore ? { continuationOffset: options.offset + values.length } : {}), + }; +} + +function routeId(match: RegExpMatchArray, name: string): string | null { + const value = match.groups?.[name]; + return value?.trim() ? value : null; +} + +async function requireSession( + ctx: SessionRouteContext, + sessionId: string +): Promise<SessionEntry | Response> { + return (await new SessionIndexStore(ctx.db).get(sessionId)) ?? error("Session not found", 404); +} + +async function runtimeJson( + ctx: SessionRouteContext, + sessionId: string, + path: (typeof SessionInternalPaths)[keyof typeof SessionInternalPaths], + search?: string +): Promise<unknown | Response> { + const response = await ctx.sessionRuntime.fetch(sessionId, path, undefined, search); + const runtimeError = adaptExternalRuntimeFailure(response); + if (runtimeError) return runtimeError; + return response.json().catch(() => error("Invalid session runtime response", 502)); +} + +function pickMetadata( + metadata: Record<string, unknown> | null, + keys: readonly string[] +): Record<string, unknown> | null { + if (!metadata) return null; + const projected: Record<string, unknown> = {}; + for (const key of keys) { + if (key in metadata) projected[key] = metadata[key]; + } + return projected; +} + +function projectArtifactMetadata( + type: "pr" | "screenshot" | "video" | "preview" | "branch", + metadata: Record<string, unknown> | null +): Record<string, unknown> | null { + switch (type) { + case "pr": + return pickMetadata(metadata, [ + "number", + "lifecycleState", + "isDraft", + "head", + "base", + "headSha", + "repoOwner", + "repoName", + "repositoryExternalId", + "providerUpdatedAt", + ]); + case "screenshot": + return pickMetadata(metadata, [ + "mimeType", + "sizeBytes", + "viewport", + "sourceUrl", + "fullPage", + "annotated", + "caption", + ]); + case "video": + return pickMetadata(metadata, [ + "mimeType", + "sizeBytes", + "caption", + "durationMs", + "createdAt", + "recordingStartedAt", + "recordingEndedAt", + "dimensions", + "truncated", + "hasAudio", + "captureSurface", + "source", + "sourceUrl", + "endUrl", + ]); + case "branch": + return pickMetadata(metadata, ["mode", "head", "base", "createPrUrl", "provider"]); + case "preview": + return null; + } +} + +function projectSession(session: SessionEntry) { + return { + id: session.id, + title: session.title, + status: session.status, + model: session.model, + reasoningEffort: session.reasoningEffort, + repoOwner: session.repoOwner, + repoName: session.repoName, + repositories: + session.repositories ?? + (session.repoOwner && session.repoName + ? [ + { + repoOwner: session.repoOwner, + repoName: session.repoName, + repoId: null, + baseBranch: session.baseBranch ?? "", + }, + ] + : []), + environmentId: session.environmentId ?? null, + parentSessionId: session.parentSessionId ?? null, + createdAt: session.createdAt, + updatedAt: session.updatedAt, + }; +} + +function projectPullRequest(record: SessionPullRequestRecord, provider: string) { + return { + id: record.artifactId, + provider, + repositoryExternalId: record.repositoryExternalId, + repoOwner: record.repoOwner, + repoName: record.repoName, + number: record.prNumber, + url: record.url, + state: record.isDraft ? "draft" : record.lifecycleState, + lifecycleState: record.lifecycleState, + isDraft: record.isDraft, + headBranch: record.headBranch, + baseBranch: record.baseBranch, + headSha: record.headSha, + providerCreatedAt: record.providerCreatedAt, + providerUpdatedAt: record.providerUpdatedAt, + mergedAt: record.mergedAt, + closedAt: record.closedAt, + createdAt: record.createdAt, + updatedAt: record.updatedAt, + }; +} + +async function listMessages( + request: Request, + _env: Env, + match: RegExpMatchArray, + ctx: SessionRouteContext +): Promise<Response> { + const sessionId = routeId(match, "id"); + if (!sessionId) return error("Session ID required", 400); + if ((await requireSession(ctx, sessionId)) instanceof Response) + return error("Session not found", 404); + + const url = new URL(request.url); + const rawLimit = url.searchParams.get("limit"); + const limit = rawLimit === null ? DEFAULT_MESSAGE_LIMIT : Number(rawLimit); + if (!Number.isSafeInteger(limit) || limit < 1 || limit > MAX_MESSAGE_LIMIT) { + return error(`limit must be an integer between 1 and ${MAX_MESSAGE_LIMIT}`, 400); + } + const search = new URLSearchParams({ limit: String(limit) }); + for (const name of ["cursor", "status"] as const) { + const value = url.searchParams.get(name); + if (value !== null) search.set(name, value); + } + const body = await runtimeJson(ctx, sessionId, SessionInternalPaths.messages, `?${search}`); + if (body instanceof Response) return body; + const parsed = messagePageSchema.safeParse(body); + return parsed.success ? json(parsed.data) : error("Invalid session message response", 502); +} + +async function listArtifacts( + request: Request, + _env: Env, + match: RegExpMatchArray, + ctx: SessionRouteContext +): Promise<Response> { + const sessionId = routeId(match, "id"); + if (!sessionId) return error("Session ID required", 400); + if ((await requireSession(ctx, sessionId)) instanceof Response) + return error("Session not found", 404); + const url = new URL(request.url); + const rawLimit = url.searchParams.get("limit"); + const limit = rawLimit === null ? 50 : Number(rawLimit); + if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100) { + return error("limit must be an integer between 1 and 100", 400); + } + const cursor = url.searchParams.get("cursor"); + const search = `?${new URLSearchParams({ limit: String(limit), ...(cursor ? { cursor } : {}) })}`; + const body = await runtimeJson(ctx, sessionId, SessionInternalPaths.artifacts, search); + if (body instanceof Response) return body; + const parsed = artifactPageSchema.safeParse(body); + if (!parsed.success) return error("Invalid session artifact response", 502); + return json({ + artifacts: parsed.data.artifacts.map((artifact) => ({ + id: artifact.id, + type: artifact.type, + url: + artifact.type === "screenshot" || artifact.type === "video" + ? `/external/v1/sessions/${encodeURIComponent(sessionId)}/artifacts/${encodeURIComponent(artifact.id)}/content` + : artifact.url, + metadata: projectArtifactMetadata(artifact.type, artifact.metadata), + createdAt: artifact.createdAt, + updatedAt: artifact.updatedAt ?? artifact.createdAt, + })), + hasMore: parsed.data.hasMore, + ...(parsed.data.cursor ? { cursor: parsed.data.cursor } : {}), + }); +} + +async function getDiffState( + request: Request, + _env: Env, + match: RegExpMatchArray, + ctx: SessionRouteContext +): Promise<Response> { + const sessionId = routeId(match, "id"); + if (!sessionId) return error("Session ID required", 400); + if ((await requireSession(ctx, sessionId)) instanceof Response) + return error("Session not found", 404); + const body = await runtimeJson(ctx, sessionId, SessionInternalPaths.diffState); + if (body instanceof Response) return body; + const parsed = sessionDiffStateSchema.safeParse(body); + if (!parsed.success) return error("Invalid session diff response", 502); + const url = new URL(request.url); + const limit = url.searchParams.has("limit") ? Number(url.searchParams.get("limit")) : 50; + const offset = url.searchParams.has("offset") ? Number(url.searchParams.get("offset")) : 0; + const continuationRevisionId = url.searchParams.get("revisionId"); + if ( + !Number.isSafeInteger(limit) || + limit < 1 || + limit > 100 || + !Number.isSafeInteger(offset) || + offset < 0 + ) { + return error("Invalid diff pagination", 400); + } + if (!parsed.data.current) return json({ ...parsed.data, hasMore: false }); + if (offset > 0 && continuationRevisionId === null) { + return error("revisionId is required for diff continuation", 400); + } + if ( + continuationRevisionId !== null && + continuationRevisionId !== parsed.data.current.revisionId + ) { + return json( + { + error: "Diff revision is stale", + code: "diff_revision_stale", + currentRevisionId: parsed.data.current.revisionId, + }, + 409 + ); + } + let remainingOffset = offset; + let remainingLimit = limit; + let totalFiles = 0; + const repositories = parsed.data.current.repositories.map((repository) => { + if (repository.status !== "ready") return repository; + totalFiles += repository.files.length; + const start = Math.min(remainingOffset, repository.files.length); + remainingOffset -= start; + const files = repository.files.slice(start, start + remainingLimit); + remainingLimit -= files.length; + return { ...repository, files }; + }); + const returned = limit - remainingLimit; + const hasMore = offset + returned < totalFiles; + return json({ + ...parsed.data, + current: { ...parsed.data.current, repositories }, + hasMore, + ...(hasMore ? { continuationOffset: offset + returned } : {}), + ...(hasMore ? { continuationRevisionId: parsed.data.current.revisionId } : {}), + }); +} + +async function getDiffFile( + request: Request, + _env: Env, + match: RegExpMatchArray, + ctx: SessionRouteContext +): Promise<Response> { + const sessionId = routeId(match, "id"); + const revisionId = routeId(match, "revisionId"); + const fileId = routeId(match, "fileId"); + if ( + !sessionId || + !revisionId || + !fileId || + !SESSION_DIFF_ID_PATTERN.test(revisionId) || + !SESSION_DIFF_ID_PATTERN.test(fileId) + ) { + return error("Invalid diff file identity", 400); + } + const search = new URL(request.url).searchParams; + const limit = search.has("limit") + ? Number(search.get("limit")) + : DEFAULT_DIFF_CONTENT_LIMIT_BYTES; + const offset = search.has("offset") ? Number(search.get("offset")) : 0; + if ( + !Number.isSafeInteger(limit) || + limit < 1 || + limit > MAX_DIFF_CONTENT_LIMIT_BYTES || + !Number.isSafeInteger(offset) || + offset < 0 + ) { + return error("Invalid diff content pagination", 400); + } + if ((await requireSession(ctx, sessionId)) instanceof Response) + return error("Session not found", 404); + const response = await ctx.sessionRuntime.fetch( + sessionId, + SessionInternalPaths.diffResolveFile, + undefined, + `?revisionId=${encodeURIComponent(revisionId)}&fileId=${encodeURIComponent(fileId)}` + ); + const runtimeError = adaptExternalRuntimeFailure(response); + if (runtimeError) return runtimeError; + + const bytes = new Uint8Array(await response.arrayBuffer()); + if (offset > bytes.byteLength) return error("Invalid diff content offset", 400); + + const decoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }); + try { + decoder.decode(bytes); + } catch { + return error("Invalid session diff content", 502); + } + try { + decoder.decode(bytes.subarray(0, offset)); + } catch { + return error("Invalid diff content offset", 400); + } + + let end = Math.min(offset + limit, bytes.byteLength); + let content: string | null = null; + while (content === null && end >= offset) { + try { + content = decoder.decode(bytes.subarray(offset, end)); + } catch { + end -= 1; + } + } + if (content === null) return error("Invalid session diff content", 502); + + const hasMore = end < bytes.byteLength; + return json({ + content, + truncated: hasMore, + hasMore, + ...(hasMore ? { continuationOffset: end } : {}), + }); +} + +async function listPullRequests( + request: Request, + env: Env, + match: RegExpMatchArray, + ctx: SessionRouteContext +): Promise<Response> { + const sessionId = routeId(match, "id"); + if (!sessionId) return error("Session ID required", 400); + if ((await requireSession(ctx, sessionId)) instanceof Response) + return error("Session not found", 404); + const records = await new SessionPullRequestStore(ctx.db).listBySession(sessionId); + const provider = resolveScmProviderFromEnv(env.SCM_PROVIDER); + const pagination = offsetPage(request); + if (pagination instanceof Response) return pagination; + const page = slicePage(records, pagination); + return json({ + pullRequests: page.values.map((record) => projectPullRequest(record, provider)), + hasMore: page.hasMore, + ...(page.continuationOffset === undefined + ? {} + : { continuationOffset: page.continuationOffset }), + }); +} + +async function getPullRequest( + _request: Request, + env: Env, + match: RegExpMatchArray, + ctx: SessionRouteContext +): Promise<Response> { + const sessionId = routeId(match, "id"); + const pullRequestId = routeId(match, "pullRequestId"); + if (!sessionId || !pullRequestId) return error("Session and pull request IDs required", 400); + if ((await requireSession(ctx, sessionId)) instanceof Response) + return error("Session not found", 404); + const record = await new SessionPullRequestStore(ctx.db).getByArtifactId(pullRequestId); + if (!record || record.sessionId !== sessionId) return error("Pull request not found", 404); + return json(projectPullRequest(record, resolveScmProviderFromEnv(env.SCM_PROVIDER))); +} + +async function listChildren( + request: Request, + _env: Env, + match: RegExpMatchArray, + ctx: SessionRouteContext +): Promise<Response> { + const parentId = routeId(match, "id"); + if (!parentId) return error("Parent session ID required", 400); + if ((await requireSession(ctx, parentId)) instanceof Response) + return error("Parent session not found", 404); + const children = await new SessionIndexStore(ctx.db).listByParent(parentId); + const pagination = offsetPage(request); + if (pagination instanceof Response) return pagination; + const page = slicePage(children, pagination); + return json({ + children: page.values.map(projectSession), + hasMore: page.hasMore, + ...(page.continuationOffset === undefined + ? {} + : { continuationOffset: page.continuationOffset }), + }); +} + +async function getChild( + _request: Request, + _env: Env, + match: RegExpMatchArray, + ctx: SessionRouteContext +): Promise<Response> { + const parentId = routeId(match, "id"); + const childId = routeId(match, "childId"); + if (!parentId || !childId) return error("Parent and child session IDs required", 400); + if ((await requireSession(ctx, parentId)) instanceof Response) + return error("Parent session not found", 404); + const child = (await new SessionIndexStore(ctx.db).listByParent(parentId)).find( + (candidate) => candidate.id === childId + ); + return child ? json(projectSession(child)) : error("Child session not found", 404); +} + +async function promptChild( + request: Request, + env: Env, + match: RegExpMatchArray, + ctx: SessionRouteContext +): Promise<Response> { + const rateLimit = await enforceExternalRateLimit(request, ctx, "mutation"); + if (rateLimit) return rateLimit; + const parentId = routeId(match, "id"); + const childId = routeId(match, "childId"); + if (!parentId || !childId) return error("Parent and child session IDs required", 400); + const child = (await new SessionIndexStore(ctx.db).listByParent(parentId)).find( + (candidate) => candidate.id === childId + ); + if (!child) return error("Child session not found", 404); + const body = await request.json().catch(() => null); + const parsed = externalChildPromptRequestSchema.safeParse(body); + if (!parsed.success) return error("Invalid child prompt request", 400); + const response = await dispatchSessionPrompt( + { ...ctx, sessionRuntime: createSessionRuntimeClient(env, ctx) }, + childId, + { + content: parsed.data.content, + authorId: ctx.principal?.kind === "user" ? ctx.principal.userId : "anonymous", + canonicalUserId: ctx.principal?.kind === "user" ? ctx.principal.userId : undefined, + source: "extension", + clientRequestId: parsed.data.clientRequestId, + } + ); + if (!response.ok) return response; + return json(sendPromptResponseSchema.parse(await response.json())); +} + +const readAuthorization = requirePermission("sessions.read", { service: "deny" }); +const resources: Array<{ + suffix: string; + handler: SessionRouteHandler; + query?: readonly string[]; +}> = [ + { suffix: "/messages", handler: listMessages, query: ["limit", "cursor", "status"] }, + { suffix: "/artifacts", handler: listArtifacts, query: ["limit", "cursor"] }, + { suffix: "/diff", handler: getDiffState, query: ["limit", "offset", "revisionId"] }, + { + suffix: "/diff/:revisionId/files/:fileId", + handler: getDiffFile, + query: ["limit", "offset"], + }, + { suffix: "/pull-requests", handler: listPullRequests, query: ["limit", "offset"] }, + { suffix: "/pull-requests/:pullRequestId", handler: getPullRequest }, + { suffix: "/children", handler: listChildren, query: ["limit", "offset"] }, + { suffix: "/children/:childId", handler: getChild }, +]; + +export const externalSessionResourceRoutes: Route[] = defineRoutes( + SCM_AGNOSTIC_EXTERNAL_USER_ROUTE, + resources.map(({ suffix, handler, query }) => + sessionRoute({ + method: "GET", + pattern: parsePattern(`${EXTERNAL_SESSION_PATH}${suffix}`), + authorization: readAuthorization, + cacheControl: "private, no-store", + handler: withStrictQuery(handler, query), + }) + ) +); + +externalSessionResourceRoutes.push( + ...defineRoutes(SCM_AGNOSTIC_EXTERNAL_USER_ROUTE, [ + sessionRoute({ + method: "POST", + pattern: parsePattern(`${EXTERNAL_SESSION_PATH}/attachments`), + authorization: requirePermission("sessions.collaborate", { service: "deny" }), + cacheControl: "private, no-store", + handler: withStrictQuery(async (request, env, match, ctx) => { + const rateLimit = await enforceExternalRateLimit(request, ctx, "mutation"); + return rateLimit ?? handleAttachmentPost(request, env, match, ctx); + }), + }), + sessionRoute({ + method: "GET", + pattern: parsePattern(`${EXTERNAL_SESSION_PATH}/attachments/:attachmentId`), + authorization: readAuthorization, + cacheControl: "private, no-store", + handler: withStrictQuery(handleAttachmentGet), + }), + sessionRoute({ + method: "POST", + pattern: parsePattern(`${EXTERNAL_SESSION_PATH}/children/:childId/messages`), + authorization: requirePermission("sessions.collaborate", { service: "deny" }), + cacheControl: "private, no-store", + handler: withStrictQuery(promptChild), + }), + sessionRoute({ + method: "GET", + pattern: parsePattern(`${EXTERNAL_SESSION_PATH}/artifacts/:artifactId/content`), + authorization: readAuthorization, + cacheControl: "private, no-store", + handler: withStrictQuery(handleMediaGet), + }), + ]) +); diff --git a/packages/control-plane/src/routes/external-sessions.ts b/packages/control-plane/src/routes/external-sessions.ts new file mode 100644 index 0000000000..ed212585f9 --- /dev/null +++ b/packages/control-plane/src/routes/external-sessions.ts @@ -0,0 +1,944 @@ +import { + externalCreateSessionRequestSchema, + externalCreateSessionResponseSchema, + externalFollowUpRequestSchema, + externalEventFeedQuerySchema, + externalSessionListQuerySchema, + externalStopSessionResponseSchema, + type ExternalCreateSessionRequest, + type ExternalCreateSessionResponse, + type ExternalSession, +} from "@open-inspect/shared/types/external-session-api"; +import { sendPromptResponseSchema } from "@open-inspect/shared/types/session-api"; +import { isSessionInactive } from "@open-inspect/shared/types/session-activity"; +import type { RepositoryRef } from "@open-inspect/shared/types/repositories"; +import type { SessionAttachmentReference } from "@open-inspect/shared/types/session-attachments"; +import { listArtifactsResponseSchema } from "@open-inspect/shared/types/artifacts"; +import { buildAgentResponseFromEvents } from "@open-inspect/shared/completion/extractor"; +import { listEventsResponseSchema } from "@open-inspect/shared/types/sandbox-events"; +import { hashToken, hmacToken } from "../auth/crypto"; +import { EnvironmentStore } from "../db/environments"; +import { EnvironmentSecretsStore } from "../db/environment-secrets"; +import { GlobalSecretsStore } from "../db/global-secrets"; +import { RepoSecretsStore } from "../db/repo-secrets"; +import { SessionIndexStore, type SessionEntry } from "../db/session-index"; +import { SessionPullRequestStore } from "../db/session-pull-request-store"; +import { listManagedSecretHistory } from "../db/managed-secret-redaction-history"; +import { CliAuthStore } from "../db/cli-auth-store"; +import { McpServerStore } from "../db/mcp-servers"; +import { ProviderCredentialStore } from "../db/provider-account-credentials"; +import { decryptToken } from "../auth/crypto"; +import { decryptProviderAccountPayload } from "../auth/provider-account-crypto"; +import type { ModelProviderId } from "../model-provider-accounts/provider-auth-contracts"; +import { getEffectiveEnabledModels } from "../db/model-preferences"; +import { createLogger } from "../logger"; +import { ProviderAccountSelectionPolicyError } from "../model-provider-accounts/selection-policy"; +import { resolveEnvironmentTarget, resolveSessionRepositories } from "../repos/resolve"; +import { initializeSession, type SessionInitInput } from "../session/initialize"; +import { resolveSessionScopedSettings } from "../session/integration-settings-resolution"; +import { resolveSessionProviderAuth } from "../session/provider-account-resolution"; +import { resolveManagedSkills, SkillResolutionError } from "../session/skill-resolution"; +import { + SessionInternalPaths, + sessionBootstrapEnsureResponseSchema, + sessionEventChangePageSchema, +} from "../session/contracts"; +import { createSessionRuntimeClient } from "../session/runtime-client"; +import { projectExternalEventPage } from "../external-api/event-projection"; +import { adaptExternalRuntimeFailure } from "../external-api/runtime-response"; +import type { Env } from "../types"; +import { requireExternalSessionIdSecret } from "../env-validation"; +import { + SCM_AGNOSTIC_EXTERNAL_USER_ROUTE, + defineRoutes, + error, + json, + parseJsonBody, + parsePattern, + resolveRepoOrError, + requirePermission, + type Route, + type UserRouteContext, +} from "./shared"; +import { sessionRoute, type SessionRouteContext } from "./session-route"; +import { admitPromptModel, dispatchSessionPrompt } from "./session-prompt"; +import { authorizeSessionTarget } from "./session-target-authorization"; + +const EXTERNAL_SESSIONS_PATH = "/external/v1/sessions"; +const BRANCH_NAME_PATTERN = /^[\w.\-/]+$/; +const RATE_LIMIT_WINDOW_MS = 60_000; +const logger = createLogger("router:external-sessions"); + +type ExternalBootstrapSnapshot = Omit< + SessionInitInput, + | "providerAuth" + | "managedSkillsManifest" + | "managedSkillsSourceSessionId" + | "externalBootstrapSnapshot" +> & { requestFingerprint: string }; + +function projectSession( + session: SessionEntry, + sandboxStatus?: string | null, + webAppUrl?: string +): ExternalSession { + const base = `${EXTERNAL_SESSIONS_PATH}/${encodeURIComponent(session.id)}`; + return { + id: session.id, + title: session.title, + model: session.model, + reasoningEffort: session.reasoningEffort, + status: session.status, + repoOwner: session.repoOwner, + repoName: session.repoName, + repositories: + session.repositories ?? + (session.repoOwner && session.repoName + ? [ + { + repoOwner: session.repoOwner, + repoName: session.repoName, + repoId: null, + baseBranch: session.baseBranch ?? "", + }, + ] + : []), + environmentId: session.environmentId ?? null, + parentSessionId: session.parentSessionId ?? null, + creatorId: session.userId ?? null, + archived: session.status === "archived", + url: `${webAppUrl?.replace(/\/$/, "") ?? ""}/sessions/${encodeURIComponent(session.id)}`, + ...(sandboxStatus === undefined ? {} : { sandboxStatus }), + resources: { + messages: `${base}/messages`, + events: `${base}/events`, + artifacts: `${base}/artifacts`, + diff: `${base}/diff`, + pullRequests: `${base}/pull-requests`, + children: `${base}/children`, + }, + createdAt: session.createdAt, + updatedAt: session.updatedAt, + }; +} + +async function externalSession( + ctx: UserRouteContext | SessionRouteContext, + sessionId: string +): Promise<SessionEntry | Response> { + return (await new SessionIndexStore(ctx.db).get(sessionId)) ?? error("Session not found", 404); +} + +async function dispatchExternalPrompt( + ctx: SessionRouteContext, + sessionId: string, + input: { + content: string; + attachments?: SessionAttachmentReference[]; + model?: string; + reasoningEffort?: string; + clientRequestId: string; + }, + preAdmittedModel?: { model: string; reasoningEffort?: string } +): Promise<Response> { + return dispatchSessionPrompt( + ctx, + sessionId, + { + content: input.content, + attachments: input.attachments, + authorId: ctx.principal?.kind === "user" ? ctx.principal.userId : "anonymous", + canonicalUserId: ctx.principal?.kind === "user" ? ctx.principal.userId : undefined, + source: "extension", + model: input.model, + reasoningEffort: input.reasoningEffort, + clientRequestId: input.clientRequestId, + }, + (response) => adaptExternalRuntimeFailure(response) ?? response, + preAdmittedModel + ); +} + +async function ensureExternalSessionRuntime( + env: Env, + ctx: UserRouteContext, + session: SessionEntry, + input: ExternalBootstrapSnapshot +): Promise<Response | null> { + const reservationError = validateExternalSessionReservation(session, ctx.principal.userId, input); + if (reservationError) return reservationError; + + const runtime = createSessionRuntimeClient(env, ctx); + const response = await runtime.fetch(session.id, SessionInternalPaths.ensureBootstrap, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + sessionName: session.id, + repoOwner: input.repoOwner, + repoName: input.repoName, + repoId: input.repoId, + defaultBranch: input.defaultBranch, + branch: input.branch, + repositories: input.repositories ?? [], + environmentId: input.environmentId ?? null, + title: input.title, + model: input.model, + reasoningEffort: input.reasoningEffort, + userId: input.participantUserId, + canonicalUserId: input.platformUserId, + scmLogin: input.scmLogin, + scmName: input.scmName, + scmEmail: input.scmEmail, + scmUserId: input.scmUserId, + scmTokenEncrypted: input.scmTokenEncrypted, + scmRefreshTokenEncrypted: input.scmRefreshTokenEncrypted, + scmTokenExpiresAt: input.scmTokenExpiresAt, + codeServerEnabled: input.codeServerEnabled, + vncEnabled: input.vncEnabled, + sandboxSettings: input.sandboxSettings, + requestFingerprint: input.requestFingerprint, + }), + }); + const runtimeError = adaptExternalRuntimeFailure(response); + if (runtimeError) return runtimeError; + const ensured = sessionBootstrapEnsureResponseSchema.parse(await response.json()); + await new SessionIndexStore(ctx.db).updateStatus(session.id, ensured.sessionStatus); + return null; +} + +function permissionError(permission: string): Response { + return json({ error: "Forbidden", code: "permission_required", permission }, 403); +} + +function hasPermission(ctx: UserRouteContext, permission: string): boolean { + return Boolean(ctx.authorization?.permissions.includes(permission as never)); +} + +export async function enforceExternalRateLimit( + request: Request, + ctx: UserRouteContext | SessionRouteContext, + bucket: "create" | "mutation" | "events" +): Promise<Response | null> { + const limits = { create: 30, mutation: 120, events: 600 } as const; + const userId = ctx.principal?.kind === "user" ? ctx.principal.userId : "unknown"; + const result = await new CliAuthStore(ctx.db).consumeRateLimit({ + key: `external:${bucket}:${userId}`, + now: Date.now(), + windowMs: RATE_LIMIT_WINDOW_MS, + limit: limits[bucket], + }); + if (result.allowed) return null; + const response = json({ error: "Rate limit exceeded", code: "rate_limited" }, 429); + response.headers.set("Retry-After", String(Math.ceil(result.retryAfterMs / 1_000))); + return response; +} + +async function prepareExternalSession( + env: Env, + ctx: UserRouteContext, + sessionId: string, + input: ExternalCreateSessionRequest, + requestFingerprint: string +): Promise<(SessionInitInput & { requestFingerprint: string }) | Response> { + let repositories: RepositoryRef[] = []; + let primaryDefaultBranch: string | null = null; + if (input.environmentId) { + const members = await resolveEnvironmentTarget( + new EnvironmentStore(ctx.db), + input.environmentId + ); + repositories = await resolveSessionRepositories(env, members, ctx, logger); + } else if (input.repositories) { + repositories = await resolveSessionRepositories(env, input.repositories, ctx, logger); + } else if (input.repoOwner && input.repoName) { + const resolved = await resolveRepoOrError(env, input.repoOwner, input.repoName, ctx, logger); + primaryDefaultBranch = resolved.defaultBranch; + repositories = [ + { + repoOwner: input.repoOwner, + repoName: input.repoName, + repoId: resolved.repoId, + baseBranch: input.branch ?? resolved.defaultBranch, + }, + ]; + } + const primary = repositories[0]; + const enabledModels = await getEffectiveEnabledModels(ctx.db).catch(() => null); + if (!enabledModels?.length) return error("Model preferences unavailable", 503); + const admission = await admitPromptModel(ctx, { + model: input.model ?? enabledModels[0], + reasoningEffort: input.reasoningEffort, + }); + if (admission instanceof Response) return admission; + const scopeMembers = repositories.map(({ repoOwner, repoName }) => ({ repoOwner, repoName })); + const { codeServerEnabled, vncEnabled, sandboxSettings } = await resolveSessionScopedSettings( + ctx.db, + scopeMembers, + input.environmentId ?? null + ); + let providerAuth; + try { + providerAuth = await resolveSessionProviderAuth(ctx.db, { + explicit: input.providerSelections, + unattended: false, + }); + } catch (cause) { + if (cause instanceof ProviderAccountSelectionPolicyError) + return error(cause.message, cause.status); + throw cause; + } + let managedSkillsManifest; + try { + managedSkillsManifest = await resolveManagedSkills( + ctx.db, + { repositories: scopeMembers, environmentId: input.environmentId ?? null }, + input.skillSelection ?? { mode: "all" }, + ctx.principal.userId + ); + } catch (cause) { + if (cause instanceof SkillResolutionError) return error(cause.message, cause.status); + throw cause; + } + const prepared: SessionInitInput & { requestFingerprint: string } = { + sessionId, + repoOwner: primary?.repoOwner ?? null, + repoName: primary?.repoName ?? null, + repoId: primary?.repoId ?? null, + defaultBranch: primaryDefaultBranch ?? primary?.baseBranch ?? null, + branch: input.repoOwner && input.repoName ? (input.branch ?? null) : null, + repositories, + environmentId: input.environmentId ?? null, + title: input.title, + model: admission.model, + reasoningEffort: admission.reasoningEffort ?? null, + codeServerEnabled, + vncEnabled, + sandboxSettings, + participantUserId: ctx.principal.userId, + platformUserId: ctx.principal.userId, + scmTokenEncrypted: null, + scmRefreshTokenEncrypted: null, + providerAuth, + managedSkillsManifest, + requestFingerprint, + }; + prepared.externalBootstrapSnapshot = JSON.stringify(toExternalBootstrapSnapshot(prepared)); + return prepared; +} + +function authorizeExternalSessionCreate( + ctx: UserRouteContext, + input: ExternalCreateSessionRequest +): Response | null { + const hasRepository = Boolean(input.repoOwner || input.repositories); + const targetError = authorizeSessionTarget(ctx, { + environmentId: input.environmentId, + hasRepository, + }); + if (targetError) return targetError; + if ( + (input.initialPrompt !== undefined || + input.initialAttachments?.length || + input.initialAttachmentCount) && + !hasPermission(ctx, "sessions.collaborate") + ) { + return permissionError("sessions.collaborate"); + } + const skillSelection = input.skillSelection ?? { mode: "all" as const }; + if (skillSelection.mode !== "none" && !hasPermission(ctx, "skills.read")) { + return permissionError("skills.read"); + } + if (skillSelection.mode === "profile" && !hasPermission(ctx, "skill_profiles.manage_own")) { + return permissionError("skill_profiles.manage_own"); + } + if (input.providerSelections && !hasPermission(ctx, "provider_accounts.read")) { + return permissionError("provider_accounts.read"); + } + if (input.branch && !BRANCH_NAME_PATTERN.test(input.branch)) { + return error("Invalid branch name", 400); + } + return null; +} + +function toExternalBootstrapSnapshot( + input: SessionInitInput & { requestFingerprint: string } +): ExternalBootstrapSnapshot { + const { + providerAuth: _providerAuth, + managedSkillsManifest: _managedSkillsManifest, + managedSkillsSourceSessionId: _managedSkillsSourceSessionId, + externalBootstrapSnapshot: _externalBootstrapSnapshot, + ...snapshot + } = input; + return snapshot; +} + +function readExternalBootstrapSnapshot( + session: SessionEntry, + userId: string, + requestFingerprint: string +): ExternalBootstrapSnapshot { + let snapshot: ExternalBootstrapSnapshot; + try { + snapshot = JSON.parse(session.externalBootstrapSnapshot ?? "") as ExternalBootstrapSnapshot; + } catch { + throw new Error(`External bootstrap snapshot is unavailable for session ${session.id}`); + } + if ( + !snapshot || + snapshot.sessionId !== session.id || + snapshot.requestFingerprint !== requestFingerprint || + snapshot.participantUserId !== userId || + snapshot.platformUserId !== userId || + typeof snapshot.model !== "string" || + !Array.isArray(snapshot.repositories) + ) { + throw new Error(`External bootstrap snapshot is invalid for session ${session.id}`); + } + return snapshot; +} + +async function currentManagedSecretValues( + env: Env, + ctx: UserRouteContext | SessionRouteContext, + session: Pick<SessionEntry, "id" | "repositories" | "environmentId"> +): Promise<string[]> { + const encryptionKey = env.REPO_SECRETS_ENCRYPTION_KEY; + if (!encryptionKey) return []; + const records = await Promise.all([ + new GlobalSecretsStore(ctx.db, encryptionKey).getDecryptedSecrets(), + ...(session.repositories ?? []).map(({ repoId }) => + repoId === null + ? Promise.resolve({}) + : new RepoSecretsStore(ctx.db, encryptionKey).getDecryptedSecrets(repoId) + ), + ...(!session.environmentId + ? [] + : [ + new EnvironmentSecretsStore(ctx.db, encryptionKey).getDecryptedSecrets( + session.environmentId + ), + ]), + ]); + const values = records.flatMap((record) => Object.values(record)); + const repositories = (session.repositories ?? []).map(({ repoOwner, repoName }) => ({ + repoOwner, + repoName, + })); + const mcpServers = await new McpServerStore(ctx.db, encryptionKey).getDecryptedForSession( + repositories + ); + values.push(...mcpServers.flatMap(({ env: serverEnv }) => Object.values(serverEnv ?? {}))); + const mcpHistory = await ctx.db + .prepare("SELECT encrypted_env FROM mcp_credential_redaction_history") + .all<{ encrypted_env: string }>(); + for (const { encrypted_env } of mcpHistory.results ?? []) { + collectStrings(JSON.parse(await decryptToken(encrypted_env, encryptionKey)), values); + } + + const scmTokens = await ctx.db + .prepare("SELECT access_token_encrypted, refresh_token_encrypted FROM user_scm_tokens") + .all<{ access_token_encrypted: string; refresh_token_encrypted: string }>(); + for (const row of scmTokens.results ?? []) { + values.push( + await decryptToken(row.access_token_encrypted, env.TOKEN_ENCRYPTION_KEY), + await decryptToken(row.refresh_token_encrypted, env.TOKEN_ENCRYPTION_KEY) + ); + } + const scmHistory = await ctx.db + .prepare( + "SELECT access_token_encrypted, refresh_token_encrypted FROM scm_credential_redaction_history" + ) + .all<{ access_token_encrypted: string; refresh_token_encrypted: string }>(); + for (const row of scmHistory.results ?? []) { + values.push( + await decryptToken(row.access_token_encrypted, env.TOKEN_ENCRYPTION_KEY), + await decryptToken(row.refresh_token_encrypted, env.TOKEN_ENCRYPTION_KEY) + ); + } + + const providerBindings = await ctx.db + .prepare( + `SELECT provider, provider_account_id + FROM session_model_provider_auth + WHERE session_id = ? AND provider_account_id IS NOT NULL` + ) + .bind(session.id) + .all<{ provider: ModelProviderId; provider_account_id: string }>(); + const providerStore = new ProviderCredentialStore(ctx.db, env.PROVIDER_ACCOUNTS_ENCRYPTION_KEY); + for (const binding of providerBindings.results ?? []) { + const state = await providerStore.readCredentialState( + binding.provider_account_id, + binding.provider + ); + if (state) collectStrings(state.payload, values); + } + const providerHistory = await ctx.db + .prepare( + `SELECT history.provider_account_id, history.provider, + history.credential_schema_version, history.encrypted_payload + FROM provider_credential_redaction_history history + JOIN session_model_provider_auth binding + ON binding.provider_account_id = history.provider_account_id + AND binding.provider = history.provider + WHERE binding.session_id = ?` + ) + .bind(session.id) + .all<{ + provider_account_id: string; + provider: ModelProviderId; + credential_schema_version: number; + encrypted_payload: string; + }>(); + for (const row of providerHistory.results ?? []) { + collectStrings( + await decryptProviderAccountPayload( + row.encrypted_payload, + env.PROVIDER_ACCOUNTS_ENCRYPTION_KEY, + { + providerAccountId: row.provider_account_id, + provider: row.provider, + credentialSchemaVersion: row.credential_schema_version, + } + ), + values + ); + } + return values; +} + +function collectStrings(value: unknown, target: string[]): void { + if (typeof value === "string") target.push(value); + else if (Array.isArray(value)) value.forEach((entry) => collectStrings(entry, target)); + else if (value && typeof value === "object") + Object.values(value).forEach((entry) => collectStrings(entry, target)); +} + +function validateExternalSessionReservation( + session: SessionEntry, + userId: string, + input: { requestFingerprint: string } +): Response | null { + return session.externalRequestFingerprint !== input.requestFingerprint || + session.userId !== userId + ? error("Idempotency key conflict", 409) + : null; +} + +export async function deriveExternalSessionId( + userId: string, + idempotencyKey: string, + secret: string +): Promise<string> { + const digest = await hmacToken( + `open-inspect.external-session-id.v1\0${userId}\0${idempotencyKey}`, + secret + ); + return `external-${digest.slice(0, 32)}`; +} + +/** Creates a target-aware session and resumes deterministic retries after partial initialization. */ +async function createExternalSession( + request: Request, + env: Env, + _match: RegExpMatchArray, + ctx: UserRouteContext +): Promise<Response> { + const rateLimit = await enforceExternalRateLimit(request, ctx, "create"); + if (rateLimit) return rateLimit; + const raw = await parseJsonBody<unknown>(request); + if (raw instanceof Response) return raw; + const parsed = externalCreateSessionRequestSchema.safeParse(raw); + if (!parsed.success) return error("Invalid external session request body", 400); + const input = parsed.data; + + let installationKey: string; + try { + installationKey = requireExternalSessionIdSecret(env); + } catch { + return error("External session identity unavailable", 503); + } + const requestFingerprint = await hashToken(JSON.stringify(input)); + const sessionId = await deriveExternalSessionId( + ctx.principal.userId, + input.idempotencyKey, + installationKey + ); + const sessionStore = new SessionIndexStore(ctx.db); + let session = await sessionStore.get(sessionId); + const reservationInput = { ...input, requestFingerprint }; + if (session) { + const reservationError = validateExternalSessionReservation( + session, + ctx.principal.userId, + reservationInput + ); + if (reservationError) return reservationError; + } + + const authorizationError = authorizeExternalSessionCreate(ctx, input); + if (authorizationError) return authorizationError; + + let prepared: ExternalBootstrapSnapshot; + let created = false; + if (session) { + prepared = readExternalBootstrapSnapshot(session, ctx.principal.userId, requestFingerprint); + } else { + const resolved = await prepareExternalSession(env, ctx, sessionId, input, requestFingerprint); + if (resolved instanceof Response) return resolved; + prepared = toExternalBootstrapSnapshot(resolved); + try { + await initializeSession(env, resolved, ctx); + created = true; + } catch (cause) { + session = await sessionStore.get(sessionId); + if (!session) throw cause; + } + session ??= await sessionStore.get(sessionId); + } + if (!session) throw new Error("External session reservation was not persisted"); + const reservationError = validateExternalSessionReservation( + session, + ctx.principal.userId, + reservationInput + ); + if (reservationError) return reservationError; + if (!created) { + prepared = readExternalBootstrapSnapshot(session, ctx.principal.userId, requestFingerprint); + const runtimeError = await ensureExternalSessionRuntime(env, ctx, session, prepared); + if (runtimeError) return runtimeError; + } + let result: ExternalCreateSessionResponse = { + sessionId, + status: "created", + url: `${(env.WEB_APP_URL ?? new URL(request.url).origin).replace(/\/$/, "")}/sessions/${encodeURIComponent(sessionId)}`, + }; + if (input.initialPrompt !== undefined || input.initialAttachments?.length) { + const response = await dispatchExternalPrompt( + { ...ctx, sessionRuntime: createSessionRuntimeClient(env, ctx) }, + sessionId, + { + content: input.initialPrompt ?? "", + attachments: input.initialAttachments, + model: prepared.model, + reasoningEffort: prepared.reasoningEffort ?? undefined, + clientRequestId: `external-create:${await hashToken(`${ctx.principal.userId}:${input.idempotencyKey}`)}`, + }, + { model: prepared.model, reasoningEffort: prepared.reasoningEffort ?? undefined } + ); + if (!response.ok) { + const failure: Record<string, unknown> = await response + .json<Record<string, unknown>>() + .catch(() => ({})); + return json( + { + ...failure, + error: typeof failure.error === "string" ? failure.error : "Initial prompt failed", + sessionId, + failedStage: "prompt", + }, + response.status + ); + } + const promptResult = sendPromptResponseSchema.parse(await response.json()); + result = externalCreateSessionResponseSchema.parse({ + sessionId, + ...promptResult, + url: `${(env.WEB_APP_URL ?? new URL(request.url).origin).replace(/\/$/, "")}/sessions/${encodeURIComponent(sessionId)}`, + }); + } + return json(result, created ? 201 : 200); +} + +async function listExternalSessions( + request: Request, + env: Env, + _match: RegExpMatchArray, + ctx: UserRouteContext +): Promise<Response> { + const url = new URL(request.url); + const allowed = new Set([ + "limit", + "offset", + "status", + "excludeStatus", + "excludeAutomationLineage", + "createdBy", + ]); + if ( + [...url.searchParams.keys()].some( + (key) => !allowed.has(key) || url.searchParams.getAll(key).length !== 1 + ) + ) { + return error("Invalid external session list query", 400); + } + const automationLineage = url.searchParams.get("excludeAutomationLineage"); + if (automationLineage !== null && automationLineage !== "true" && automationLineage !== "false") { + return error("Invalid external session list query", 400); + } + const parsed = externalSessionListQuerySchema.safeParse({ + ...(url.searchParams.has("limit") ? { limit: Number(url.searchParams.get("limit")) } : {}), + ...(url.searchParams.has("offset") ? { offset: Number(url.searchParams.get("offset")) } : {}), + ...(url.searchParams.has("status") ? { status: url.searchParams.get("status") } : {}), + ...(url.searchParams.has("excludeStatus") + ? { excludeStatus: url.searchParams.get("excludeStatus") } + : {}), + ...(url.searchParams.has("excludeAutomationLineage") + ? { excludeAutomationLineage: automationLineage === "true" } + : {}), + ...(url.searchParams.has("createdBy") ? { createdBy: url.searchParams.get("createdBy") } : {}), + }); + if (!parsed.success) return error("Invalid external session list query", 400); + const offset = parsed.data.offset ?? 0; + const { createdBy, ...options } = parsed.data; + const result = await new SessionIndexStore(ctx.db).list({ + ...options, + ...(createdBy ? { createdByUserIds: [createdBy] } : {}), + }); + return json({ + sessions: result.sessions.map((session) => projectSession(session, undefined, env.WEB_APP_URL)), + hasMore: result.hasMore, + ...(result.hasMore ? { continuationOffset: offset + result.sessions.length } : {}), + }); +} + +async function getExternalSession( + _request: Request, + env: Env, + match: RegExpMatchArray, + ctx: UserRouteContext +): Promise<Response> { + const sessionId = match.groups?.id; + if (!sessionId) return error("Session ID required", 400); + const session = await externalSession(ctx, sessionId); + if (session instanceof Response) return session; + const snapshot = await createSessionRuntimeClient(env, ctx).fetch( + sessionId, + SessionInternalPaths.snapshot + ); + let sandboxStatus: string | null = null; + if (snapshot.ok) { + const body = (await snapshot.json()) as { sandbox?: { status?: unknown } | null }; + sandboxStatus = typeof body.sandbox?.status === "string" ? body.sandbox.status : null; + } + return json(projectSession(session, sandboxStatus, env.WEB_APP_URL)); +} + +async function followUp( + request: Request, + _env: Env, + match: RegExpMatchArray, + ctx: SessionRouteContext +): Promise<Response> { + const rateLimit = await enforceExternalRateLimit(request, ctx, "mutation"); + if (rateLimit) return rateLimit; + const sessionId = match.groups?.id; + if (!sessionId) return error("Session ID required", 400); + const session = await externalSession(ctx, sessionId); + if (session instanceof Response) return session; + const raw = await parseJsonBody<unknown>(request); + if (raw instanceof Response) return raw; + const parsed = externalFollowUpRequestSchema.safeParse(raw); + if (!parsed.success) return error("Invalid external follow-up request body", 400); + return dispatchExternalPrompt(ctx, sessionId, { + ...parsed.data, + content: parsed.data.content ?? "", + }); +} + +async function stopExternalSession( + request: Request, + _env: Env, + match: RegExpMatchArray, + ctx: SessionRouteContext +): Promise<Response> { + const rateLimit = await enforceExternalRateLimit(request, ctx, "mutation"); + if (rateLimit) return rateLimit; + const sessionId = match.groups?.id; + if (!sessionId) return error("Session ID required", 400); + const session = await externalSession(ctx, sessionId); + if (session instanceof Response) return session; + const response = await ctx.sessionRuntime.fetch(sessionId, SessionInternalPaths.stop, { + method: "POST", + }); + const runtimeError = adaptExternalRuntimeFailure(response); + if (runtimeError) return runtimeError; + return json(externalStopSessionResponseSchema.parse(await response.json())); +} + +async function externalEvents( + request: Request, + env: Env, + match: RegExpMatchArray, + ctx: SessionRouteContext +): Promise<Response> { + const rateLimit = await enforceExternalRateLimit(request, ctx, "events"); + if (rateLimit) return rateLimit; + const sessionId = match.groups?.id; + if (!sessionId) return error("Session ID required", 400); + const session = await externalSession(ctx, sessionId); + if (session instanceof Response) return session; + const url = new URL(request.url); + const allowed = new Set(["after", "cursor", "limit"]); + if ( + [...url.searchParams.keys()].some( + (key) => !allowed.has(key) || url.searchParams.getAll(key).length !== 1 + ) + ) { + return error("Invalid external event feed query", 400); + } + const rawQuery = { + ...(url.searchParams.has("after") ? { after: Number(url.searchParams.get("after")) } : {}), + ...(url.searchParams.has("cursor") ? { cursor: url.searchParams.get("cursor") } : {}), + ...(url.searchParams.has("limit") ? { limit: Number(url.searchParams.get("limit")) } : {}), + }; + const query = externalEventFeedQuerySchema.safeParse(rawQuery); + if (!query.success) { + return error("Invalid external event feed query", 400); + } + const search = new URLSearchParams(); + if (query.data.after !== undefined) search.set("after", String(query.data.after)); + if (query.data.cursor !== undefined) search.set("cursor", query.data.cursor); + if (query.data.limit !== undefined) search.set("limit", String(query.data.limit)); + const response = await ctx.sessionRuntime.fetch( + sessionId, + SessionInternalPaths.eventChanges, + undefined, + search.size ? `?${search}` : undefined + ); + const runtimeError = adaptExternalRuntimeFailure(response); + if (runtimeError) return runtimeError; + const page = sessionEventChangePageSchema.parse(await response.json()); + if (page.changes.length === 0 || !env.REPO_SECRETS_ENCRYPTION_KEY) { + return json(projectExternalEventPage(page)); + } + const encryptionKey = env.REPO_SECRETS_ENCRYPTION_KEY; + const currentValues = await currentManagedSecretValues(env, ctx, session); + const managedSecretValues = new Set([ + ...currentValues, + ...(await listManagedSecretHistory(ctx.db, encryptionKey)), + ]); + return json(projectExternalEventPage(page, managedSecretValues)); +} + +async function waitExternalSession( + _request: Request, + env: Env, + match: RegExpMatchArray, + ctx: UserRouteContext +): Promise<Response> { + const sessionId = match.groups?.id; + if (!sessionId) return error("Session ID required", 400); + const session = await externalSession(ctx, sessionId); + if (session instanceof Response) return session; + const settled = isSessionInactive(session.status); + let artifactIds: string[] = []; + let pullRequestIds: string[] = []; + let latestAssistantMessage: { id: string; content: string; completedAt: number | null } | null = + null; + if (settled) { + const runtime = createSessionRuntimeClient(env, ctx); + const artifactResponse = await runtime.fetch(sessionId, SessionInternalPaths.artifacts); + if (artifactResponse.ok) { + const parsed = listArtifactsResponseSchema.safeParse(await artifactResponse.json()); + if (parsed.success) artifactIds = parsed.data.artifacts.map(({ id }) => id); + } + pullRequestIds = (await new SessionPullRequestStore(ctx.db).listBySession(sessionId)).map( + ({ artifactId }) => artifactId + ); + const messagesResponse = await runtime.fetch( + sessionId, + SessionInternalPaths.messages, + undefined, + "?limit=50" + ); + if (messagesResponse.ok) { + const body = (await messagesResponse.json()) as { + messages?: Array<{ + id: string; + status: string; + completedAt: number | null; + }>; + }; + const message = body.messages?.find( + ({ status }) => status === "completed" || status === "failed" + ); + if (message) { + const eventsResponse = await runtime.fetch( + sessionId, + SessionInternalPaths.events, + undefined, + `?message_id=${encodeURIComponent(message.id)}&limit=200` + ); + if (eventsResponse.ok) { + const events = listEventsResponseSchema.safeParse(await eventsResponse.json()); + if (events.success) { + latestAssistantMessage = { + id: message.id, + content: buildAgentResponseFromEvents(events.data.events, []).textContent, + completedAt: message.completedAt, + }; + } + } + } + } + } + return json({ + sessionId, + status: session.status, + settled, + ...(settled ? { latestAssistantMessage, artifactIds, pullRequestIds } : {}), + }); +} + +export const externalSessionsRoutes: Route[] = defineRoutes(SCM_AGNOSTIC_EXTERNAL_USER_ROUTE, [ + { + method: "POST", + pattern: parsePattern(EXTERNAL_SESSIONS_PATH), + authorization: requirePermission("sessions.create", { service: "deny" }), + cacheControl: "private, no-store", + handler: createExternalSession, + }, + { + method: "GET", + pattern: parsePattern(EXTERNAL_SESSIONS_PATH), + authorization: requirePermission("sessions.read", { service: "deny" }), + cacheControl: "private, no-store", + handler: listExternalSessions, + }, + { + method: "GET", + pattern: parsePattern(`${EXTERNAL_SESSIONS_PATH}/:id`), + authorization: requirePermission("sessions.read", { service: "deny" }), + cacheControl: "private, no-store", + handler: getExternalSession, + }, + sessionRoute({ + method: "POST", + pattern: parsePattern(`${EXTERNAL_SESSIONS_PATH}/:id/messages`), + authorization: requirePermission("sessions.collaborate", { service: "deny" }), + cacheControl: "private, no-store", + handler: followUp, + }), + sessionRoute({ + method: "POST", + pattern: parsePattern(`${EXTERNAL_SESSIONS_PATH}/:id/stop`), + authorization: requirePermission("sessions.lifecycle", { service: "deny" }), + cacheControl: "private, no-store", + handler: stopExternalSession, + }), + sessionRoute({ + method: "GET", + pattern: parsePattern(`${EXTERNAL_SESSIONS_PATH}/:id/events`), + authorization: requirePermission("sessions.read", { service: "deny" }), + cacheControl: "private, no-store", + handler: externalEvents, + }), + { + method: "GET", + pattern: parsePattern(`${EXTERNAL_SESSIONS_PATH}/:id/wait`), + authorization: requirePermission("sessions.read", { service: "deny" }), + cacheControl: "private, no-store", + handler: waitExternalSession, + }, +]); diff --git a/packages/control-plane/src/routes/repos.ts b/packages/control-plane/src/routes/repos.ts index b936ad41e2..95a10645e9 100644 --- a/packages/control-plane/src/routes/repos.ts +++ b/packages/control-plane/src/routes/repos.ts @@ -138,7 +138,7 @@ async function refreshReposCache( * This prevents slow API pagination from blocking the Worker * isolate and causing head-of-line blocking for other requests. */ -async function handleListRepos( +export async function handleListRepos( request: Request, env: Env, _match: RegExpMatchArray, diff --git a/packages/control-plane/src/routes/session-attachments.ts b/packages/control-plane/src/routes/session-attachments.ts index 52f1b7e3d1..ff026b10cd 100644 --- a/packages/control-plane/src/routes/session-attachments.ts +++ b/packages/control-plane/src/routes/session-attachments.ts @@ -77,7 +77,7 @@ function attachmentStorageErrorResponse(cause: SessionAttachmentStorageError): R } } -async function handleAttachmentPost( +export async function handleAttachmentPost( request: Request, env: Env, match: RegExpMatchArray, @@ -136,9 +136,25 @@ async function handleAttachmentPost( return error("Uploaded file MIME type does not match file contents", 400); } - const attachmentId = generateId(); + const idempotencyKey = request.headers.get("Idempotency-Key"); + const attachmentId = idempotencyKey + ? `attachment-${await idempotencyFingerprint(sessionId, idempotencyKey)}` + : generateId(); const objectKey = buildSessionAttachmentObjectKey(sessionId, attachmentId); const storage = createMediaObjectStorage(env); + if (idempotencyKey) { + const existing = await storage.get(objectKey); + if (existing) { + const existingBytes = new Uint8Array(await new Response(existing.body).arrayBuffer()); + if (!equalBytes(existingBytes, bytes)) { + return error("Idempotency key was already used with different attachment content", 409); + } + return json({ + attachmentId, + mimeType: detected.mimeType, + } satisfies SessionAttachmentUploadResponse); + } + } const attachmentStorage = new SessionAttachmentStorageService( ctx.sessionRuntime, storage, @@ -180,7 +196,20 @@ async function handleAttachmentPost( ); } -async function handleAttachmentGet( +async function idempotencyFingerprint(sessionId: string, idempotencyKey: string): Promise<string> { + const input = new TextEncoder().encode(`${sessionId}\0${idempotencyKey}`); + const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", input)); + return [...digest] + .map((byte) => byte.toString(16).padStart(2, "0")) + .join("") + .slice(0, 32); +} + +function equalBytes(left: Uint8Array, right: Uint8Array): boolean { + return left.byteLength === right.byteLength && left.every((byte, index) => byte === right[index]); +} + +export async function handleAttachmentGet( request: Request, env: Env, match: RegExpMatchArray, diff --git a/packages/control-plane/src/routes/session-media-stream.ts b/packages/control-plane/src/routes/session-media-stream.ts index 6204d75b58..c387210e08 100644 --- a/packages/control-plane/src/routes/session-media-stream.ts +++ b/packages/control-plane/src/routes/session-media-stream.ts @@ -53,7 +53,7 @@ function resolveMediaContentType( return getMediaMimeType(artifact); } -async function handleMediaGet( +export async function handleMediaGet( request: Request, env: Env, match: RegExpMatchArray, diff --git a/packages/control-plane/src/routes/session-prompt.ts b/packages/control-plane/src/routes/session-prompt.ts index 5916dfeea0..547a91dc9c 100644 --- a/packages/control-plane/src/routes/session-prompt.ts +++ b/packages/control-plane/src/routes/session-prompt.ts @@ -11,7 +11,9 @@ import { import { applyIdentityEnforcement, mayAttachCallbackContext } from "../auth/identity-enforcement"; import { resolveGitHubCredentialAuthority } from "../source-control/github-credential-authority"; import { SessionIndexStore } from "../db/session-index"; +import { getEffectiveEnabledModels } from "../db/model-preferences"; import { UserStore } from "../db/user-store"; +import { isValidModel, isValidReasoningEffort } from "@open-inspect/shared/models"; import { createLogger } from "../logger"; import { SessionInternalPaths } from "../session/contracts"; import type { EnqueuePromptRequest } from "../session/enqueue-prompt-contract"; @@ -33,6 +35,85 @@ import { sessionRoute, type SessionRouteContext } from "./session-route"; const logger = createLogger("router:session-prompt"); +interface PromptModelAdmissionInput { + sessionId?: string; + model?: string; + reasoningEffort?: string; +} + +export async function admitPromptModel( + ctx: Pick<SessionRouteContext, "db">, + input: PromptModelAdmissionInput +): Promise<{ model: string; reasoningEffort?: string } | Response> { + let model = input.model; + if (!model && input.sessionId) { + const session = await new SessionIndexStore(ctx.db).get(input.sessionId); + if (!session) return error("Session not found", 404); + model = session.model; + } + if (!model || !isValidModel(model)) { + return error(`Model "${model ?? ""}" is not recognized`, 400); + } + try { + const enabledModels = await getEffectiveEnabledModels(ctx.db); + if (!enabledModels.includes(model)) return error(`Model "${model}" is not enabled`, 400); + } catch { + return error("Model preferences unavailable", 503); + } + if ( + input.reasoningEffort !== undefined && + !isValidReasoningEffort(model, input.reasoningEffort) + ) { + return error( + `Reasoning effort "${input.reasoningEffort}" is not supported by model "${model}"`, + 400 + ); + } + return { model, reasoningEffort: input.reasoningEffort }; +} + +/** Dispatch a prompt and project session activity through one canonical operation. */ +export async function dispatchSessionPrompt( + ctx: SessionRouteContext, + sessionId: string, + promptRequest: EnqueuePromptRequest, + adaptRuntimeResponse: (response: Response) => Response = (response) => response, + preAdmittedModel?: { model: string; reasoningEffort?: string } +): Promise<Response> { + const admission = + preAdmittedModel ?? + (await admitPromptModel(ctx, { + sessionId, + model: promptRequest.model, + reasoningEffort: promptRequest.reasoningEffort, + })); + if (admission instanceof Response) return admission; + const response = await ctx.sessionRuntime.fetch(sessionId, SessionInternalPaths.prompt, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ...promptRequest, ...admission }), + }); + + const store = new SessionIndexStore(ctx.db); + ctx.executionCtx.submit( + () => + store.touchUpdatedAt(sessionId).catch((error) => { + logger.error("session_index.touch_updated_at.background_error", { + session_id: sessionId, + trace_id: ctx.trace_id, + request_id: ctx.request_id, + error, + }); + }), + { + name: "session_index.touch_updated_at", + context: { session_id: sessionId, trace_id: ctx.trace_id, request_id: ctx.request_id }, + } + ); + + return adaptRuntimeResponse(response); +} + function validateAttachments(raw: unknown): SessionAttachmentReference[] | Response | undefined { if (raw === undefined) return undefined; const result = sessionAttachmentReferencesSchema.safeParse(raw); @@ -152,30 +233,7 @@ async function handleSessionPrompt( : undefined, } satisfies EnqueuePromptRequest; - const response = await ctx.sessionRuntime.fetch(sessionId, SessionInternalPaths.prompt, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(promptRequest), - }); - - const store = new SessionIndexStore(ctx.db); - ctx.executionCtx.submit( - () => - store.touchUpdatedAt(sessionId).catch((error) => { - logger.error("session_index.touch_updated_at.background_error", { - session_id: sessionId, - trace_id: ctx.trace_id, - request_id: ctx.request_id, - error, - }); - }), - { - name: "session_index.touch_updated_at", - context: { session_id: sessionId, trace_id: ctx.trace_id, request_id: ctx.request_id }, - } - ); - - return response; + return dispatchSessionPrompt(ctx, sessionId, promptRequest); } export const sessionPromptRoutes: Route[] = defineRoutes(GITHUB_USER_OR_SERVICE_ROUTE, [ diff --git a/packages/control-plane/src/routes/sessions.ts b/packages/control-plane/src/routes/sessions.ts index a26806db96..cc185a24a0 100644 --- a/packages/control-plane/src/routes/sessions.ts +++ b/packages/control-plane/src/routes/sessions.ts @@ -11,8 +11,14 @@ import { sessionAttachmentRoutes } from "./session-attachments"; import { sessionWsTokenRoutes } from "./session-ws-token"; import { sessionDiffRoutes } from "./session-diffs"; import { sessionSkillRoutes } from "./session-skills"; +import { externalSessionsRoutes } from "./external-sessions"; +import { externalDiscoveryRoutes } from "./external-discovery"; +import { externalSessionResourceRoutes } from "./external-session-resources"; export const sessionRoutes: Route[] = [ + ...externalSessionsRoutes, + ...externalDiscoveryRoutes, + ...externalSessionResourceRoutes, ...sessionCreateRoutes, ...sessionIndexRoutes, ...sessionRuntimeProxyRoutes, diff --git a/packages/control-plane/src/routes/shared.ts b/packages/control-plane/src/routes/shared.ts index 9560f71023..5cfaad034b 100644 --- a/packages/control-plane/src/routes/shared.ts +++ b/packages/control-plane/src/routes/shared.ts @@ -271,12 +271,13 @@ export type RouteAuthentication = | { kind: "web-service" } | { kind: "service" } | { kind: "user" } + | { kind: "external-user" } | { kind: "user-or-service" } | ({ kind: "sandbox" } & SandboxSessionBinding) | ({ kind: "user-or-service-with-sandbox-fallback" } & SandboxSessionBinding); export type RouteContext<Authentication extends RouteAuthentication> = RequestContext & { - principal: Authentication extends { kind: "user" } + principal: Authentication extends { kind: "user" | "external-user" } ? UserPrincipal : Authentication extends { kind: "sandbox" } ? SandboxPrincipal @@ -325,6 +326,12 @@ export const SCM_AGNOSTIC_HUMAN_USER_ROUTE = { supportedScmProviders: "all", } as const satisfies RoutePolicy; +/** Direct human CLI credential route under the versioned external API. */ +export const SCM_AGNOSTIC_EXTERNAL_USER_ROUTE = { + authentication: { kind: "external-user" }, + supportedScmProviders: "all", +} as const satisfies RoutePolicy; + export const SCM_AGNOSTIC_WEB_SERVICE_ROUTE = { authentication: { kind: "web-service" }, supportedScmProviders: "all", diff --git a/packages/control-plane/src/session/artifact-repository.test.ts b/packages/control-plane/src/session/artifact-repository.test.ts index 906d530402..cebebeee01 100644 --- a/packages/control-plane/src/session/artifact-repository.test.ts +++ b/packages/control-plane/src/session/artifact-repository.test.ts @@ -74,14 +74,22 @@ describe("ArtifactRepository", () => { it("lists artifacts in descending creation order", () => { repository.listArtifacts(); - expect(mock.calls[0].query).toContain("ORDER BY created_at DESC"); + expect(mock.calls[0].query).toContain("ORDER BY created_at DESC, id DESC"); }); it("returns an empty artifact list when none exist", () => { - mock.setRows(`SELECT * FROM artifacts ORDER BY created_at DESC`, []); + mock.setRows(`SELECT * FROM artifacts ORDER BY created_at DESC, id DESC`, []); expect(repository.listArtifacts()).toEqual([]); }); + it("uses both creation time and id for a page continuation", () => { + repository.listArtifacts({ cursor: { createdAt: 1000, id: "art-2" }, limit: 2 }); + + expect(mock.calls[0].query).toContain("created_at = ? AND id < ?"); + expect(mock.calls[0].query).toContain("ORDER BY created_at DESC, id DESC"); + expect(mock.calls[0].params).toEqual([1000, 1000, "art-2", 3]); + }); + it("queries artifacts by id", () => { repository.getArtifactById("art-1"); expect(mock.calls[0].query).toContain("SELECT * FROM artifacts WHERE id = ?"); diff --git a/packages/control-plane/src/session/artifact-repository.ts b/packages/control-plane/src/session/artifact-repository.ts index 28c2f80306..d9e5884613 100644 --- a/packages/control-plane/src/session/artifact-repository.ts +++ b/packages/control-plane/src/session/artifact-repository.ts @@ -1,6 +1,7 @@ import type { ArtifactType } from "@open-inspect/shared/types/artifacts"; import type { SqlStorage } from "./sql-storage"; import type { ArtifactRow } from "./types"; +import type { CreatedAtIdCursor } from "./list-cursor"; /** Data for creating an artifact. */ export interface CreateArtifactData { @@ -46,8 +47,19 @@ export class ArtifactRepository { ); } - listArtifacts(): ArtifactRow[] { - const result = this.sql.exec(`SELECT * FROM artifacts ORDER BY created_at DESC`); + listArtifacts(options?: { cursor: CreatedAtIdCursor | null; limit: number }): ArtifactRow[] { + const cursorCondition = options?.cursor + ? `WHERE created_at < ? OR (created_at = ? AND id < ?)` + : ""; + const params: Array<string | number> = options?.cursor + ? [options.cursor.createdAt, options.cursor.createdAt, options.cursor.id] + : []; + const limit = options ? `LIMIT ?` : ""; + if (options) params.push(options.limit + 1); + const result = this.sql.exec( + `SELECT * FROM artifacts ${cursorCondition} ORDER BY created_at DESC, id DESC ${limit}`.trim(), + ...params + ); return result.toArray() as ArtifactRow[]; } diff --git a/packages/control-plane/src/session/components.ts b/packages/control-plane/src/session/components.ts index 85858afdeb..59ad1a14b9 100644 --- a/packages/control-plane/src/session/components.ts +++ b/packages/control-plane/src/session/components.ts @@ -709,6 +709,7 @@ export function createSessionRuntime(platform: SessionPlatform, env: Env): Sessi // Internal HTTP route table (transport wiring only). const routes = createSessionInternalRoutes({ init: (request, _url, requestLog) => sessionInitHandler.init(request, requestLog), + ensureBootstrap: (request, _url, requestLog) => sessionInitHandler.ensure(request, requestLog), state: () => sessionLifecycleHandler.getState(), snapshot: () => snapshotReader.handleSnapshot(), sandboxAccess: () => accessReader.handleSandboxAccess(), @@ -727,6 +728,7 @@ export function createSessionRuntime(platform: SessionPlatform, env: Env): Sessi }, listParticipants: () => participantsHandler.listParticipants(), listEvents: (_request, url) => messagesHandler.listEvents(url), + listEventChanges: (_request, url) => messagesHandler.listEventChanges(url), listArtifacts: (_request, url) => messagesHandler.listArtifacts(url), listMessages: (_request, url) => messagesHandler.listMessages(url), createPr: (request, _url, requestLog) => pullRequestHandler.createPr(request, requestLog), diff --git a/packages/control-plane/src/session/contracts.ts b/packages/control-plane/src/session/contracts.ts index 7471e74fda..490fd28c06 100644 --- a/packages/control-plane/src/session/contracts.ts +++ b/packages/control-plane/src/session/contracts.ts @@ -4,6 +4,13 @@ */ import { z } from "zod"; +import { eventResponseSchema } from "@open-inspect/shared/types/sandbox-events"; +import { sessionStatusSchema } from "@open-inspect/shared/types/sessions"; + +export const sessionBootstrapEnsureResponseSchema = z.strictObject({ + status: z.literal("ensured"), + sessionStatus: sessionStatusSchema, +}); /** SCM display fields forwarded from the authenticated route to the Session runtime. */ export const sessionScmDisplayFieldsSchema = z.object({ @@ -14,6 +21,7 @@ export const sessionScmDisplayFieldsSchema = z.object({ export const SessionInternalPaths = { init: "/internal/init", + ensureBootstrap: "/internal/ensure-bootstrap", state: "/internal/state", snapshot: "/internal/snapshot", sandboxAccess: "/internal/sandbox-access", @@ -26,6 +34,7 @@ export const SessionInternalPaths = { attachments: "/internal/attachments", participants: "/internal/participants", events: "/internal/events", + eventChanges: "/internal/event-changes", artifacts: "/internal/artifacts", messages: "/internal/messages", createPr: "/internal/create-pr", @@ -56,6 +65,33 @@ export const SessionInternalPaths = { diffRetry: "/internal/diff-retry", } as const; +export const sessionEventChangePageSchema = z + .strictObject({ + changes: z.array( + z.discriminatedUnion("kind", [ + z.strictObject({ + kind: z.literal("upsert"), + revision: z.number().int().positive(), + event: eventResponseSchema, + }), + z.strictObject({ + kind: z.literal("delete"), + revision: z.number().int().positive(), + eventId: z.string().min(1), + }), + ]) + ), + checkpoint: z.number().int().nonnegative(), + cursor: z.string().min(1).optional(), + hasMore: z.boolean(), + }) + .refine((page) => !page.hasMore || page.cursor !== undefined, { + message: "cursor is required when hasMore is true", + path: ["cursor"], + }); + +export type SessionEventChangePage = z.infer<typeof sessionEventChangePageSchema>; + export type SessionInternalPath = (typeof SessionInternalPaths)[keyof typeof SessionInternalPaths]; const INTERNAL_ORIGIN = "http://internal"; diff --git a/packages/control-plane/src/session/enqueue-prompt-contract.ts b/packages/control-plane/src/session/enqueue-prompt-contract.ts index 77e065a0ef..9829ba895b 100644 --- a/packages/control-plane/src/session/enqueue-prompt-contract.ts +++ b/packages/control-plane/src/session/enqueue-prompt-contract.ts @@ -6,6 +6,7 @@ import { promptContentSchema, } from "@open-inspect/shared/types/prompts"; import { z } from "zod"; +import { clientRequestIdSchema } from "@open-inspect/shared/types/prompts"; export const enqueuePromptRequestSchema = z .object({ @@ -15,6 +16,7 @@ export const enqueuePromptRequestSchema = z source: messageSourceSchema, model: z.string().optional(), reasoningEffort: z.string().optional(), + clientRequestId: clientRequestIdSchema.optional(), attachments: sessionAttachmentReferencesSchema.optional(), callbackContext: z.record(z.string(), z.unknown()).optional(), // Trusted SCM enrichment resolved by the router at prompt time. diff --git a/packages/control-plane/src/session/event-repository.test.ts b/packages/control-plane/src/session/event-repository.test.ts index 2dfbaad808..68220edc28 100644 --- a/packages/control-plane/src/session/event-repository.test.ts +++ b/packages/control-plane/src/session/event-repository.test.ts @@ -1,16 +1,30 @@ +import { DatabaseSync, type SQLInputValue } from "node:sqlite"; import { beforeEach, describe, expect, it } from "vitest"; -import { EventRepository } from "./event-repository"; +import { + EVENT_CHANGE_JOURNAL_BYTE_LIMIT, + EVENT_CHANGE_RETENTION_LIMIT, + EVENT_CHANGE_RETENTION_MS, + EventRepository, +} from "./event-repository"; import type { SqlResult, SqlStorage } from "./sql-storage"; function createMockSql() { const calls: Array<{ query: string; params: unknown[] }> = []; const rowsByQuery = new Map<string, unknown[]>(); + let currentRevision = 0; const sql: SqlStorage = { exec(query: string, ...params: unknown[]): SqlResult { calls.push({ query, params }); + const rows = query.includes("RETURNING current_revision") + ? [{ current_revision: ++currentRevision }] + : query.includes("AS time_floor") + ? [{ existing_floor: 0, time_floor: null, count_floor: 0, byte_floor: 0 }] + : query.includes("AS baseline_bytes") + ? [{ baseline_bytes: 0, baseline_count: 0, total_bytes: 0, total_count: 0 }] + : (rowsByQuery.get(query) ?? []); return { - toArray: () => rowsByQuery.get(query) ?? [], - one: () => null, + toArray: () => rows, + one: () => rows[0] ?? null, rowsWritten: 0, }; }, @@ -24,6 +38,10 @@ function createMockSql() { }; } +function eventWrites(calls: Array<{ query: string; params: unknown[] }>) { + return calls.filter(({ query }) => /(?:INSERT INTO|UPDATE) events/.test(query)); +} + describe("EventRepository", () => { let mock: ReturnType<typeof createMockSql>; let repository: EventRepository; @@ -48,20 +66,22 @@ describe("EventRepository", () => { createdAt: 1000, }); - expect(mock.calls).toHaveLength(1); - expect(mock.calls[0].query).toContain("INSERT INTO events"); - expect(mock.calls[0].params).toEqual([ - "evt-1", - "tool_call", - '{"tool":"read"}', - "msg-1", - 1000, - ]); + const writes = eventWrites(mock.calls); + expect(writes).toHaveLength(1); + expect(writes[0].query).toContain("INSERT INTO events"); + expect(writes[0].params).toEqual(["evt-1", "tool_call", '{"tool":"read"}', "msg-1", 1000, 1]); + expect(mock.calls.some(({ query }) => query.includes("INSERT INTO event_changes"))).toBe( + true + ); + expect(mock.calls.some(({ query }) => query.includes("AS time_floor"))).toBe(false); }); }); describe("createContextCompactionEvent", () => { it("atomically seals the current token and inserts the compaction marker", () => { + mock.setRows(`UPDATE events SET id = ?, change_revision = ? WHERE id = ? RETURNING id`, [ + { id: "token:msg-1:compaction-1" }, + ]); repository.createContextCompactionEvent({ id: "compaction-1", type: "context_compacted", @@ -71,17 +91,27 @@ describe("EventRepository", () => { }); expect(transactionSyncCalls).toBe(1); - expect(mock.calls).toHaveLength(2); - expect(mock.calls[0].query).toContain("UPDATE events SET id = ? WHERE id = ?"); - expect(mock.calls[0].params).toEqual(["token:msg-1:compaction-1", "token:msg-1"]); - expect(mock.calls[1].query).toContain("INSERT INTO events"); - expect(mock.calls[1].params).toEqual([ + const writes = eventWrites(mock.calls); + expect(writes).toHaveLength(2); + expect(writes[0].query).toContain( + "UPDATE events SET id = ?, change_revision = ? WHERE id = ?" + ); + expect(writes[0].params).toEqual(["token:msg-1:compaction-1", 2, "token:msg-1"]); + expect(writes[1].query).toContain("INSERT INTO events"); + expect(writes[1].params).toEqual([ "compaction-1", "context_compacted", '{"type":"context_compacted"}', "msg-1", 1000, + 3, ]); + const journalWrites = mock.calls.filter(({ query }) => + query.includes("INSERT INTO event_changes") + ); + expect(journalWrites).toHaveLength(3); + expect(journalWrites[0].params.slice(0, 2)).toEqual([1, "token:msg-1"]); + expect(journalWrites[1].params[2]).toBe("token:msg-1:compaction-1"); }); }); @@ -97,13 +127,15 @@ describe("EventRepository", () => { repository.upsertTokenEvent("msg-1", event, 1000); - expect(mock.calls[0].query).toContain("ON CONFLICT(id) DO UPDATE SET"); - expect(mock.calls[0].params).toEqual([ + const write = eventWrites(mock.calls)[0]; + expect(write.query).toContain("ON CONFLICT(id) DO UPDATE SET"); + expect(write.params).toEqual([ "token:msg-1", "token", JSON.stringify(event), "msg-1", 1000, + 1, ]); }); @@ -120,10 +152,18 @@ describe("EventRepository", () => { repository.upsertTokenEvent("msg-1", firstEvent, 1000); repository.upsertTokenEvent("msg-1", secondEvent, 2000); - expect(mock.calls[0].params[0]).toBe("token:msg-1"); - expect(mock.calls[1].params[0]).toBe("token:msg-1"); - expect(mock.calls[1].params[2]).toBe(JSON.stringify(secondEvent)); - expect(mock.calls[1].params[4]).toBe(2000); + const writes = eventWrites(mock.calls); + expect(writes[0].params[0]).toBe("token:msg-1"); + expect(writes[1].params[0]).toBe("token:msg-1"); + expect(writes[1].params[2]).toBe(JSON.stringify(secondEvent)); + expect(writes[1].params[4]).toBe(2000); + expect(writes[1].params[5]).toBe(2); + expect( + mock.calls.filter(({ query }) => query.includes("INSERT INTO event_changes")) + ).toHaveLength(2); + expect( + mock.calls.filter(({ query }) => query.includes("INSERT INTO event_changes"))[1].query + ).not.toContain("ON CONFLICT"); }); }); @@ -145,14 +185,16 @@ describe("EventRepository", () => { repository.upsertToolCallEvent("msg-1", event, 1000); - expect(mock.calls[0].query).toContain("ON CONFLICT(id) DO UPDATE SET"); - expect(mock.calls[0].query).not.toContain("created_at = excluded.created_at"); - expect(mock.calls[0].params).toEqual([ + const write = eventWrites(mock.calls)[0]; + expect(write.query).toContain("ON CONFLICT(id) DO UPDATE SET"); + expect(write.query).not.toContain("created_at = excluded.created_at"); + expect(write.params).toEqual([ 'tool_call:["msg-1","child-1","call-1"]', "tool_call", JSON.stringify(event), "msg-1", 1000, + 1, ]); }); @@ -168,7 +210,7 @@ describe("EventRepository", () => { }; repository.upsertToolCallEvent("msg-1", event, 1000); - expect(mock.calls[0].params[0]).toBe('tool_call:["msg-1","parent","call-1"]'); + expect(eventWrites(mock.calls)[0].params[0]).toBe('tool_call:["msg-1","parent","call-1"]'); }); }); @@ -184,12 +226,13 @@ describe("EventRepository", () => { repository.upsertExecutionCompleteEvent("msg-1", event, 1000); - expect(mock.calls[0].params).toEqual([ + expect(eventWrites(mock.calls)[0].params).toEqual([ "execution_complete:msg-1", "execution_complete", JSON.stringify(event), "msg-1", 1000, + 1, ]); }); }); @@ -309,4 +352,397 @@ describe("EventRepository", () => { expect(result.nextCursor).toEqual({ kind: "timeline", createdAt: 3000, id: "e1" }); }); }); + + describe("listEventChanges", () => { + it("pins the current checkpoint and returns an ascending bounded journal page", () => { + const stateQuery = `SELECT cursor_scope, current_revision, retention_floor + FROM event_feed_state WHERE singleton = 1`; + const pageQuery = `SELECT * FROM event_changes + WHERE revision > ? AND revision <= ? + ORDER BY revision ASC LIMIT ?`; + mock.setRows(stateQuery, [ + { cursor_scope: "a".repeat(32), current_revision: 5, retention_floor: 0 }, + ]); + mock.setRows(pageQuery, [ + { kind: "upsert", event_id: "e2", revision: 2 }, + { kind: "delete", event_id: "e1", revision: 4 }, + { kind: "upsert", event_id: "e5", revision: 5 }, + ]); + + expect(repository.listEventChanges({ after: 1, limit: 2 })).toMatchObject({ + changes: [{ event_id: "e2" }, { event_id: "e1", kind: "delete" }], + checkpoint: 5, + hasMore: true, + nextCursor: { mode: "changes", checkpoint: 5, revision: 4 }, + }); + expect(mock.calls.find(({ query }) => query === pageQuery)?.params).toEqual([1, 5, 3]); + }); + + it("rejects foreign and future continuation cursors", () => { + const stateQuery = `SELECT cursor_scope, current_revision, retention_floor + FROM event_feed_state WHERE singleton = 1`; + mock.setRows(stateQuery, [ + { cursor_scope: "a".repeat(32), current_revision: 5, retention_floor: 0 }, + ]); + expect(() => + repository.listEventChanges({ + cursor: { + mode: "changes", + scope: "b".repeat(32), + checkpoint: 6, + revision: 5, + }, + limit: 50, + }) + ).toThrow("Invalid event feed cursor"); + }); + + it("rejects checkpoints below the monotonic retention floor", () => { + const stateQuery = `SELECT cursor_scope, current_revision, retention_floor + FROM event_feed_state WHERE singleton = 1`; + mock.setRows(stateQuery, [ + { cursor_scope: "a".repeat(32), current_revision: 80_000, retention_floor: 30_000 }, + ]); + + expect(() => repository.listEventChanges({ after: 29_999, limit: 50 })).toThrow( + "Event feed checkpoint expired" + ); + expect(() => repository.listEventChanges({ after: 30_000, limit: 50 })).not.toThrow(); + expect(() => + repository.listEventChanges({ + cursor: { + mode: "snapshot", + scope: "a".repeat(32), + checkpoint: 29_999, + createdAt: 1, + timelineSequence: 1, + }, + limit: 50, + }) + ).toThrow("Event feed checkpoint expired"); + }); + + it("reads snapshots from canonical events under the captured checkpoint", () => { + const stateQuery = `SELECT cursor_scope, current_revision, retention_floor + FROM event_feed_state WHERE singleton = 1`; + mock.setRows(stateQuery, [ + { cursor_scope: "a".repeat(32), current_revision: 9, retention_floor: 4 }, + ]); + + repository.listEventChanges({ limit: 50 }); + + const snapshot = mock.calls.find(({ query }) => query.includes("WITH versions AS"))!; + expect(snapshot.query).toContain("FROM event_changes WHERE revision <= ?"); + expect(snapshot.query).toContain("FROM events WHERE change_revision <= ?"); + expect(snapshot.params).toEqual([9, 4, 9, 51]); + }); + }); +}); + +function createRealRepository() { + const db = new DatabaseSync(":memory:"); + db.exec(`CREATE TABLE events ( + id TEXT PRIMARY KEY, + type TEXT NOT NULL, + data TEXT NOT NULL, + message_id TEXT, + created_at INTEGER NOT NULL, + timeline_sequence INTEGER NOT NULL UNIQUE, + change_revision INTEGER UNIQUE + ); + CREATE TABLE event_changes ( + revision INTEGER PRIMARY KEY, + kind TEXT NOT NULL CHECK (kind IN ('upsert', 'delete')), + event_id TEXT NOT NULL, + type TEXT, + data TEXT, + message_id TEXT, + created_at INTEGER, + timeline_sequence INTEGER, + changed_at INTEGER NOT NULL, + journal_bytes INTEGER NOT NULL, + is_baseline INTEGER NOT NULL DEFAULT 0 + ); + CREATE TABLE event_feed_state ( + singleton INTEGER PRIMARY KEY, + cursor_scope TEXT NOT NULL, + current_revision INTEGER NOT NULL, + retention_floor INTEGER NOT NULL + ); + INSERT INTO event_feed_state VALUES (1, 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', 0, 0);`); + const sql: SqlStorage = { + exec(query: string, ...params: unknown[]): SqlResult { + const values = params as SQLInputValue[]; + if (/^\s*(?:SELECT|PRAGMA|WITH)\b/i.test(query) || /\bRETURNING\b/i.test(query)) { + const rows = db.prepare(query).all(...values); + return { toArray: () => rows, one: () => rows[0] ?? null }; + } + const result = db.prepare(query).run(...values); + return { toArray: () => [], one: () => null, rowsWritten: Number(result.changes) }; + }, + }; + const transactionSync = <T>(closure: () => T): T => { + db.exec("BEGIN"); + try { + const result = closure(); + db.exec("COMMIT"); + return result; + } catch (error) { + db.exec("ROLLBACK"); + throw error; + } + }; + return { db, repository: new EventRepository(sql, transactionSync) }; +} + +describe("EventRepository journal retention", () => { + it("retains immutable versions for one event ID", () => { + const { db, repository } = createRealRepository(); + try { + const event = { + type: "token" as const, + content: "first", + messageId: "message-1", + sandboxId: "sandbox-1", + timestamp: 1, + }; + repository.upsertTokenEvent("message-1", event, 1); + repository.upsertTokenEvent("message-1", { ...event, content: "final" }, 2); + + expect( + db + .prepare(`SELECT revision, data FROM event_changes WHERE event_id = ?`) + .all("token:message-1") + ).toEqual([ + { revision: 1, data: JSON.stringify(event) }, + { revision: 2, data: JSON.stringify({ ...event, content: "final" }) }, + ]); + } finally { + db.close(); + } + }); + + it("continues a pinned snapshot after an unseen event is updated and deleted", () => { + const { db, repository } = createRealRepository(); + try { + const token = (messageId: string, content: string, timestamp: number) => ({ + type: "token" as const, + content, + messageId, + sandboxId: "sandbox-1", + timestamp, + }); + repository.upsertTokenEvent("m1", token("m1", "one", 1), 1); + repository.upsertTokenEvent("m2", token("m2", "two", 2), 2); + repository.upsertTokenEvent("m3", token("m3", "original", 3), 3); + + const first = repository.listEventChanges({ limit: 2 }); + expect(first.changes.map((change) => change.event_id)).toEqual(["token:m1", "token:m2"]); + expect(first.checkpoint).toBe(3); + expect(first.nextCursor).not.toBeNull(); + + repository.upsertTokenEvent("m3", token("m3", "updated", 4), 4); + repository.deleteEventWithinTransaction("token:m3"); + + const continuation = repository.listEventChanges({ cursor: first.nextCursor!, limit: 2 }); + expect(continuation.changes).toHaveLength(1); + expect(continuation.changes[0]).toMatchObject({ + revision: 3, + kind: "upsert", + event_id: "token:m3", + }); + expect(JSON.parse(continuation.changes[0].data!)).toMatchObject({ content: "original" }); + + const later = repository.listEventChanges({ after: first.checkpoint, limit: 10 }); + expect(later.changes.map(({ revision, kind }) => ({ revision, kind }))).toEqual([ + { revision: 4, kind: "upsert" }, + { revision: 5, kind: "delete" }, + ]); + expect(db.prepare(`SELECT id FROM events WHERE id = 'token:m3'`).get()).toBeUndefined(); + } finally { + db.close(); + } + }); + + it("continues an accepted pinned snapshot from a baseline after the floor advances", () => { + const { db, repository } = createRealRepository(); + try { + const token = (messageId: string, content: string, timestamp: number) => ({ + type: "token" as const, + content, + messageId, + sandboxId: "sandbox-1", + timestamp, + }); + repository.upsertTokenEvent("m1", token("m1", "one", 1), 1); + repository.upsertTokenEvent("m2", token("m2", "original", 2), 2); + repository.upsertTokenEvent("m3", token("m3", "three", 3), 3); + db.prepare(`UPDATE event_changes SET changed_at = ? WHERE revision <= 2`).run( + Date.now() - EVENT_CHANGE_RETENTION_MS + ); + expect(() => repository.listEventChanges({ after: 0, limit: 10 })).toThrow( + "Event feed checkpoint expired" + ); + repository.upsertTokenEvent("m4", token("m4", "four", 4), 4); + + const first = repository.listEventChanges({ limit: 1 }); + expect(first.checkpoint).toBe(4); + expect(first.changes[0].event_id).toBe("token:m1"); + repository.upsertTokenEvent("m2", token("m2", "updated", 5), 5); + + const continuation = repository.listEventChanges({ cursor: first.nextCursor!, limit: 1 }); + expect(continuation.changes[0]).toMatchObject({ + revision: 2, + event_id: "token:m2", + }); + expect(JSON.parse(continuation.changes[0].data!)).toMatchObject({ content: "original" }); + expect(db.prepare(`SELECT retention_floor FROM event_feed_state`).get()).toEqual({ + retention_floor: 2, + }); + } finally { + db.close(); + } + }); + + it("expires old checkpoints after more than 50,000 updates to one event", () => { + const { db, repository } = createRealRepository(); + try { + db.exec(`WITH RECURSIVE revisions(value) AS ( + VALUES(1) UNION ALL SELECT value + 1 FROM revisions WHERE value <= ${EVENT_CHANGE_RETENTION_LIMIT} + ) + INSERT INTO event_changes (revision, kind, event_id, changed_at, journal_bytes) + SELECT value, 'delete', 'same-event', ${Date.now()}, 64 FROM revisions; + UPDATE event_feed_state SET current_revision = ${EVENT_CHANGE_RETENTION_LIMIT + 1};`); + + repository.createEvent({ + id: "latest", + type: "heartbeat", + data: "{}", + messageId: null, + createdAt: 1, + }); + + expect(() => repository.listEventChanges({ after: 1, limit: 10 })).toThrow( + "Event feed checkpoint expired" + ); + expect(db.prepare(`SELECT COUNT(*) AS count FROM event_changes`).get()).toEqual({ + count: EVENT_CHANGE_RETENTION_LIMIT, + }); + expect(db.prepare(`SELECT retention_floor FROM event_feed_state`).get()).toEqual({ + retention_floor: 2, + }); + expect(() => repository.listEventChanges({ after: 2, limit: 10 })).not.toThrow(); + } finally { + db.close(); + } + }); + + it("prunes changes once they reach the 24-hour recovery limit", () => { + const { db, repository } = createRealRepository(); + try { + db.prepare( + `INSERT INTO event_changes (revision, kind, event_id, changed_at, journal_bytes) + VALUES (1, 'delete', 'expired', ?, 64)` + ).run(Date.now() - EVENT_CHANGE_RETENTION_MS); + db.exec(`UPDATE event_feed_state SET current_revision = 1`); + + expect(() => repository.listEventChanges({ after: 0, limit: 10 })).toThrow( + "Event feed checkpoint expired" + ); + + expect( + db.prepare(`SELECT event_id, is_baseline FROM event_changes ORDER BY revision`).all() + ).toEqual([]); + expect(db.prepare(`SELECT retention_floor FROM event_feed_state`).get()).toEqual({ + retention_floor: 1, + }); + } finally { + db.close(); + } + }); + + it("advances the floor until retained journal bytes fit the strict cap", () => { + const { db, repository } = createRealRepository(); + try { + const content = "x".repeat(9 * 1024 * 1024); + const event = { + type: "token" as const, + content, + messageId: "large-message", + sandboxId: "sandbox-1", + timestamp: 1, + }; + repository.upsertTokenEvent("large-message", event, 1); + repository.upsertTokenEvent("large-message", { ...event, timestamp: 2 }, 2); + + expect(() => repository.listEventChanges({ after: 0, limit: 10 })).toThrow( + "Event feed checkpoint expired" + ); + expect(db.prepare(`SELECT SUM(journal_bytes) AS bytes FROM event_changes`).get()).toEqual( + expect.objectContaining({ bytes: expect.any(Number) }) + ); + const retained = db + .prepare(`SELECT SUM(journal_bytes) AS bytes FROM event_changes`) + .get() as { + bytes: number; + }; + expect(retained.bytes).toBeLessThanOrEqual(EVENT_CHANGE_JOURNAL_BYTE_LIMIT); + expect(db.prepare(`SELECT retention_floor FROM event_feed_state`).get()).toEqual({ + retention_floor: 2, + }); + } finally { + db.close(); + } + }); + + it("rotates cursor scope when required baselines exceed the byte cap", () => { + const { db, repository } = createRealRepository(); + try { + const baselineBytes = 9 * 1024 * 1024; + db.exec(`INSERT INTO event_changes + (revision, kind, event_id, changed_at, journal_bytes) VALUES + (1, 'upsert', 'one', 0, ${baselineBytes}), + (2, 'upsert', 'two', 0, ${baselineBytes}); + UPDATE event_feed_state SET current_revision = 2;`); + + expect(() => repository.listEventChanges({ after: 0, limit: 10 })).toThrow( + "Event feed checkpoint expired" + ); + + const state = db + .prepare(`SELECT cursor_scope, retention_floor FROM event_feed_state`) + .get() as { cursor_scope: string; retention_floor: number }; + expect(state.cursor_scope).not.toBe("a".repeat(32)); + expect(state.retention_floor).toBe(2); + expect(db.prepare(`SELECT COUNT(*) AS count FROM event_changes`).get()).toEqual({ count: 0 }); + } finally { + db.close(); + } + }); + + it("reports expiry when pruning rotates a previously valid cursor scope", () => { + const { db, repository } = createRealRepository(); + try { + const baselineBytes = 9 * 1024 * 1024; + db.exec(`INSERT INTO event_changes + (revision, kind, event_id, changed_at, journal_bytes) VALUES + (1, 'upsert', 'one', 0, ${baselineBytes}), + (2, 'upsert', 'two', 0, ${baselineBytes}); + UPDATE event_feed_state SET current_revision = 2;`); + + expect(() => + repository.listEventChanges({ + cursor: { + mode: "changes", + scope: "a".repeat(32), + checkpoint: 2, + revision: 0, + }, + limit: 10, + }) + ).toThrow("Event feed checkpoint expired"); + } finally { + db.close(); + } + }); }); diff --git a/packages/control-plane/src/session/event-repository.ts b/packages/control-plane/src/session/event-repository.ts index e19486dc26..aeabc00790 100644 --- a/packages/control-plane/src/session/event-repository.ts +++ b/packages/control-plane/src/session/event-repository.ts @@ -6,7 +6,19 @@ import { type EventTimelineCursor, } from "./event-cursor"; import type { SqlStorage, TransactionSync } from "./sql-storage"; -import type { EventRow } from "./types"; +import { + EVENT_CHANGE_JOURNAL_BYTE_LIMIT, + EVENT_CHANGE_RETENTION_LIMIT, + EVENT_CHANGE_RETENTION_MS, + type EventChangeRow, + type EventRow, +} from "./types"; + +export { + EVENT_CHANGE_JOURNAL_BYTE_LIMIT, + EVENT_CHANGE_RETENTION_LIMIT, + EVENT_CHANGE_RETENTION_MS, +} from "./types"; type TokenEvent = Extract<SandboxEvent, { type: "token" }>; type ToolCallEvent = Extract<SandboxEvent, { type: "tool_call" }>; @@ -14,11 +26,9 @@ type ExecutionCompleteEvent = Extract<SandboxEvent, { type: "execution_complete" type UpsertableEventType = TokenEvent["type"] | ExecutionCompleteEvent["type"]; const NEXT_TIMELINE_SEQUENCE_SQL = "(SELECT COALESCE(MAX(timeline_sequence), 0) + 1 FROM events)"; +const EVENT_CHANGE_PRUNE_BATCH_SIZE = 500; +const EVENT_CHANGE_PRUNE_INTERVAL = 32; -/** - * Data for creating an event. Type is open because sandboxes emit additional - * event types beyond the shared EventType union. - */ export interface CreateEventData { id: string; type: string; @@ -46,10 +56,41 @@ export interface EventPage { nextCursor: EventTimelineCursor | null; } +export type EventFeedCursor = + | { + mode: "snapshot"; + scope: string; + checkpoint: number; + createdAt: number; + timelineSequence: number; + } + | { + mode: "changes"; + scope: string; + checkpoint: number; + revision: number; + }; + +export interface EventChangePage { + changes: EventChangeRow[]; + checkpoint: number; + hasMore: boolean; + nextCursor: EventFeedCursor | null; +} + +export interface ListEventChangesOptions { + after?: number; + cursor?: EventFeedCursor; + limit: number; +} + interface QueryEventPageOptions extends ListEventPageOptions { excludeTypes?: string[]; } +export class InvalidEventFeedCursorError extends Error {} +export class EventFeedCheckpointExpiredError extends Error {} + /** Persistence for events scoped to one session. */ export class EventRepository { constructor( @@ -57,69 +98,328 @@ export class EventRepository { private readonly transactionSync: TransactionSync ) {} - createEvent(data: CreateEventData): void { + private nextRevision(): number { + return ( + this.sql + .exec( + `UPDATE event_feed_state + SET current_revision = current_revision + 1 + WHERE singleton = 1 + RETURNING current_revision` + ) + .one() as { current_revision: number } + ).current_revision; + } + + private appendUpsert(eventId: string, revision: number, changedAt: number): void { + this.sql.exec( + `INSERT INTO event_changes + (revision, kind, event_id, type, data, message_id, created_at, timeline_sequence, + changed_at, journal_bytes) + SELECT ?, 'upsert', id, type, data, message_id, created_at, timeline_sequence, ?, + 64 + length(CAST(id AS BLOB)) + length(CAST(type AS BLOB)) + + length(CAST(data AS BLOB)) + COALESCE(length(CAST(message_id AS BLOB)), 0) + FROM events WHERE id = ?`, + revision, + changedAt, + eventId + ); + this.maybePruneChanges(revision, changedAt); + } + + private appendDelete(eventId: string, revision: number, changedAt: number): void { + this.sql.exec( + `INSERT INTO event_changes (revision, kind, event_id, changed_at, journal_bytes) + VALUES (?, 'delete', ?, ?, 64 + length(CAST(? AS BLOB)))`, + revision, + eventId, + changedAt, + eventId + ); + this.maybePruneChanges(revision, changedAt); + } + + private maybePruneChanges(revision: number, changedAt: number): void { + if (revision % EVENT_CHANGE_PRUNE_INTERVAL === 0) this.pruneChanges(changedAt); + } + + private ensureCurrentVersionRecoverable(eventId: string): void { + const mustRotate = ( + this.sql + .exec( + `SELECT 1 AS must_rotate FROM events, event_feed_state + WHERE events.id = ? AND event_feed_state.singleton = 1 + AND events.change_revision <= event_feed_state.retention_floor + AND NOT EXISTS ( + SELECT 1 FROM event_changes + WHERE revision = events.change_revision AND event_id = events.id + AND is_baseline = 1 + )`, + eventId + ) + .toArray()[0] as { must_rotate: number } | undefined + )?.must_rotate; + if (mustRotate) this.rotateCursorScopeAndDeleteHistory(); + } + + private pruneChanges(now: number): void { + const boundaries = this.sql + .exec( + `SELECT + COALESCE((SELECT retention_floor FROM event_feed_state WHERE singleton = 1), 0) + AS existing_floor, + MAX(CASE WHEN changed_at <= ? THEN revision END) AS time_floor, + COALESCE((SELECT current_revision FROM event_feed_state WHERE singleton = 1), 0) + - ? AS count_floor, + COALESCE(( + SELECT MAX(revision) FROM ( + SELECT revision, + SUM(journal_bytes) OVER (ORDER BY revision DESC) AS retained_bytes + FROM event_changes + ) WHERE retained_bytes > ? + ), 0) AS byte_floor + FROM event_changes`, + now - EVENT_CHANGE_RETENTION_MS, + EVENT_CHANGE_RETENTION_LIMIT, + EVENT_CHANGE_JOURNAL_BYTE_LIMIT + ) + .one() as { + existing_floor: number; + time_floor: number | null; + count_floor: number; + byte_floor: number; + }; + const logicalBoundary = Math.max( + boundaries.existing_floor, + boundaries.time_floor ?? 0, + boundaries.count_floor, + boundaries.byte_floor + ); + if (logicalBoundary > 0) this.compactThroughFloor(logicalBoundary); + + while (true) { + const stats = this.sql + .exec( + `SELECT + COALESCE(SUM(CASE WHEN is_baseline = 1 THEN journal_bytes ELSE 0 END), 0) + AS baseline_bytes, + COALESCE(SUM(CASE WHEN is_baseline = 1 THEN 1 ELSE 0 END), 0) + AS baseline_count, + COALESCE(SUM(journal_bytes), 0) AS total_bytes, + COUNT(*) AS total_count + FROM event_changes` + ) + .one() as { + baseline_bytes: number; + baseline_count: number; + total_bytes: number; + total_count: number; + }; + if ( + stats.baseline_bytes > EVENT_CHANGE_JOURNAL_BYTE_LIMIT || + stats.baseline_count > EVENT_CHANGE_RETENTION_LIMIT + ) { + this.rotateCursorScopeAndDeleteHistory(); + return; + } + if ( + stats.total_bytes <= EVENT_CHANGE_JOURNAL_BYTE_LIMIT && + stats.total_count <= EVENT_CHANGE_RETENTION_LIMIT + ) { + return; + } + const nextFloor = ( + this.sql + .exec( + `SELECT MAX(revision) AS revision FROM ( + SELECT revision, + SUM(journal_bytes) OVER (ORDER BY revision DESC) AS retained_bytes, + ROW_NUMBER() OVER (ORDER BY revision DESC) AS retained_count + FROM event_changes WHERE is_baseline = 0 + ) WHERE retained_bytes > ? OR retained_count > ?`, + EVENT_CHANGE_JOURNAL_BYTE_LIMIT - stats.baseline_bytes, + EVENT_CHANGE_RETENTION_LIMIT - stats.baseline_count + ) + .one() as { revision: number | null } + ).revision; + if (nextFloor === null) { + this.rotateCursorScopeAndDeleteHistory(); + return; + } + this.compactThroughFloor(nextFloor); + } + } + + private compactThroughFloor(retentionFloor: number): void { + this.sql.exec( + `UPDATE event_feed_state + SET retention_floor = MAX(retention_floor, ?) + WHERE singleton = 1`, + retentionFloor + ); + this.sql.exec(`UPDATE event_changes SET is_baseline = 0 WHERE revision <= ?`, retentionFloor); + this.sql.exec( + `UPDATE event_changes SET is_baseline = 1 + WHERE revision IN ( + SELECT MAX(revision) FROM event_changes + WHERE revision <= ? GROUP BY event_id + )`, + retentionFloor + ); + this.sql.exec( + `DELETE FROM event_changes + WHERE revision <= ? AND is_baseline = 1 AND kind = 'delete'`, + retentionFloor + ); + this.deleteChangesThrough(retentionFloor, true); + } + + private rotateCursorScopeAndDeleteHistory(): void { + const currentRevision = ( + this.sql + .exec( + `UPDATE event_feed_state SET + cursor_scope = lower(hex(randomblob(16))), + retention_floor = current_revision + WHERE singleton = 1 + RETURNING current_revision` + ) + .one() as { current_revision: number } + ).current_revision; + this.deleteChangesThrough(currentRevision, false); + } + + private deleteChangesThrough(revision: number, preserveBaselines: boolean): void { + const baselineCondition = preserveBaselines ? "AND is_baseline = 0" : ""; + while (true) { + const boundary = ( + this.sql + .exec( + `SELECT revision FROM event_changes + WHERE revision <= ? ${baselineCondition} + ORDER BY revision ASC LIMIT 1 OFFSET ?`, + revision, + EVENT_CHANGE_PRUNE_BATCH_SIZE - 1 + ) + .toArray()[0] as { revision: number } | undefined + )?.revision; + if (boundary === undefined) { + this.sql.exec( + `DELETE FROM event_changes WHERE revision <= ? ${baselineCondition}`, + revision + ); + return; + } + this.sql.exec(`DELETE FROM event_changes WHERE revision <= ? ${baselineCondition}`, boundary); + } + } + + createEventWithinTransaction(data: CreateEventData): void { + const revision = this.nextRevision(); this.sql.exec( - `INSERT INTO events (id, type, data, message_id, created_at, timeline_sequence) - VALUES (?, ?, ?, ?, ?, ${NEXT_TIMELINE_SEQUENCE_SQL})`, + `INSERT INTO events + (id, type, data, message_id, created_at, timeline_sequence, change_revision) + VALUES (?, ?, ?, ?, ?, ${NEXT_TIMELINE_SEQUENCE_SQL}, ?)`, data.id, data.type, data.data, data.messageId, - data.createdAt + data.createdAt, + revision ); + this.appendUpsert(data.id, revision, Date.now()); + } + + createEvent(data: CreateEventData): void { + this.transactionSync(() => this.createEventWithinTransaction(data)); } createContextCompactionEvent(data: CreateEventData & { messageId: string }): void { this.transactionSync(() => { - this.sql.exec( - `UPDATE events SET id = ? WHERE id = ?`, - `token:${data.messageId}:${data.id}`, - `token:${data.messageId}` + const oldId = `token:${data.messageId}`; + const newId = `token:${data.messageId}:${data.id}`; + this.ensureCurrentVersionRecoverable(oldId); + const deleteRevision = this.nextRevision(); + const upsertRevision = this.nextRevision(); + const renamed = this.sql.exec( + `UPDATE events SET id = ?, change_revision = ? WHERE id = ? RETURNING id`, + newId, + upsertRevision, + oldId ); - this.createEvent(data); + if (renamed.toArray().length === 1) { + const changedAt = Date.now(); + this.appendDelete(oldId, deleteRevision, changedAt); + this.appendUpsert(newId, upsertRevision, changedAt); + } + this.createEventWithinTransaction(data); }); } - private upsertEventByMessageId<TType extends UpsertableEventType>( + private upsertEventByMessageIdWithinTransaction<TType extends UpsertableEventType>( type: TType, messageId: string, event: Extract<SandboxEvent, { type: TType }>, createdAt: number ): void { const id = `${type}:${messageId}`; + this.ensureCurrentVersionRecoverable(id); + const revision = this.nextRevision(); this.sql.exec( - `INSERT INTO events (id, type, data, message_id, created_at, timeline_sequence) - VALUES (?, ?, ?, ?, ?, ${NEXT_TIMELINE_SEQUENCE_SQL}) + `INSERT INTO events + (id, type, data, message_id, created_at, timeline_sequence, change_revision) + VALUES (?, ?, ?, ?, ?, ${NEXT_TIMELINE_SEQUENCE_SQL}, ?) ON CONFLICT(id) DO UPDATE SET - data = excluded.data, - message_id = excluded.message_id, - created_at = excluded.created_at`, + data = excluded.data, + message_id = excluded.message_id, + created_at = excluded.created_at, + change_revision = excluded.change_revision`, id, type, JSON.stringify(event), messageId, - createdAt + createdAt, + revision ); + this.appendUpsert(id, revision, Date.now()); } upsertTokenEvent(messageId: string, event: TokenEvent, createdAt: number): void { - this.upsertEventByMessageId("token", messageId, event, createdAt); + this.transactionSync(() => + this.upsertEventByMessageIdWithinTransaction("token", messageId, event, createdAt) + ); } upsertToolCallEvent(messageId: string, event: ToolCallEvent, createdAt: number): void { const id = `tool_call:${toolCallIdentityKey(event)}`; - this.sql.exec( - `INSERT INTO events (id, type, data, message_id, created_at, timeline_sequence) - VALUES (?, ?, ?, ?, ?, ${NEXT_TIMELINE_SEQUENCE_SQL}) - ON CONFLICT(id) DO UPDATE SET - data = excluded.data, - message_id = excluded.message_id`, - id, - event.type, - JSON.stringify(event), - messageId, - createdAt - ); + this.transactionSync(() => { + this.ensureCurrentVersionRecoverable(id); + const revision = this.nextRevision(); + this.sql.exec( + `INSERT INTO events + (id, type, data, message_id, created_at, timeline_sequence, change_revision) + VALUES (?, ?, ?, ?, ?, ${NEXT_TIMELINE_SEQUENCE_SQL}, ?) + ON CONFLICT(id) DO UPDATE SET + data = excluded.data, + message_id = excluded.message_id, + change_revision = excluded.change_revision`, + id, + event.type, + JSON.stringify(event), + messageId, + createdAt, + revision + ); + this.appendUpsert(id, revision, Date.now()); + }); + } + + upsertExecutionCompleteEventWithinTransaction( + messageId: string, + event: ExecutionCompleteEvent, + createdAt: number + ): void { + this.upsertEventByMessageIdWithinTransaction("execution_complete", messageId, event, createdAt); } upsertExecutionCompleteEvent( @@ -127,7 +427,17 @@ export class EventRepository { event: ExecutionCompleteEvent, createdAt: number ): void { - this.upsertEventByMessageId("execution_complete", messageId, event, createdAt); + this.transactionSync(() => + this.upsertExecutionCompleteEventWithinTransaction(messageId, event, createdAt) + ); + } + + deleteEventWithinTransaction(eventId: string): boolean { + this.ensureCurrentVersionRecoverable(eventId); + const deleted = this.sql.exec(`DELETE FROM events WHERE id = ? RETURNING id`, eventId); + if (deleted.toArray().length === 0) return false; + this.appendDelete(eventId, this.nextRevision(), Date.now()); + return true; } listEventPage(options: ListEventPageOptions): EventPage { @@ -139,6 +449,155 @@ export class EventRepository { return { ...page, events: [...page.events].reverse() }; } + listEventChanges(options: ListEventChangesOptions): EventChangePage { + const stateBeforePrune = options.cursor + ? (this.sql + .exec( + `SELECT cursor_scope, current_revision + FROM event_feed_state WHERE singleton = 1` + ) + .one() as { cursor_scope: string; current_revision: number }) + : null; + const cursorWasValid = + stateBeforePrune !== null && + options.cursor !== undefined && + options.cursor.scope === stateBeforePrune.cursor_scope && + options.cursor.checkpoint <= stateBeforePrune.current_revision && + (options.cursor.mode !== "changes" || options.cursor.revision <= options.cursor.checkpoint); + this.transactionSync(() => this.pruneChanges(Date.now())); + try { + return this.listEventChangesWithinTransaction(options); + } catch (cause) { + if (cursorWasValid && cause instanceof InvalidEventFeedCursorError) { + throw new EventFeedCheckpointExpiredError("Event feed checkpoint expired"); + } + throw cause; + } + } + + private listEventChangesWithinTransaction(options: ListEventChangesOptions): EventChangePage { + const state = this.sql + .exec( + `SELECT cursor_scope, current_revision, retention_floor + FROM event_feed_state WHERE singleton = 1` + ) + .one() as { + cursor_scope: string; + current_revision: number; + retention_floor: number; + }; + const highWater = state.current_revision; + const scope = state.cursor_scope; + if ( + options.cursor && + (options.cursor.scope !== scope || + options.cursor.checkpoint > highWater || + (options.cursor.mode === "changes" && options.cursor.revision > options.cursor.checkpoint)) + ) { + throw new InvalidEventFeedCursorError("Invalid event feed cursor"); + } + if (options.after !== undefined && options.after > highWater) { + throw new InvalidEventFeedCursorError("Invalid event feed checkpoint"); + } + + const checkpoint = options.cursor?.checkpoint ?? highWater; + const mode = options.cursor?.mode ?? (options.after === undefined ? "snapshot" : "changes"); + const position = options.cursor?.mode === "changes" ? options.cursor.revision : options.after; + if ( + (mode === "changes" && position !== undefined && position < state.retention_floor) || + (options.cursor?.mode === "snapshot" && checkpoint < state.retention_floor) + ) { + throw new EventFeedCheckpointExpiredError("Event feed checkpoint expired"); + } + const rows = + mode === "snapshot" + ? this.listSnapshotChanges(checkpoint, state.retention_floor, options.cursor, options.limit) + : this.listJournalChanges(checkpoint, options.after, options.cursor, options.limit); + const hasMore = rows.length > options.limit; + const changes = hasMore ? rows.slice(0, options.limit) : rows; + const last = changes[changes.length - 1]; + return { + changes, + checkpoint, + hasMore, + nextCursor: + hasMore && last + ? last.kind === "upsert" && mode === "snapshot" + ? { + mode, + scope, + checkpoint, + createdAt: last.created_at!, + timelineSequence: last.timeline_sequence!, + } + : { mode: "changes", scope, checkpoint, revision: last.revision } + : null, + }; + } + + private listSnapshotChanges( + checkpoint: number, + retentionFloor: number, + cursor: EventFeedCursor | undefined, + limit: number + ): EventChangeRow[] { + const position = cursor?.mode === "snapshot" ? cursor : null; + const condition = position + ? `AND (change.created_at > ? OR + (change.created_at = ? AND change.timeline_sequence > ?))` + : ""; + const params = position + ? [ + checkpoint, + retentionFloor, + checkpoint, + position.createdAt, + position.createdAt, + position.timelineSequence, + limit + 1, + ] + : [checkpoint, retentionFloor, checkpoint, limit + 1]; + return this.sql + .exec( + `WITH versions AS ( + SELECT revision, kind, event_id, type, data, message_id, created_at, timeline_sequence + FROM event_changes WHERE revision <= ? + AND (revision > ? OR is_baseline = 1) + UNION + SELECT change_revision, 'upsert', id, type, data, message_id, created_at, + timeline_sequence + FROM events WHERE change_revision <= ? + ), latest AS ( + SELECT event_id, MAX(revision) AS revision FROM versions GROUP BY event_id + ) + SELECT change.* FROM versions AS change + JOIN latest USING (event_id, revision) + WHERE change.kind = 'upsert' ${condition} + ORDER BY change.created_at ASC, change.timeline_sequence ASC LIMIT ?`, + ...params + ) + .toArray() as EventChangeRow[]; + } + + private listJournalChanges( + checkpoint: number, + after: number | undefined, + cursor: EventFeedCursor | undefined, + limit: number + ): EventChangeRow[] { + const position = cursor?.mode === "changes" ? cursor.revision : (after ?? 0); + return this.sql + .exec( + `SELECT * FROM event_changes + WHERE revision > ? AND revision <= ? + ORDER BY revision ASC LIMIT ?`, + position, + checkpoint, + limit + 1 + ) + .toArray() as EventChangeRow[]; + } + private queryEventPage(options: QueryEventPageOptions): EventPage { let query = `SELECT * FROM events`; const conditions: string[] = []; diff --git a/packages/control-plane/src/session/event-stream.ts b/packages/control-plane/src/session/event-stream.ts index 8277e41258..fab0c2e7ad 100644 --- a/packages/control-plane/src/session/event-stream.ts +++ b/packages/control-plane/src/session/event-stream.ts @@ -10,7 +10,8 @@ import { type EventTimelineCursor, } from "./event-cursor"; import type { EventRow } from "./types"; -import type { EventRepository } from "./event-repository"; +import type { EventFeedCursor, EventRepository, ListEventChangesOptions } from "./event-repository"; +import type { SessionEventChangePage } from "./contracts"; import { sessionTimelineEventSchema, type ServerMessage, @@ -87,6 +88,80 @@ export class SessionEventStream { hasMore: page.hasMore, }; } + + listEventChanges(request: ListEventChangesOptions): SessionEventChangePage { + const page = this.repository.listEventChanges(request); + return { + changes: page.changes.map((change) => + change.kind === "delete" + ? { kind: change.kind, revision: change.revision, eventId: change.event_id } + : { + kind: change.kind, + revision: change.revision, + event: toEventResponse({ + id: change.event_id, + type: change.type!, + data: change.data!, + message_id: change.message_id, + created_at: change.created_at!, + timeline_sequence: change.timeline_sequence!, + }), + } + ), + checkpoint: page.checkpoint, + ...(page.nextCursor === null ? {} : { cursor: encodeEventChangeCursor(page.nextCursor) }), + hasMore: page.hasMore, + }; + } +} + +export function encodeEventChangeCursor(cursor: EventFeedCursor): string { + return btoa(JSON.stringify(cursor)).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, ""); +} + +export function parseEventChangeCursor(value: string): EventFeedCursor | null { + try { + const encoded = value.replaceAll("-", "+").replaceAll("_", "/"); + const parsed = JSON.parse( + atob(encoded.padEnd(Math.ceil(encoded.length / 4) * 4, "=")) + ) as Record<string, unknown>; + if ( + (parsed.mode !== "snapshot" && parsed.mode !== "changes") || + typeof parsed.scope !== "string" || + !/^[a-f0-9]{32}$/.test(parsed.scope) || + !isCheckpoint(parsed.checkpoint) + ) { + return null; + } + if ( + parsed.mode === "snapshot" && + isCheckpoint(parsed.createdAt) && + isCheckpoint(parsed.timelineSequence) + ) { + return { + mode: parsed.mode, + scope: parsed.scope, + checkpoint: parsed.checkpoint, + createdAt: parsed.createdAt, + timelineSequence: parsed.timelineSequence, + }; + } + if (parsed.mode === "changes" && isCheckpoint(parsed.revision)) { + return { + mode: parsed.mode, + scope: parsed.scope, + checkpoint: parsed.checkpoint, + revision: parsed.revision, + }; + } + return null; + } catch { + return null; + } +} + +function isCheckpoint(value: unknown): value is number { + return Number.isSafeInteger(value) && (value as number) >= 0; } function parseSessionTimelineEvents(rows: EventRow[]): SessionTimelineEvent[] { diff --git a/packages/control-plane/src/session/http/handlers/attachments.handler.test.ts b/packages/control-plane/src/session/http/handlers/attachments.handler.test.ts index e9b2fc9733..0fbcbe4c3f 100644 --- a/packages/control-plane/src/session/http/handlers/attachments.handler.test.ts +++ b/packages/control-plane/src/session/http/handlers/attachments.handler.test.ts @@ -18,6 +18,7 @@ function buildHandler(options?: { }) { const repository = { create: vi.fn(), + get: vi.fn(), getTotals: vi.fn(() => options?.totals ?? { count: 0, totalBytes: 0 }), claimStale: vi.fn(() => options?.stale ?? []), acknowledgeCleanup: vi.fn(), diff --git a/packages/control-plane/src/session/http/handlers/attachments.handler.ts b/packages/control-plane/src/session/http/handlers/attachments.handler.ts index fc076f2ccb..6d1992e1b3 100644 --- a/packages/control-plane/src/session/http/handlers/attachments.handler.ts +++ b/packages/control-plane/src/session/http/handlers/attachments.handler.ts @@ -51,6 +51,13 @@ export class AttachmentsHandler { } const record = command.data; + const existing = this.repository.get(record.attachmentId); + if (existing) { + return existing.mime_type === record.mimeType && existing.size_bytes === record.sizeBytes + ? Response.json({ status: "ok" }) + : Response.json({ error: "Attachment idempotency conflict" }, { status: 409 }); + } + const timestamp = this.now(); const stale = this.repository.claimStale( timestamp - SESSION_ATTACHMENT_UNREFERENCED_TTL_MS, diff --git a/packages/control-plane/src/session/http/handlers/messages.handler.test.ts b/packages/control-plane/src/session/http/handlers/messages.handler.test.ts index fe057f0ec8..9316752806 100644 --- a/packages/control-plane/src/session/http/handlers/messages.handler.test.ts +++ b/packages/control-plane/src/session/http/handlers/messages.handler.test.ts @@ -1,13 +1,16 @@ import { describe, expect, it, vi } from "vitest"; import type { Logger } from "../../../logger"; import { MessagesHandler } from "./messages.handler"; +import { EventFeedCheckpointExpiredError } from "../../event-repository"; import type { MessageService } from "../../services/message.service"; +import { encodeEventChangeCursor } from "../../event-stream"; function createHandler() { const messageService = { enqueuePrompt: vi.fn(), stop: vi.fn(), listEvents: vi.fn(), + listEventChanges: vi.fn(), listArtifacts: vi.fn(), getArtifact: vi.fn(), listMessages: vi.fn(), @@ -184,7 +187,7 @@ describe("MessagesHandler", () => { createdAt: 1000, }, ], - cursor: "1000", + cursor: "opaque-cursor", hasMore: false, }); @@ -192,7 +195,7 @@ describe("MessagesHandler", () => { expect(response.status).toBe(200); expect(await response.json()).toEqual({ events: [{ id: "e1", type: "token", data: { x: 1 }, messageId: "m1", createdAt: 1000 }], - cursor: "1000", + cursor: "opaque-cursor", hasMore: false, }); expect(messageService.listEvents).toHaveBeenCalledWith({ @@ -224,6 +227,68 @@ describe("MessagesHandler", () => { }); }); + it("parses bounded event change feeds and continuation cursors", async () => { + const { handler, messageService } = createHandler(); + vi.mocked(messageService.listEventChanges).mockReturnValue({ + changes: [], + checkpoint: 12, + hasMore: false, + }); + + expect( + handler.listEventChanges(new URL("http://internal/internal/event-changes?after=4&limit=20")) + .status + ).toBe(200); + expect(messageService.listEventChanges).toHaveBeenLastCalledWith({ + after: 4, + cursor: undefined, + limit: 20, + }); + + const cursor = { + mode: "changes" as const, + scope: "a".repeat(32), + checkpoint: 12, + revision: 8, + }; + handler.listEventChanges( + new URL( + `http://internal/internal/event-changes?cursor=${encodeURIComponent(encodeEventChangeCursor(cursor))}&limit=20` + ) + ); + expect(messageService.listEventChanges).toHaveBeenLastCalledWith({ + after: undefined, + cursor, + limit: 20, + }); + }); + + it.each(["?after=-1", "?limit=501", "?cursor=bad", "?after=1&cursor=1%3A2%3A1"])( + "rejects invalid event change query %s", + (search) => { + const { handler, messageService } = createHandler(); + const response = handler.listEventChanges( + new URL(`http://internal/internal/event-changes${search}`) + ); + expect(response.status).toBe(400); + expect(messageService.listEventChanges).not.toHaveBeenCalled(); + } + ); + + it("returns 410 when an event checkpoint has expired", async () => { + const { handler, messageService } = createHandler(); + vi.mocked(messageService.listEventChanges).mockImplementation(() => { + throw new EventFeedCheckpointExpiredError("Event feed checkpoint expired"); + }); + + const response = handler.listEventChanges( + new URL("http://internal/internal/event-changes?after=1") + ); + + expect(response.status).toBe(410); + await expect(response.json()).resolves.toEqual({ error: "Event feed checkpoint expired" }); + }); + it("returns artifacts from service unchanged", async () => { const { handler, messageService } = createHandler(); vi.mocked(messageService.listArtifacts).mockReturnValue({ @@ -319,7 +384,7 @@ describe("MessagesHandler", () => { completedAt: 1200, }, ], - cursor: "1000", + cursor: undefined, hasMore: false, }); @@ -345,7 +410,6 @@ describe("MessagesHandler", () => { completedAt: 1200, }, ], - cursor: "1000", hasMore: false, }); }); @@ -366,7 +430,7 @@ describe("MessagesHandler", () => { completedAt: null, }, ], - cursor: "1000", + cursor: undefined, hasMore: false, }); diff --git a/packages/control-plane/src/session/http/handlers/messages.handler.ts b/packages/control-plane/src/session/http/handlers/messages.handler.ts index 296d0ddb86..b7c1427cbc 100644 --- a/packages/control-plane/src/session/http/handlers/messages.handler.ts +++ b/packages/control-plane/src/session/http/handlers/messages.handler.ts @@ -6,7 +6,13 @@ import { } from "../../enqueue-prompt-contract"; import type { MessageService } from "../../services/message.service"; import { parseEventListCursor } from "../../event-cursor"; +import { parseEventChangeCursor } from "../../event-stream"; +import { + EventFeedCheckpointExpiredError, + InvalidEventFeedCursorError, +} from "../../event-repository"; import { SessionAttachmentError } from "../../session-attachment-resolver"; +import { parseCreatedAtIdCursor } from "../../list-cursor"; import { PromptQueueFullError, PromptRequestConflictError, @@ -87,25 +93,75 @@ export class MessagesHandler { return Response.json(result); } + listEventChanges(url: URL): Response { + const cursorValue = url.searchParams.get("cursor"); + const afterValue = url.searchParams.get("after"); + if (cursorValue && afterValue !== null) { + return Response.json({ error: "after and cursor are mutually exclusive" }, { status: 400 }); + } + const cursor = cursorValue ? parseEventChangeCursor(cursorValue) : null; + const after = afterValue === null ? undefined : Number(afterValue); + const limitValue = url.searchParams.get("limit"); + const limit = limitValue === null ? 100 : Number(limitValue); + if ( + (cursorValue && !cursor) || + (after !== undefined && (!Number.isSafeInteger(after) || after < 0)) || + !Number.isSafeInteger(limit) || + limit < 1 || + limit > 500 + ) { + return Response.json({ error: "Invalid event change query" }, { status: 400 }); + } + try { + return Response.json( + this.messageService.listEventChanges({ after, cursor: cursor ?? undefined, limit }) + ); + } catch (error) { + if (error instanceof EventFeedCheckpointExpiredError) { + return Response.json({ error: error.message }, { status: 410 }); + } + if (error instanceof InvalidEventFeedCursorError) { + return Response.json({ error: error.message }, { status: 400 }); + } + throw error; + } + } + listArtifacts(url: URL): Response { const artifactId = url.searchParams.get("artifactId"); if (artifactId) { return Response.json(this.messageService.getArtifact(artifactId)); } - return Response.json(this.messageService.listArtifacts()); + const rawLimit = url.searchParams.get("limit"); + if (rawLimit === null) return Response.json(this.messageService.listArtifacts()); + const limit = Number(rawLimit); + const rawCursor = url.searchParams.get("cursor"); + const cursor = parseCreatedAtIdCursor(rawCursor); + if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100 || (rawCursor && !cursor)) { + return Response.json({ error: "Invalid artifact pagination" }, { status: 400 }); + } + return Response.json(this.messageService.listArtifacts({ cursor, limit })); } listMessages(url: URL): Response { - const cursor = url.searchParams.get("cursor"); + const rawCursor = url.searchParams.get("cursor"); + const cursor = parseCreatedAtIdCursor(rawCursor); const limit = Math.min(parseInt(url.searchParams.get("limit") ?? "50"), 100); const status = url.searchParams.get("status"); if ( - status && - !VALID_MESSAGE_STATUSES.includes(status as (typeof VALID_MESSAGE_STATUSES)[number]) + (status && + !VALID_MESSAGE_STATUSES.includes(status as (typeof VALID_MESSAGE_STATUSES)[number])) || + (rawCursor !== null && !cursor) ) { - return Response.json({ error: `Invalid message status: ${status}` }, { status: 400 }); + return Response.json( + { + error: + rawCursor !== null && !cursor ? "Invalid cursor" : `Invalid message status: ${status}`, + }, + { status: 400 } + ); } const result = this.messageService.listMessages({ cursor, limit, status }); diff --git a/packages/control-plane/src/session/http/handlers/session-init.handler.test.ts b/packages/control-plane/src/session/http/handlers/session-init.handler.test.ts index aedbfa5db3..2b2dcc6627 100644 --- a/packages/control-plane/src/session/http/handlers/session-init.handler.test.ts +++ b/packages/control-plane/src/session/http/handlers/session-init.handler.test.ts @@ -10,11 +10,15 @@ function createHandler() { const repository = { upsertSession: vi.fn(), replaceSessionRepositories: vi.fn(), + getSession: vi.fn(), + getInitializationFingerprint: vi.fn(), + setInitializationFingerprint: vi.fn(), transaction: vi.fn((callback: () => void) => callback()), createParticipant: vi.fn(), }; const sandboxRepository = { createSandbox: vi.fn(), + getSandbox: vi.fn(), } as unknown as SandboxRepository; const encryptScmToken = vi.fn(); const generateId = vi.fn(); @@ -43,6 +47,7 @@ function createHandler() { // repeating it at every invocation. const handler = { init: (request: Request) => sessionInitHandler.init(request, log), + ensure: (request: Request) => sessionInitHandler.ensure(request, log), }; return { @@ -500,4 +505,72 @@ describe("SessionInitHandler", () => { default_model: getValidModelOrDefault("invalid/model-name"), }); }); + + it("re-drives pending warm scheduling after init committed before scheduling", async () => { + const { handler, repository, sandboxRepository, generateId, scheduleWarmSandbox } = + createHandler(); + generateId.mockReturnValueOnce("sandbox-1").mockReturnValueOnce("participant-1"); + scheduleWarmSandbox.mockImplementationOnce(() => { + throw new Error("crash after commit"); + }); + const body = { + sessionName: "session-public-id", + repoOwner: null, + repoName: null, + repositories: [], + userId: "user-1", + canonicalUserId: "user-1", + }; + + await expect( + handler.init( + new Request("http://internal/internal/init", { + method: "POST", + body: JSON.stringify(body), + }) + ) + ).rejects.toThrow("crash after commit"); + const fingerprint = repository.setInitializationFingerprint.mock.calls[0]![0]; + repository.getSession.mockReturnValue({ id: "session-do-id", status: "created" }); + repository.getInitializationFingerprint.mockReturnValue(fingerprint); + vi.mocked(sandboxRepository.getSandbox).mockReturnValue({ status: "pending" } as never); + + const response = await handler.ensure( + new Request("http://internal/internal/ensure-bootstrap", { + method: "POST", + body: JSON.stringify(body), + }) + ); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ + status: "ensured", + sessionStatus: "created", + }); + expect(scheduleWarmSandbox).toHaveBeenCalledTimes(2); + expect(repository.upsertSession).toHaveBeenCalledTimes(1); + }); + + it("rejects a bootstrap ensure fingerprint mismatch without rewriting state", async () => { + const { handler, repository, sandboxRepository, scheduleWarmSandbox } = createHandler(); + repository.getSession.mockReturnValue({ id: "session-do-id" }); + repository.getInitializationFingerprint.mockReturnValue("different-fingerprint"); + vi.mocked(sandboxRepository.getSandbox).mockReturnValue({ status: "pending" } as never); + + const response = await handler.ensure( + new Request("http://internal/internal/ensure-bootstrap", { + method: "POST", + body: JSON.stringify({ + sessionName: "session-public-id", + repoOwner: null, + repoName: null, + userId: "user-1", + }), + }) + ); + + expect(response.status).toBe(409); + expect(repository.upsertSession).not.toHaveBeenCalled(); + expect(scheduleWarmSandbox).not.toHaveBeenCalled(); + }); }); diff --git a/packages/control-plane/src/session/http/handlers/session-init.handler.ts b/packages/control-plane/src/session/http/handlers/session-init.handler.ts index 0b695efd05..c903d0e7cd 100644 --- a/packages/control-plane/src/session/http/handlers/session-init.handler.ts +++ b/packages/control-plane/src/session/http/handlers/session-init.handler.ts @@ -31,7 +31,7 @@ const spawnSourceSchema = z.enum([ * The router constructs this from SessionInitInput — see session/initialize.ts. * Note: `userId` here is the participantUserId from SessionInitInput. */ -const initRequestSchema = z.object({ +const sessionBootstrapRequestSchema = z.strictObject({ sessionName: z.string(), repoOwner: z.string().nullable(), repoName: z.string().nullable(), @@ -72,9 +72,11 @@ const initRequestSchema = z.object({ * SandboxSettings later, so the shape is validated at the use site instead. */ sandboxSettings: z.unknown().optional(), + /** Adapter-owned request identity included in the canonical bootstrap fingerprint. */ + requestFingerprint: z.string().optional(), }); -type InitRequest = z.infer<typeof initRequestSchema>; +type SessionBootstrapRequest = z.infer<typeof sessionBootstrapRequestSchema>; /** * HTTP boundary for `/internal/init` — the Durable Object side of session @@ -103,31 +105,28 @@ export class SessionInitHandler { return Response.json({ error: "Invalid request body" }, { status: 400 }); } - const parseResult = initRequestSchema.safeParse(raw); + const parseResult = sessionBootstrapRequestSchema.safeParse(raw); if (!parseResult.success) { return Response.json({ error: "Invalid request body" }, { status: 400 }); } - const body: InitRequest = parseResult.data; + const body: SessionBootstrapRequest = parseResult.data; const sessionId = this.durableObjectId; const sessionName = body.sessionName; const now = this.now(); - const repoOwner = body.repoOwner?.trim() || null; - const repoName = body.repoName?.trim() || null; - const hasRepoOwner = repoOwner !== null; - const hasRepoName = repoName !== null; - const hasRepoId = body.repoId != null; - if ( - hasRepoOwner !== hasRepoName || - (!hasRepoOwner && hasRepoId) || - (hasRepoOwner && !hasRepoId) - ) { - return Response.json( - { error: "Repository context must include repoOwner, repoName, and repoId together" }, - { status: 400 } - ); - } + const prepared = prepareSessionBootstrap(body, log); + if (prepared instanceof Response) return prepared; + const { + repoOwner, + repoName, + hasRepoOwner, + baseBranch, + repositories: memberRepositories, + sandboxSettings, + model, + reasoningEffort, + } = prepared; let encryptedToken = body.scmTokenEncrypted ?? null; if (body.scmToken) { @@ -141,44 +140,28 @@ export class SessionInitHandler { } } - const model = getValidModelOrDefault(body.model); - if (body.model && !isValidModel(body.model)) { - log.warn("Invalid model name, using default", { - requested_model: body.model, - default_model: model, - }); - } + const initializationFingerprint = await createInitializationFingerprint({ + body, + repoOwner, + repoName, + baseBranch, + repositories: memberRepositories, + model, + reasoningEffort, + sandboxSettings, + encryptedToken, + }); - const reasoningEffort = validateReasoningEffort(model, body.reasoningEffort ?? undefined, log); - const baseBranch = hasRepoOwner - ? body.branch || body.defaultBranch || DEFAULT_BASE_BRANCH - : null; - - const repositories = body.repositories ?? []; - if (repositories.length > 0) { - const primary = repositories[0]; - if ( - !hasRepoOwner || - primary.repoOwner !== repoOwner || - primary.repoName !== repoName || - primary.repoId !== body.repoId || - primary.baseBranch !== baseBranch - ) { - return Response.json( - { error: "repositories[0] must match the scalar repository mirror" }, - { status: 400 } - ); + const transactionResult = this.sessionCoreRepository.transaction< + { kind: "initialized" } | { kind: "existing"; status: string } | { kind: "conflict" } + >(() => { + const existing = this.sessionCoreRepository.getSession(); + if (existing) { + return this.sessionCoreRepository.getInitializationFingerprint() === + initializationFingerprint + ? { kind: "existing", status: existing.status } + : { kind: "conflict" }; } - } else if (hasRepoOwner && body.repositories !== undefined) { - // An explicit empty list alongside scalar context is a producer bug — - // initialize.ts synthesizes a one-entry list for scalar callers. - return Response.json( - { error: "repositories must include the scalar repository" }, - { status: 400 } - ); - } - - this.sessionCoreRepository.transaction(() => { this.sessionCoreRepository.upsertSession({ id: sessionId, sessionName, @@ -195,9 +178,7 @@ export class SessionInitHandler { spawnDepth: body.spawnDepth ?? 0, codeServerEnabled: body.codeServerEnabled ?? false, vncEnabled: body.vncEnabled ?? false, - sandboxSettings: body.sandboxSettings - ? JSON.stringify(normalizeSandboxSettings(body.sandboxSettings, { invalid: "omit" })) - : null, + sandboxSettings, environmentId: body.environmentId ?? null, createdAt: now, updatedAt: now, @@ -205,12 +186,6 @@ export class SessionInitHandler { // Legacy scalar producers (spawn paths not yet list-aware) still get a // member row so spawn/read paths have one source of truth. - const memberRepositories: RepositoryRef[] = - repositories.length > 0 - ? repositories - : repoOwner !== null && repoName !== null && body.repoId != null && baseBranch !== null - ? [{ repoOwner, repoName, repoId: body.repoId, baseBranch }] - : []; this.sessionCoreRepository.replaceSessionRepositories( memberRepositories.map((repo, position) => ({ position, @@ -243,11 +218,177 @@ export class SessionInitHandler { role: "owner", joinedAt: now, }); + this.sessionCoreRepository.setInitializationFingerprint(initializationFingerprint); + return { kind: "initialized" }; }); - log.info("Triggering sandbox spawn for new session"); - this.scheduleWarmSandbox(); + if (transactionResult.kind === "conflict") { + return Response.json({ error: "Session initialization conflict" }, { status: 409 }); + } + if (transactionResult.kind === "existing" && !this.sandboxRepository.getSandbox()) { + return Response.json({ error: "Session sandbox not found" }, { status: 409 }); + } + + if ( + transactionResult.kind === "initialized" || + this.sandboxRepository.getSandbox()?.status === "pending" + ) { + log.info("Triggering sandbox spawn for new session"); + this.scheduleWarmSandbox(); + } - return Response.json({ sessionId, status: "created" }); + return Response.json({ + sessionId, + status: transactionResult.kind === "existing" ? transactionResult.status : "created", + }); } + + async ensure(request: Request, log: Logger): Promise<Response> { + let raw: unknown; + try { + raw = await request.json(); + } catch { + return Response.json({ error: "Invalid request body" }, { status: 400 }); + } + const parsed = sessionBootstrapRequestSchema.safeParse(raw); + if (!parsed.success) return Response.json({ error: "Invalid request body" }, { status: 400 }); + const body = parsed.data; + const initialized = await this.init( + new Request(request.url, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }), + log + ); + if (!initialized.ok) return initialized; + const result = (await initialized.json()) as { status: string }; + return Response.json({ status: "ensured", sessionStatus: result.status }); + } +} + +interface InitializationFingerprintInput { + body: SessionBootstrapRequest; + repoOwner: string | null; + repoName: string | null; + baseBranch: string | null; + repositories: RepositoryRef[]; + model: string; + reasoningEffort: string | null; + sandboxSettings: string | null; + encryptedToken: string | null; +} + +interface PreparedSessionBootstrap { + repoOwner: string | null; + repoName: string | null; + hasRepoOwner: boolean; + baseBranch: string | null; + repositories: RepositoryRef[]; + model: string; + reasoningEffort: string | null; + sandboxSettings: string | null; +} + +function prepareSessionBootstrap( + body: SessionBootstrapRequest, + log: Logger +): PreparedSessionBootstrap | Response { + const repoOwner = body.repoOwner?.trim() || null; + const repoName = body.repoName?.trim() || null; + const hasRepoOwner = repoOwner !== null; + const hasRepoName = repoName !== null; + const hasRepoId = body.repoId != null; + if ( + hasRepoOwner !== hasRepoName || + (!hasRepoOwner && hasRepoId) || + (hasRepoOwner && !hasRepoId) + ) { + return Response.json( + { error: "Repository context must include repoOwner, repoName, and repoId together" }, + { status: 400 } + ); + } + const baseBranch = hasRepoOwner ? body.branch || body.defaultBranch || DEFAULT_BASE_BRANCH : null; + const requestedRepositories = body.repositories ?? []; + if (requestedRepositories.length > 0) { + const primary = requestedRepositories[0]; + if ( + !hasRepoOwner || + primary.repoOwner !== repoOwner || + primary.repoName !== repoName || + primary.repoId !== body.repoId || + primary.baseBranch !== baseBranch + ) { + return Response.json( + { error: "repositories[0] must match the scalar repository mirror" }, + { status: 400 } + ); + } + } else if (hasRepoOwner && body.repositories !== undefined) { + return Response.json( + { error: "repositories must include the scalar repository" }, + { status: 400 } + ); + } + const model = getValidModelOrDefault(body.model); + if (body.model && !isValidModel(body.model)) { + log.warn("Invalid model name, using default", { + requested_model: body.model, + default_model: model, + }); + } + return { + repoOwner, + repoName, + hasRepoOwner, + baseBranch, + repositories: + requestedRepositories.length > 0 + ? requestedRepositories + : repoOwner !== null && repoName !== null && body.repoId != null && baseBranch !== null + ? [{ repoOwner, repoName, repoId: body.repoId, baseBranch }] + : [], + model, + reasoningEffort: validateReasoningEffort(model, body.reasoningEffort ?? undefined, log), + sandboxSettings: body.sandboxSettings + ? JSON.stringify(normalizeSandboxSettings(body.sandboxSettings, { invalid: "omit" })) + : null, + }; +} + +async function createInitializationFingerprint( + input: InitializationFingerprintInput +): Promise<string> { + const { body } = input; + const canonical = JSON.stringify({ + sessionName: body.sessionName, + title: body.title ?? null, + repoOwner: input.repoOwner, + repoName: input.repoName, + repoId: body.repoId ?? null, + baseBranch: input.baseBranch, + repositories: input.repositories, + environmentId: body.environmentId ?? null, + model: input.model, + reasoningEffort: input.reasoningEffort, + codeServerEnabled: body.codeServerEnabled ?? false, + vncEnabled: body.vncEnabled ?? false, + sandboxSettings: input.sandboxSettings, + userId: body.userId, + canonicalUserId: body.canonicalUserId ?? null, + scmLogin: body.scmLogin ?? null, + scmName: body.scmName ?? null, + scmEmail: body.scmEmail ?? null, + scmUserId: body.scmUserId ?? null, + scmCredential: body.scmToken ?? input.encryptedToken, + scmRefreshTokenEncrypted: body.scmRefreshTokenEncrypted ?? null, + scmTokenExpiresAt: body.scmTokenExpiresAt ?? null, + parentSessionId: body.parentSessionId ?? null, + spawnSource: body.spawnSource ?? "user", + spawnDepth: body.spawnDepth ?? 0, + requestFingerprint: body.requestFingerprint ?? null, + }); + const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(canonical)); + return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join(""); } diff --git a/packages/control-plane/src/session/http/routes.test.ts b/packages/control-plane/src/session/http/routes.test.ts index d92ce5d3ba..280785c3c5 100644 --- a/packages/control-plane/src/session/http/routes.test.ts +++ b/packages/control-plane/src/session/http/routes.test.ts @@ -10,6 +10,7 @@ describe("createSessionInternalRoutes", () => { it("builds the expected method/path mapping", () => { const routes = createSessionInternalRoutes({ init: noopHandler(), + ensureBootstrap: noopHandler(), state: noopHandler(), snapshot: noopHandler(), sandboxAccess: noopHandler(), @@ -22,6 +23,7 @@ describe("createSessionInternalRoutes", () => { recordAttachment: noopHandler(), listParticipants: noopHandler(), listEvents: noopHandler(), + listEventChanges: noopHandler(), listArtifacts: noopHandler(), listMessages: noopHandler(), createPr: noopHandler(), @@ -55,6 +57,7 @@ describe("createSessionInternalRoutes", () => { expect(methodPathSet).toEqual( new Set([ `POST ${SessionInternalPaths.init}`, + `POST ${SessionInternalPaths.ensureBootstrap}`, `GET ${SessionInternalPaths.snapshot}`, `GET ${SessionInternalPaths.sandboxAccess}`, `GET ${SessionInternalPaths.state}`, @@ -67,6 +70,7 @@ describe("createSessionInternalRoutes", () => { `POST ${SessionInternalPaths.attachments}`, `GET ${SessionInternalPaths.participants}`, `GET ${SessionInternalPaths.events}`, + `GET ${SessionInternalPaths.eventChanges}`, `GET ${SessionInternalPaths.artifacts}`, `GET ${SessionInternalPaths.messages}`, `POST ${SessionInternalPaths.createPr}`, diff --git a/packages/control-plane/src/session/http/routes.ts b/packages/control-plane/src/session/http/routes.ts index 132e709a9b..2cf2327415 100644 --- a/packages/control-plane/src/session/http/routes.ts +++ b/packages/control-plane/src/session/http/routes.ts @@ -22,6 +22,7 @@ export interface SessionInternalRoute { /** Handlers required to serve every internal SessionDO HTTP route. */ export interface SessionInternalRouteHandlers { init: SessionInternalRouteHandler; + ensureBootstrap: SessionInternalRouteHandler; state: SessionInternalRouteHandler; snapshot: SessionInternalRouteHandler; sandboxAccess: SessionInternalRouteHandler; @@ -34,6 +35,7 @@ export interface SessionInternalRouteHandlers { recordAttachment: SessionInternalRouteHandler; listParticipants: SessionInternalRouteHandler; listEvents: SessionInternalRouteHandler; + listEventChanges: SessionInternalRouteHandler; listArtifacts: SessionInternalRouteHandler; listMessages: SessionInternalRouteHandler; createPr: SessionInternalRouteHandler; @@ -71,6 +73,11 @@ export function createSessionInternalRoutes( ): SessionInternalRoute[] { return [ { method: "POST", path: SessionInternalPaths.init, handler: handlers.init }, + { + method: "POST", + path: SessionInternalPaths.ensureBootstrap, + handler: handlers.ensureBootstrap, + }, { method: "GET", path: SessionInternalPaths.state, handler: handlers.state }, { method: "GET", path: SessionInternalPaths.snapshot, handler: handlers.snapshot }, { @@ -95,6 +102,11 @@ export function createSessionInternalRoutes( handler: handlers.listParticipants, }, { method: "GET", path: SessionInternalPaths.events, handler: handlers.listEvents }, + { + method: "GET", + path: SessionInternalPaths.eventChanges, + handler: handlers.listEventChanges, + }, { method: "GET", path: SessionInternalPaths.artifacts, handler: handlers.listArtifacts }, { method: "GET", path: SessionInternalPaths.messages, handler: handlers.listMessages }, { method: "POST", path: SessionInternalPaths.createPr, handler: handlers.createPr }, diff --git a/packages/control-plane/src/session/initialize.ts b/packages/control-plane/src/session/initialize.ts index ccbb5de73b..8d433e3ce9 100644 --- a/packages/control-plane/src/session/initialize.ts +++ b/packages/control-plane/src/session/initialize.ts @@ -75,6 +75,9 @@ export interface SessionInitInput { managedSkillsSourceSessionId?: string; /** Complete, immutable provider routing snapshot resolved by the caller. */ providerAuth: SessionModelProviderAuthInput[]; + requestFingerprint?: string; + /** Retry payload for external creates; persisted atomically with the D1 reservation. */ + externalBootstrapSnapshot?: string; } /** @@ -158,6 +161,8 @@ export async function initializeSession( skillManifest: input.managedSkillsManifest, skillManifestSourceSessionId: input.managedSkillsSourceSessionId, providerAuth: input.providerAuth, + externalRequestFingerprint: input.requestFingerprint, + externalBootstrapSnapshot: input.externalBootstrapSnapshot, }); // Step 2: DO init @@ -203,6 +208,7 @@ export async function initializeSession( parentSessionId: input.parentSessionId, spawnSource: input.spawnSource, spawnDepth: input.spawnDepth, + requestFingerprint: input.requestFingerprint, }), }) ); diff --git a/packages/control-plane/src/session/list-cursor.ts b/packages/control-plane/src/session/list-cursor.ts new file mode 100644 index 0000000000..12bda065bf --- /dev/null +++ b/packages/control-plane/src/session/list-cursor.ts @@ -0,0 +1,29 @@ +export interface CreatedAtIdCursor { + createdAt: number; + id: string; +} + +export function encodeCreatedAtIdCursor(cursor: CreatedAtIdCursor): string { + const bytes = new TextEncoder().encode(JSON.stringify(cursor)); + const encoded = btoa(String.fromCharCode(...bytes)); + return encoded.replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, ""); +} + +export function parseCreatedAtIdCursor(raw: string | null): CreatedAtIdCursor | null { + if (raw === null) return null; + try { + const base64 = raw.replaceAll("-", "+").replaceAll("_", "/"); + const binary = atob(base64.padEnd(Math.ceil(base64.length / 4) * 4, "=")); + const value = JSON.parse( + new TextDecoder().decode(Uint8Array.from(binary, (character) => character.charCodeAt(0))) + ) as Partial<CreatedAtIdCursor>; + return Number.isSafeInteger(value.createdAt) && + value.createdAt! >= 0 && + typeof value.id === "string" && + value.id.length > 0 + ? { createdAt: value.createdAt!, id: value.id } + : null; + } catch { + return null; + } +} diff --git a/packages/control-plane/src/session/message-queue.ts b/packages/control-plane/src/session/message-queue.ts index df854e5c2b..75d98ca47e 100644 --- a/packages/control-plane/src/session/message-queue.ts +++ b/packages/control-plane/src/session/message-queue.ts @@ -702,7 +702,6 @@ export class SessionMessageQueue { data: EnqueuePromptRequest ): Promise<{ messageId: string; status: "queued" }> { this.assertPromptableSession(); - this.assertQueueCapacity(); let participant = this.participantService.getByUserId(data.authorId); if (!participant) { const name = data.scmEnrichment?.name || data.authorId; @@ -744,6 +743,7 @@ export class SessionMessageQueue { reasoningEffort: data.reasoningEffort, attachments: data.attachments, callbackContext: data.callbackContext, + clientRequestId: data.clientRequestId, }); await this.processMessageQueue(); diff --git a/packages/control-plane/src/session/message-repository.test.ts b/packages/control-plane/src/session/message-repository.test.ts index 7ccca04831..0dccf4f4f4 100644 --- a/packages/control-plane/src/session/message-repository.test.ts +++ b/packages/control-plane/src/session/message-repository.test.ts @@ -14,6 +14,7 @@ function createMockSql() { const matchingData: Array<{ pattern: RegExp; rows: unknown[] }> = []; let oneValue: unknown = null; let rowsWritten = 0; + let eventRevision = 0; const sql: SqlStorage = { exec(query: string, ...params: unknown[]): SqlResult { calls.push({ query, params }); @@ -25,7 +26,14 @@ function createMockSql() { data.get(query) ?? matchingData.find(({ pattern }) => pattern.test(query))?.rows ?? [] ); }, - one: () => oneValue, + one: () => + query.includes("RETURNING current_revision") + ? { current_revision: ++eventRevision } + : query.includes("AS time_floor") + ? { existing_floor: 0, time_floor: null, count_floor: 0, byte_floor: 0 } + : query.includes("AS baseline_bytes") + ? { baseline_bytes: 0, baseline_count: 0, total_bytes: 0, total_count: 0 } + : oneValue, get rowsWritten() { return consumed ? rowsWritten : 0; }, @@ -390,7 +398,9 @@ describe("MessageRepository", () => { expect(mock.calls[0].query).toContain("status = 'processing'"); expect(mock.calls[0].query).toContain("status = 'pending'"); expect(mock.calls[0].query).toContain("NOT EXISTS"); - expect(mock.calls[1].params[0]).toBe("user_message:msg-1"); + expect(mock.calls.find(({ query }) => query.includes("INSERT INTO events"))?.params[0]).toBe( + "user_message:msg-1" + ); }); it("does not create a user event when the processing claim is lost", () => { @@ -410,10 +420,15 @@ describe("MessageRepository", () => { mock.setMatchingData(/UPDATE messages SET status = 'pending'[\s\S]*RETURNING id/, [ { id: "msg-1" }, ]); + mock.setMatchingData(/DELETE FROM events[\s\S]*RETURNING id/, [{ id: "user_message:msg-1" }]); repository.updateMessageToPending("msg-1"); expect(mock.calls[0].query).toContain("status = 'pending'"); expect(mock.calls[0].params).toEqual(["msg-1"]); expect(mock.calls[1].params).toEqual(["user_message:msg-1"]); + const journalWrite = mock.calls.find(({ query }) => + query.includes("INSERT INTO event_changes") + ); + expect(journalWrite?.params.slice(0, 2)).toEqual([1, "user_message:msg-1"]); }); it("atomically records message completion and its canonical event", () => { @@ -435,7 +450,9 @@ describe("MessageRepository", () => { status: "completed", }); expect(transactionSyncCalls).toBe(1); - expect(mock.calls[2].params[0]).toBe("execution_complete:msg-1"); + expect(mock.calls.find(({ query }) => query.includes("INSERT INTO events"))?.params[0]).toBe( + "execution_complete:msg-1" + ); }); it("does not complete a message in another state", () => { @@ -467,10 +484,15 @@ describe("MessageRepository", () => { }); it("builds message list pagination filters", () => { - repository.listMessages({ limit: 10, status: "pending", cursor: "5000" }); + repository.listMessages({ + limit: 10, + status: "pending", + cursor: { createdAt: 5000, id: "message-5" }, + }); expect(mock.calls[0].query).toContain("status = ?"); - expect(mock.calls[0].query).toContain("created_at < ?"); - expect(mock.calls[0].params).toEqual(["pending", 5000, 11]); + expect(mock.calls[0].query).toContain("created_at = ? AND id < ?"); + expect(mock.calls[0].query).toContain("ORDER BY created_at DESC, id DESC"); + expect(mock.calls[0].params).toEqual(["pending", 5000, 5000, "message-5", 11]); }); it("selects the latest terminal message", () => { diff --git a/packages/control-plane/src/session/message-repository.ts b/packages/control-plane/src/session/message-repository.ts index 8dc4b2f68f..e0c2e392c5 100644 --- a/packages/control-plane/src/session/message-repository.ts +++ b/packages/control-plane/src/session/message-repository.ts @@ -6,6 +6,7 @@ import type { CreateEventData, EventRepository } from "./event-repository"; import type { SessionAttachmentRepository } from "./session-attachment-repository"; import type { SqlResult, SqlStorage, TransactionSync } from "./sql-storage"; import type { MessageRow } from "./types"; +import type { CreatedAtIdCursor } from "./list-cursor"; type ExecutionCompleteEvent = Extract<SandboxEvent, { type: "execution_complete" }>; @@ -55,7 +56,7 @@ export type AutofixMessageAdmission = /** Options for listing messages. */ export interface ListMessagesOptions { - cursor?: string | null; + cursor?: CreatedAtIdCursor | null; limit: number; status?: string | null; } @@ -309,7 +310,7 @@ export class MessageRepository { this.transactionSync(() => { this.attachments.claimForMessage(data.id, attachmentIds); this.createMessage(data); - if (event) this.eventRepository.createEvent(event); + if (event) this.eventRepository.createEventWithinTransaction(event); }); } @@ -329,7 +330,7 @@ export class MessageRepository { ); if (claimed.toArray().length !== 1) return false; - this.eventRepository.createEvent({ + this.eventRepository.createEventWithinTransaction({ id: `user_message:${messageId}`, type: "user_message", data: JSON.stringify(userMessageEvent), @@ -349,7 +350,7 @@ export class MessageRepository { messageId ); if (updated.toArray().length === 1) { - this.sql.exec(`DELETE FROM events WHERE id = ?`, `user_message:${messageId}`); + this.eventRepository.deleteEventWithinTransaction(`user_message:${messageId}`); } }); } @@ -381,7 +382,11 @@ export class MessageRepository { event.success ? null : (event.error ?? null), event.messageId ); - this.eventRepository.upsertExecutionCompleteEvent(event.messageId, event, completedAt); + this.eventRepository.upsertExecutionCompleteEventWithinTransaction( + event.messageId, + event, + completedAt + ); return { messageId: event.messageId, @@ -410,11 +415,11 @@ export class MessageRepository { } if (options.cursor) { - query += ` AND created_at < ?`; - params.push(parseInt(options.cursor)); + query += ` AND (created_at < ? OR (created_at = ? AND id < ?))`; + params.push(options.cursor.createdAt, options.cursor.createdAt, options.cursor.id); } - query += ` ORDER BY created_at DESC LIMIT ?`; + query += ` ORDER BY created_at DESC, id DESC LIMIT ?`; params.push(options.limit + 1); const result = this.sql.exec(query, ...params); diff --git a/packages/control-plane/src/session/schema.test.ts b/packages/control-plane/src/session/schema.test.ts index 3625154b1d..4a87c776c1 100644 --- a/packages/control-plane/src/session/schema.test.ts +++ b/packages/control-plane/src/session/schema.test.ts @@ -4,6 +4,7 @@ import { DatabaseSync, type SQLInputValue } from "node:sqlite"; import { describe, it, expect, beforeEach, vi } from "vitest"; +import { EventRepository } from "./event-repository"; import { applyMigrations, initSchema, MIGRATIONS, SCHEMA_SQL } from "./schema"; import type { SqlResult, SqlStorage } from "./sql-storage"; @@ -591,4 +592,236 @@ describe("applyMigrations", () => { db.close(); } }); + + it("backfills monotonic event change revisions", () => { + const migration = MIGRATIONS.find((entry) => entry.id === 47); + expect(typeof migration?.run).toBe("function"); + const db = new DatabaseSync(":memory:"); + const sql = createDatabaseSql(db); + try { + db.exec(`CREATE TABLE events ( + id TEXT PRIMARY KEY, + timeline_sequence INTEGER NOT NULL UNIQUE + )`); + db.exec(`INSERT INTO events (id, timeline_sequence) VALUES ('first', 2), ('second', 7)`); + + const run = migration!.run as (sql: SqlStorage) => void; + run(sql); + + expect( + db.prepare("SELECT id, change_revision FROM events ORDER BY change_revision").all() + ).toEqual([ + { id: "first", change_revision: 2 }, + { id: "second", change_revision: 7 }, + ]); + expect(db.prepare("SELECT revision FROM event_revision_state WHERE id = 1").get()).toEqual({ + revision: 7, + }); + } finally { + db.close(); + } + }); + + it("migrates current events into the immutable journal deterministically", () => { + const revisionMigration = MIGRATIONS.find((entry) => entry.id === 47)!; + const journalMigration = MIGRATIONS.find((entry) => entry.id === 48)!; + const db = new DatabaseSync(":memory:"); + const sql = createDatabaseSql(db); + try { + db.exec(`CREATE TABLE events ( + id TEXT PRIMARY KEY, + type TEXT NOT NULL, + data TEXT NOT NULL, + message_id TEXT, + created_at INTEGER NOT NULL, + timeline_sequence INTEGER NOT NULL UNIQUE + )`); + db.exec(`INSERT INTO events VALUES + ('later', 'token', '{}', NULL, 20, 1), + ('earlier', 'token', '{}', NULL, 10, 2)`); + (revisionMigration.run as (sql: SqlStorage) => void)(sql); + (journalMigration.run as (sql: SqlStorage) => void)(sql); + expect(() => (journalMigration.run as (sql: SqlStorage) => void)(sql)).not.toThrow(); + + expect( + db.prepare("SELECT revision, kind, event_id FROM event_changes ORDER BY revision").all() + ).toEqual([ + { revision: 1, kind: "upsert", event_id: "earlier" }, + { revision: 2, kind: "upsert", event_id: "later" }, + ]); + expect(db.prepare("PRAGMA table_info(events)").all()).not.toEqual( + expect.arrayContaining([expect.objectContaining({ name: "change_revision" })]) + ); + expect(() => + db.prepare("INSERT INTO event_changes (kind, event_id) VALUES ('upsert', 'invalid')").run() + ).toThrow(); + expect( + db.prepare("SELECT length(cursor_scope) AS length FROM event_feed_state").get() + ).toEqual({ length: 32 }); + } finally { + db.close(); + } + }); + + it("preserves legacy versions and safely retries the bounded-journal migration", () => { + const revisionMigration = MIGRATIONS.find((entry) => entry.id === 47)!; + const journalMigration = MIGRATIONS.find((entry) => entry.id === 48)!; + const boundedMigration = MIGRATIONS.find((entry) => entry.id === 50)!; + const db = new DatabaseSync(":memory:"); + const sql = createDatabaseSql(db); + try { + db.exec(`CREATE TABLE events ( + id TEXT PRIMARY KEY, + type TEXT NOT NULL, + data TEXT NOT NULL, + message_id TEXT, + created_at INTEGER NOT NULL, + timeline_sequence INTEGER NOT NULL UNIQUE + )`); + db.exec(`INSERT INTO events VALUES + ('first', 'token', '{"content":"latest"}', 'message-1', 10, 1), + ('second', 'tool_call', '{}', 'message-1', 20, 2)`); + (revisionMigration.run as (sql: SqlStorage) => void)(sql); + (journalMigration.run as (sql: SqlStorage) => void)(sql); + db.exec(`INSERT INTO event_changes + (kind, event_id, type, data, message_id, created_at, timeline_sequence) + VALUES ('upsert', 'first', 'token', '{"content":"latest"}', 'message-1', 10, 1)`); + + const run = boundedMigration.run as (sql: SqlStorage) => void; + run(sql); + db.exec(`ALTER TABLE event_changes RENAME TO event_changes_versioned`); + expect(() => run(sql)).not.toThrow(); + + expect( + db + .prepare(`SELECT event_id, revision, kind, data FROM event_changes ORDER BY revision ASC`) + .all() + ).toEqual([ + { + event_id: "first", + revision: 1, + kind: "upsert", + data: '{"content":"latest"}', + }, + { event_id: "second", revision: 2, kind: "upsert", data: "{}" }, + { + event_id: "first", + revision: 3, + kind: "upsert", + data: '{"content":"latest"}', + }, + ]); + expect( + db + .prepare( + `SELECT current_revision, retention_floor FROM event_feed_state WHERE singleton = 1` + ) + .get() + ).toEqual({ current_revision: 3, retention_floor: 0 }); + expect(db.prepare(`SELECT id, change_revision FROM events ORDER BY id`).all()).toEqual([ + { id: "first", change_revision: 3 }, + { id: "second", change_revision: 2 }, + ]); + expect(db.prepare(`SELECT COUNT(*) AS count FROM event_changes`).get()).toEqual({ count: 3 }); + } finally { + db.close(); + } + }); + + it("gives fresh and migrated databases the same event feed columns", () => { + const fresh = new DatabaseSync(":memory:"); + const migrated = new DatabaseSync(":memory:"); + try { + initSchema(createDatabaseSql(fresh)); + migrated.exec(`CREATE TABLE events ( + id TEXT PRIMARY KEY, + type TEXT NOT NULL, + data TEXT NOT NULL, + message_id TEXT, + created_at INTEGER NOT NULL, + timeline_sequence INTEGER NOT NULL UNIQUE + )`); + const sql = createDatabaseSql(migrated); + for (const id of [47, 48, 50]) { + const migration = MIGRATIONS.find((entry) => entry.id === id)!; + (migration.run as (sql: SqlStorage) => void)(sql); + } + + for (const table of ["events", "event_changes", "event_feed_state"]) { + const columns = (db: DatabaseSync) => + db + .prepare(`PRAGMA table_info(${table})`) + .all() + .map(({ name, type, notnull, dflt_value, pk }) => ({ + name, + type, + notnull, + dflt_value, + pk, + })); + expect(columns(migrated)).toEqual(columns(fresh)); + } + } finally { + fresh.close(); + migrated.close(); + } + }); + + it("rotates scope and rejects cursors from the prior coalesced journal", () => { + const migration = MIGRATIONS.find((entry) => entry.id === 51)!; + const db = new DatabaseSync(":memory:"); + const sql = createDatabaseSql(db); + const oldScope = "a".repeat(32); + try { + db.exec(`CREATE TABLE events ( + id TEXT PRIMARY KEY, + type TEXT NOT NULL, + data TEXT NOT NULL, + message_id TEXT, + created_at INTEGER NOT NULL, + timeline_sequence INTEGER NOT NULL UNIQUE, + change_revision INTEGER + ); + CREATE TABLE event_feed_state ( + singleton INTEGER PRIMARY KEY, + cursor_scope TEXT NOT NULL, + current_revision INTEGER NOT NULL DEFAULT 0, + retention_floor INTEGER NOT NULL DEFAULT 0 + ); + CREATE TABLE event_changes ( + event_id TEXT PRIMARY KEY, + revision INTEGER NOT NULL UNIQUE, + kind TEXT NOT NULL, + type TEXT, + data TEXT, + message_id TEXT, + created_at INTEGER, + timeline_sequence INTEGER, + changed_at INTEGER NOT NULL + ); + INSERT INTO event_feed_state VALUES (1, '${oldScope}', 2, 0); + INSERT INTO events VALUES ('event-1', 'token', '{}', NULL, 1, 1, 2); + INSERT INTO event_changes VALUES + ('event-1', 2, 'upsert', 'token', '{}', NULL, 1, 1, 1);`); + + (migration.run as (sql: SqlStorage) => void)(sql); + + const state = db + .prepare(`SELECT cursor_scope, current_revision, retention_floor FROM event_feed_state`) + .get() as { cursor_scope: string; current_revision: number; retention_floor: number }; + expect(state).toMatchObject({ current_revision: 2, retention_floor: 2 }); + expect(state.cursor_scope).not.toBe(oldScope); + expect(db.prepare(`SELECT COUNT(*) AS count FROM event_changes`).get()).toEqual({ count: 0 }); + + const repository = new EventRepository(sql, (closure) => closure()); + expect(() => + repository.listEventChanges({ + cursor: { mode: "changes", scope: oldScope, checkpoint: 2, revision: 1 }, + limit: 10, + }) + ).toThrow("Invalid event feed cursor"); + } finally { + db.close(); + } + }); }); diff --git a/packages/control-plane/src/session/schema.ts b/packages/control-plane/src/session/schema.ts index 8ee706494f..0559e47bfd 100644 --- a/packages/control-plane/src/session/schema.ts +++ b/packages/control-plane/src/session/schema.ts @@ -47,6 +47,62 @@ const SESSION_ALARM_STATE_TABLE_SQL = `CREATE TABLE IF NOT EXISTS session_alarm_ cancelled INTEGER NOT NULL DEFAULT 0 );`; +const EVENT_CHANGES_TABLE_SQL = `CREATE TABLE IF NOT EXISTS event_changes ( + revision INTEGER PRIMARY KEY AUTOINCREMENT, + kind TEXT NOT NULL CHECK (kind IN ('upsert', 'delete')), + event_id TEXT NOT NULL, + type TEXT, + data TEXT, + message_id TEXT, + created_at INTEGER, + timeline_sequence INTEGER, + CHECK ( + (kind = 'upsert' AND type IS NOT NULL AND data IS NOT NULL + AND created_at IS NOT NULL AND timeline_sequence IS NOT NULL) + OR + (kind = 'delete' AND type IS NULL AND data IS NULL + AND message_id IS NULL AND created_at IS NULL AND timeline_sequence IS NULL) + ) +)`; + +const EVENT_FEED_STATE_TABLE_SQL = `CREATE TABLE IF NOT EXISTS event_feed_state ( + singleton INTEGER PRIMARY KEY CHECK (singleton = 1), + cursor_scope TEXT NOT NULL +)`; + +const CURRENT_EVENT_CHANGES_TABLE_SQL = `CREATE TABLE IF NOT EXISTS event_changes ( + revision INTEGER PRIMARY KEY, + kind TEXT NOT NULL CHECK (kind IN ('upsert', 'delete')), + event_id TEXT NOT NULL, + type TEXT, + data TEXT, + message_id TEXT, + created_at INTEGER, + timeline_sequence INTEGER, + changed_at INTEGER NOT NULL, + journal_bytes INTEGER NOT NULL CHECK (journal_bytes >= 0), + is_baseline INTEGER NOT NULL DEFAULT 0 CHECK (is_baseline IN (0, 1)), + CHECK ( + (kind = 'upsert' AND type IS NOT NULL AND data IS NOT NULL + AND created_at IS NOT NULL AND timeline_sequence IS NOT NULL) + OR + (kind = 'delete' AND type IS NULL AND data IS NULL + AND message_id IS NULL AND created_at IS NULL AND timeline_sequence IS NULL) + ) +)`; + +const CURRENT_EVENT_FEED_STATE_TABLE_SQL = `CREATE TABLE IF NOT EXISTS event_feed_state ( + singleton INTEGER PRIMARY KEY CHECK (singleton = 1), + cursor_scope TEXT NOT NULL, + current_revision INTEGER NOT NULL DEFAULT 0, + retention_floor INTEGER NOT NULL DEFAULT 0 +)`; + +const SESSION_BOOTSTRAP_TABLE_SQL = `CREATE TABLE IF NOT EXISTS session_bootstrap ( + singleton INTEGER PRIMARY KEY CHECK (singleton = 1), + initialization_fingerprint TEXT NOT NULL +)`; + export const SCHEMA_SQL = ` -- Core session state CREATE TABLE IF NOT EXISTS session ( @@ -135,9 +191,17 @@ CREATE TABLE IF NOT EXISTS events ( data TEXT NOT NULL, -- JSON payload message_id TEXT, created_at INTEGER NOT NULL, - timeline_sequence INTEGER NOT NULL UNIQUE + timeline_sequence INTEGER NOT NULL UNIQUE, + change_revision INTEGER ); +${CURRENT_EVENT_CHANGES_TABLE_SQL}; +${CURRENT_EVENT_FEED_STATE_TABLE_SQL}; +INSERT OR IGNORE INTO event_feed_state (singleton, cursor_scope) +VALUES (1, lower(hex(randomblob(16)))); + +${SESSION_BOOTSTRAP_TABLE_SQL}; + -- Artifacts (PRs, screenshots, video recordings, preview URLs) CREATE TABLE IF NOT EXISTS artifacts ( id TEXT PRIMARY KEY, @@ -226,11 +290,18 @@ CREATE INDEX IF NOT EXISTS idx_events_message ON events(message_id); CREATE INDEX IF NOT EXISTS idx_events_type ON events(type); CREATE INDEX IF NOT EXISTS idx_events_created_at ON events(created_at, id); CREATE UNIQUE INDEX IF NOT EXISTS idx_events_timeline_sequence ON events(timeline_sequence); +CREATE INDEX IF NOT EXISTS idx_event_changes_event_revision +ON event_changes(event_id, revision); CREATE INDEX IF NOT EXISTS idx_participants_user ON participants(user_id); `; import { createLogger } from "../logger"; import type { SqlStorage } from "./sql-storage"; +import { + EVENT_CHANGE_JOURNAL_BYTE_LIMIT, + EVENT_CHANGE_RETENTION_LIMIT, + EVENT_CHANGE_RETENTION_MS, +} from "./types"; const schemaLog = createLogger("schema"); @@ -630,8 +701,239 @@ export const MIGRATIONS: readonly SchemaMigration[] = [ ); }, }, + { + id: 47, + description: "Add monotonic event change revisions", + run: (sql) => { + runMigration(sql, `ALTER TABLE events ADD COLUMN change_revision INTEGER`); + sql.exec(`CREATE TABLE IF NOT EXISTS event_revision_state ( + id INTEGER PRIMARY KEY CHECK (id = 1), + revision INTEGER NOT NULL + )`); + sql.exec(`UPDATE events SET change_revision = timeline_sequence + WHERE change_revision IS NULL`); + sql.exec(`INSERT OR IGNORE INTO event_revision_state (id, revision) + SELECT 1, COALESCE(MAX(change_revision), 0) FROM events`); + sql.exec(`UPDATE event_revision_state SET revision = + MAX(revision, COALESCE((SELECT MAX(change_revision) FROM events), 0)) WHERE id = 1`); + sql.exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_events_change_revision + ON events(change_revision)`); + }, + }, + { + id: 48, + description: "Add immutable event change journal", + run: (sql) => { + sql.exec(EVENT_CHANGES_TABLE_SQL); + sql.exec(EVENT_FEED_STATE_TABLE_SQL); + sql.exec(`INSERT OR IGNORE INTO event_feed_state (singleton, cursor_scope) + VALUES (1, lower(hex(randomblob(16))))`); + sql.exec(`INSERT INTO event_changes + (kind, event_id, type, data, message_id, created_at, timeline_sequence) + SELECT 'upsert', id, type, data, message_id, created_at, timeline_sequence + FROM events + WHERE NOT EXISTS (SELECT 1 FROM event_changes) + ORDER BY created_at ASC, timeline_sequence ASC`); + sql.exec(`DROP INDEX IF EXISTS idx_events_change_revision`); + if (tableHasColumn(sql, "events", "change_revision")) { + sql.exec(`ALTER TABLE events DROP COLUMN change_revision`); + } + sql.exec(`DROP TABLE IF EXISTS event_revision_state`); + }, + }, + { + id: 49, + description: "Persist canonical session initialization fingerprint", + run: SESSION_BOOTSTRAP_TABLE_SQL, + }, + { + id: 50, + description: "Bound the immutable event version journal", + run: migrateEventVersionJournal, + }, + { + id: 51, + description: "Restore immutable event versions after journal compaction", + run: migrateEventVersionJournal, + }, ]; +function migrateEventVersionJournal(sql: SqlStorage): void { + if (!tableHasColumn(sql, "events", "change_revision")) { + runMigration(sql, `ALTER TABLE events ADD COLUMN change_revision INTEGER`); + } + if (!tableHasColumn(sql, "event_feed_state", "current_revision")) { + runMigration( + sql, + `ALTER TABLE event_feed_state ADD COLUMN current_revision INTEGER NOT NULL DEFAULT 0` + ); + } + if (!tableHasColumn(sql, "event_feed_state", "retention_floor")) { + runMigration( + sql, + `ALTER TABLE event_feed_state ADD COLUMN retention_floor INTEGER NOT NULL DEFAULT 0` + ); + } + + const hasEventChanges = tableExists(sql, "event_changes"); + const hasVersionedChanges = tableExists(sql, "event_changes_versioned"); + const hasCompactedChanges = tableExists(sql, "event_changes_compacted"); + if (!hasEventChanges && !hasVersionedChanges && !hasCompactedChanges) return; + if (!hasEventChanges && hasVersionedChanges) { + sql.exec(`ALTER TABLE event_changes_versioned RENAME TO event_changes`); + } else if (!hasEventChanges && hasCompactedChanges) { + sql.exec(`ALTER TABLE event_changes_compacted RENAME TO event_changes`); + } + + if ( + !tableHasColumn(sql, "event_changes", "journal_bytes") || + !tableHasColumn(sql, "event_changes", "is_baseline") + ) { + const hasChangedAt = tableHasColumn(sql, "event_changes", "changed_at"); + const hasJournalBytes = tableHasColumn(sql, "event_changes", "journal_bytes"); + const hasBaselineMarker = tableHasColumn(sql, "event_changes", "is_baseline"); + const historyWasCoalesced = hasChangedAt && !hasJournalBytes; + const currentRevision = + ( + sql + .exec(`SELECT COALESCE(MAX(revision), 0) AS revision FROM event_changes`) + .toArray()[0] as { revision: number } | undefined + )?.revision ?? 0; + const existingFloor = + ( + sql + .exec(`SELECT retention_floor FROM event_feed_state WHERE singleton = 1`) + .toArray()[0] as { retention_floor: number } | undefined + )?.retention_floor ?? 0; + const countFloor = Math.max(existingFloor, currentRevision - EVENT_CHANGE_RETENTION_LIMIT); + sql.exec(`DROP TABLE IF EXISTS event_changes_versioned`); + sql.exec(CURRENT_EVENT_CHANGES_TABLE_SQL.replace("event_changes", "event_changes_versioned")); + sql.exec( + `INSERT INTO event_changes_versioned + (revision, kind, event_id, type, data, message_id, created_at, timeline_sequence, + changed_at, journal_bytes, is_baseline) + SELECT revision, kind, event_id, type, data, message_id, created_at, timeline_sequence, + ${hasChangedAt ? "changed_at" : "?"}, + ${ + hasJournalBytes + ? "journal_bytes" + : `64 + length(CAST(event_id AS BLOB)) + COALESCE(length(CAST(type AS BLOB)), 0) + + COALESCE(length(CAST(data AS BLOB)), 0) + + COALESCE(length(CAST(message_id AS BLOB)), 0)` + }, + ${hasBaselineMarker ? "is_baseline" : "0"} + FROM event_changes + WHERE revision > ? OR revision IN ( + SELECT MAX(revision) FROM event_changes + WHERE revision <= ? GROUP BY event_id + ) + ORDER BY revision ASC`, + ...(hasChangedAt ? [] : [Date.now()]), + countFloor, + countFloor + ); + const byteFloor = + ( + sql + .exec( + `SELECT MAX(revision) AS revision FROM ( + SELECT revision, SUM(journal_bytes) OVER (ORDER BY revision DESC) AS retained_bytes + FROM event_changes_versioned + ) WHERE retained_bytes > ?`, + EVENT_CHANGE_JOURNAL_BYTE_LIMIT + ) + .toArray()[0] as { revision: number | null } | undefined + )?.revision ?? 0; + const timeFloor = + ( + sql + .exec( + `SELECT MAX(revision) AS revision FROM event_changes_versioned WHERE changed_at <= ?`, + Date.now() - EVENT_CHANGE_RETENTION_MS + ) + .toArray()[0] as { revision: number | null } | undefined + )?.revision ?? 0; + const retentionFloor = Math.max(countFloor, byteFloor, timeFloor); + if (retentionFloor > 0) { + sql.exec( + `UPDATE event_changes_versioned SET is_baseline = 1 + WHERE revision IN ( + SELECT MAX(revision) FROM event_changes_versioned + WHERE revision <= ? GROUP BY event_id + )`, + retentionFloor + ); + sql.exec( + `DELETE FROM event_changes_versioned + WHERE revision <= ? AND is_baseline = 0`, + retentionFloor + ); + } + const retained = (sql + .exec( + `SELECT COALESCE(SUM(journal_bytes), 0) AS total_bytes, + COUNT(*) AS total_count, + COALESCE(SUM(CASE WHEN is_baseline = 1 THEN journal_bytes ELSE 0 END), 0) + AS baseline_bytes + FROM event_changes_versioned` + ) + .toArray()[0] as + | { total_bytes: number; total_count: number; baseline_bytes: number } + | undefined) ?? { total_bytes: 0, total_count: 0, baseline_bytes: 0 }; + const mustRotateHistory = + historyWasCoalesced || + retained.baseline_bytes > EVENT_CHANGE_JOURNAL_BYTE_LIMIT || + retained.total_bytes > EVENT_CHANGE_JOURNAL_BYTE_LIMIT || + retained.total_count > EVENT_CHANGE_RETENTION_LIMIT; + sql.exec( + `UPDATE event_feed_state SET + current_revision = MAX(current_revision, ?), + retention_floor = MAX(retention_floor, ?) + WHERE singleton = 1`, + currentRevision, + retentionFloor + ); + sql.exec(`DROP TABLE event_changes`); + sql.exec(`ALTER TABLE event_changes_versioned RENAME TO event_changes`); + if (mustRotateHistory) { + sql.exec(`UPDATE event_feed_state SET + cursor_scope = lower(hex(randomblob(16))), + retention_floor = current_revision + WHERE singleton = 1`); + sql.exec(`DELETE FROM event_changes`); + } + } + + const currentRevision = + ( + sql.exec(`SELECT COALESCE(MAX(revision), 0) AS revision FROM event_changes`).toArray()[0] as + | { revision: number } + | undefined + )?.revision ?? 0; + sql.exec( + `UPDATE event_feed_state SET current_revision = MAX( + current_revision, ?, COALESCE((SELECT MAX(change_revision) FROM events), 0) + ) WHERE singleton = 1`, + currentRevision + ); + sql.exec(`UPDATE events + SET change_revision = ( + SELECT MAX(revision) FROM event_changes WHERE event_id = events.id AND kind = 'upsert' + ) WHERE change_revision IS NULL`); + sql.exec(`UPDATE events + SET change_revision = + (SELECT current_revision FROM event_feed_state WHERE singleton = 1) + timeline_sequence + WHERE change_revision IS NULL`); + sql.exec(`UPDATE event_feed_state + SET current_revision = MAX( + current_revision, COALESCE((SELECT MAX(change_revision) FROM events), 0) + ) WHERE singleton = 1`); + sql.exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_events_change_revision + ON events(change_revision)`); + sql.exec(`CREATE INDEX IF NOT EXISTS idx_event_changes_event_revision + ON event_changes(event_id, revision)`); +} + /** * Run a migration statement, only ignoring "column already exists" errors. * Rethrows any other errors to surface real problems. @@ -650,6 +952,19 @@ function runMigration(sql: SqlStorage, statement: string): void { } } +function tableHasColumn(sql: SqlStorage, table: string, column: string): boolean { + return (sql.exec(`PRAGMA table_info(${table})`).toArray() as Array<{ name: string }>).some( + (entry) => entry.name === column + ); +} + +function tableExists(sql: SqlStorage, table: string): boolean { + return Boolean( + sql.exec(`SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?`, table).toArray() + .length + ); +} + /** * Apply pending migrations, tracking which have already run via _schema_migrations. */ diff --git a/packages/control-plane/src/session/services/message.service.test.ts b/packages/control-plane/src/session/services/message.service.test.ts index 8ec38db74f..a2985721d5 100644 --- a/packages/control-plane/src/session/services/message.service.test.ts +++ b/packages/control-plane/src/session/services/message.service.test.ts @@ -76,9 +76,27 @@ describe("MessageService", () => { it("paginates events with hasMore and cursor", () => { const { service, eventRepository } = createService(); const events: EventRow[] = [ - { id: "e3", type: "token", data: "{}", message_id: "m1", created_at: 3000 }, - { id: "e2", type: "token", data: "{}", message_id: "m1", created_at: 2000 }, - { id: "e1", type: "token", data: "{}", message_id: "m1", created_at: 1000 }, + { + id: "e3", + type: "token", + data: "{}", + message_id: "m1", + created_at: 3000, + }, + { + id: "e2", + type: "token", + data: "{}", + message_id: "m1", + created_at: 2000, + }, + { + id: "e1", + type: "token", + data: "{}", + message_id: "m1", + created_at: 1000, + }, ]; vi.mocked(eventRepository.listEventPage).mockReturnValue({ events: events.slice(0, 2), @@ -253,7 +271,7 @@ describe("MessageService", () => { const result = service.listMessages({ cursor: null, limit: 2, status: "pending" }); expect(result.hasMore).toBe(true); - expect(result.cursor).toBe("2000"); + expect(result.cursor).toBeDefined(); expect(result.messages).toHaveLength(2); expect(result.messages[0]?.attachments).toEqual([ { diff --git a/packages/control-plane/src/session/services/message.service.ts b/packages/control-plane/src/session/services/message.service.ts index 524db20134..7f496f7c0a 100644 --- a/packages/control-plane/src/session/services/message.service.ts +++ b/packages/control-plane/src/session/services/message.service.ts @@ -4,16 +4,18 @@ import type { ListEventsResponse } from "@open-inspect/shared/types/sandbox-even import type { NormalizedArtifactResponse } from "../artifacts"; import type { MessageRepository } from "../message-repository"; import type { ArtifactRepository } from "../artifact-repository"; -import type { EventRepository } from "../event-repository"; +import type { EventRepository, ListEventChangesOptions } from "../event-repository"; import type { SessionMessageQueue } from "../message-queue"; import type { EnqueuePromptRequest } from "../enqueue-prompt-contract"; import { SessionEventStream, type SessionEventListRequest } from "../event-stream"; +import type { SessionEventChangePage } from "../contracts"; import { parseStoredSessionAttachments } from "../session-attachment-resolver"; +import { encodeCreatedAtIdCursor, type CreatedAtIdCursor } from "../list-cursor"; export type ListEventsRequest = SessionEventListRequest; export interface ListMessagesRequest { - cursor: string | null; + cursor: CreatedAtIdCursor | null; limit: number; status: string | null; } @@ -49,8 +51,19 @@ export class MessageService { return this.eventStream.listEvents(request); } - listArtifacts(): { artifacts: NormalizedArtifactResponse[] } { - const artifacts = this.deps.artifactRepository.listArtifacts(); + listEventChanges(request: ListEventChangesOptions): SessionEventChangePage { + return this.eventStream.listEventChanges(request); + } + + listArtifacts(request?: { cursor: CreatedAtIdCursor | null; limit: number }): { + artifacts: NormalizedArtifactResponse[]; + cursor?: string; + hasMore?: boolean; + } { + const artifacts = this.deps.artifactRepository.listArtifacts(request); + const hasMore = request !== undefined && artifacts.length > request.limit; + if (hasMore) artifacts.pop(); + const last = artifacts.at(-1); return { artifacts: artifacts.map((artifact) => ({ id: artifact.id, @@ -60,6 +73,10 @@ export class MessageService { createdAt: artifact.created_at, updatedAt: artifact.updated_at, })), + ...(request ? { hasMore } : {}), + ...(hasMore && last + ? { cursor: encodeCreatedAtIdCursor({ createdAt: last.created_at, id: last.id }) } + : {}), }; } @@ -106,7 +123,13 @@ export class MessageService { startedAt: message.started_at, completedAt: message.completed_at, })), - cursor: messages.length > 0 ? messages[messages.length - 1].created_at.toString() : undefined, + cursor: + hasMore && messages.length > 0 + ? encodeCreatedAtIdCursor({ + createdAt: messages[messages.length - 1]!.created_at, + id: messages[messages.length - 1]!.id, + }) + : undefined, hasMore, }; } diff --git a/packages/control-plane/src/session/session-attachment-repository.ts b/packages/control-plane/src/session/session-attachment-repository.ts index 8d69c9d220..cf53c5d40d 100644 --- a/packages/control-plane/src/session/session-attachment-repository.ts +++ b/packages/control-plane/src/session/session-attachment-repository.ts @@ -34,6 +34,11 @@ export class SessionAttachmentRepository { ); } + get(id: string): SessionAttachmentRow | null { + const row = this.sql.exec(`SELECT * FROM attachments WHERE id = ?`, id).toArray()[0]; + return row ? (sessionAttachmentRowSchema.parse(row) as SessionAttachmentRow) : null; + } + getTotals(): { count: number; totalBytes: number } { const result = this.sql.exec( `SELECT COUNT(*) as count, COALESCE(SUM(size_bytes), 0) as total_bytes diff --git a/packages/control-plane/src/session/session-core-repository.ts b/packages/control-plane/src/session/session-core-repository.ts index cc4e91d4cf..a578fd4274 100644 --- a/packages/control-plane/src/session/session-core-repository.ts +++ b/packages/control-plane/src/session/session-core-repository.ts @@ -61,6 +61,21 @@ export class SessionCoreRepository { return rows[0] ?? null; } + getInitializationFingerprint(): string | null { + const rows = this.sql + .exec(`SELECT initialization_fingerprint FROM session_bootstrap WHERE singleton = 1`) + .toArray() as Array<{ initialization_fingerprint: string }>; + return rows[0]?.initialization_fingerprint ?? null; + } + + setInitializationFingerprint(fingerprint: string): void { + this.sql.exec( + `INSERT INTO session_bootstrap (singleton, initialization_fingerprint) VALUES (1, ?) + ON CONFLICT(singleton) DO UPDATE SET initialization_fingerprint = excluded.initialization_fingerprint`, + fingerprint + ); + } + upsertSession(data: UpsertSessionData): void { const hasRepoOwner = data.repoOwner !== null; const hasRepoName = data.repoName !== null; diff --git a/packages/control-plane/src/session/types.ts b/packages/control-plane/src/session/types.ts index 626b003953..324dc55801 100644 --- a/packages/control-plane/src/session/types.ts +++ b/packages/control-plane/src/session/types.ts @@ -128,6 +128,10 @@ export const sessionAttachmentRowSchema = z.object({ export type SessionAttachmentRow = z.infer<typeof sessionAttachmentRowSchema>; +export const EVENT_CHANGE_RETENTION_LIMIT = 50_000; +export const EVENT_CHANGE_RETENTION_MS = 24 * 60 * 60 * 1000; +export const EVENT_CHANGE_JOURNAL_BYTE_LIMIT = 16 * 1024 * 1024; + export interface EventRow { id: string; type: EventType; @@ -135,6 +139,21 @@ export interface EventRow { message_id: string | null; created_at: number; timeline_sequence?: number; + change_revision?: number; +} + +export interface EventChangeRow { + revision: number; + kind: "upsert" | "delete"; + event_id: string; + type: EventType | null; + data: string | null; + message_id: string | null; + created_at: number | null; + timeline_sequence: number | null; + changed_at?: number; + journal_bytes?: number; + is_baseline?: number; } export interface ArtifactRow { diff --git a/packages/control-plane/src/types.ts b/packages/control-plane/src/types.ts index d9879b3abd..2b00b9013f 100644 --- a/packages/control-plane/src/types.ts +++ b/packages/control-plane/src/types.ts @@ -36,6 +36,7 @@ export interface Env { GOOGLE_CLIENT_SECRET?: string; BROWSER_AUTH_SECRET?: string; TOKEN_ENCRYPTION_KEY: string; + EXTERNAL_SESSION_ID_SECRET?: string; PROVIDER_ACCOUNTS_ENCRYPTION_KEY: string; REPO_SECRETS_ENCRYPTION_KEY?: string; MODAL_TOKEN_ID?: string; diff --git a/packages/control-plane/test/integration/cleanup.ts b/packages/control-plane/test/integration/cleanup.ts index 10f84a361e..6217baf67d 100644 --- a/packages/control-plane/test/integration/cleanup.ts +++ b/packages/control-plane/test/integration/cleanup.ts @@ -6,6 +6,9 @@ import { env } from "cloudflare:test"; */ export async function cleanD1Tables(): Promise<void> { await env.DB.exec( - "DELETE FROM auth_verifications; DELETE FROM auth_sessions; DELETE FROM automation_model_provider_auth; DELETE FROM automation_slack_channels; DELETE FROM automation_runs; DELETE FROM automation_invocations; DELETE FROM automation_repositories; DELETE FROM automation_environments; DELETE FROM automations; DELETE FROM pr_autofix_feedback; DELETE FROM session_model_provider_auth; DELETE FROM session_read_states; DELETE FROM session_pull_requests; DELETE FROM session_repositories; DELETE FROM child_admission_leases; DELETE FROM session_skill_revisions; DELETE FROM session_skill_manifests; DELETE FROM sessions; DELETE FROM model_provider_account_authorization_attempts; DELETE FROM model_provider_account_authorizations; DELETE FROM model_provider_account_defaults; DELETE FROM model_provider_account_credentials; DELETE FROM model_provider_accounts; DELETE FROM skill_profile_items; DELETE FROM skill_profiles; DELETE FROM skill_assignments; DELETE FROM skill_import_sources; DELETE FROM skill_revision_files; DELETE FROM skill_revisions; DELETE FROM skills; UPDATE skills_catalog_state SET generation = 0 WHERE singleton = 1; DELETE FROM user_scm_tokens; DELETE FROM repo_metadata; DELETE FROM repo_secrets; DELETE FROM global_secrets; DELETE FROM commit_signing_configuration; DELETE FROM integration_settings; DELETE FROM integration_repo_settings; DELETE FROM model_preferences; DELETE FROM mcp_servers; DELETE FROM keyboard_shortcut_preferences; DELETE FROM authorization_audit_events; DELETE FROM user_role_assignments; DELETE FROM role_permissions WHERE role_id IN (SELECT id FROM roles WHERE is_system = 0); DELETE FROM roles WHERE is_system = 0; DELETE FROM user_identities; DELETE FROM users; DELETE FROM image_builds; DELETE FROM environment_secrets; DELETE FROM environment_repositories; DELETE FROM environments;" + "DELETE FROM cli_auth_rate_limits; DELETE FROM cli_credentials; DELETE FROM cli_device_authorization_attempts; DELETE FROM auth_verifications; DELETE FROM auth_sessions; DELETE FROM automation_model_provider_auth; DELETE FROM automation_slack_channels; DELETE FROM automation_runs; DELETE FROM automation_invocations; DELETE FROM automation_repositories; DELETE FROM automation_environments; DELETE FROM automations; DELETE FROM pr_autofix_feedback; DELETE FROM session_model_provider_auth; DELETE FROM session_read_states; DELETE FROM session_pull_requests; DELETE FROM session_repositories; DELETE FROM child_admission_leases; DELETE FROM session_skill_revisions; DELETE FROM session_skill_manifests; DELETE FROM sessions; DELETE FROM model_provider_account_authorization_attempts; DELETE FROM model_provider_account_authorizations; DELETE FROM model_provider_account_defaults; DELETE FROM model_provider_account_credentials; DELETE FROM model_provider_accounts; DELETE FROM skill_profile_items; DELETE FROM skill_profiles; DELETE FROM skill_assignments; DELETE FROM skill_import_sources; DELETE FROM skill_revision_files; DELETE FROM skill_revisions; DELETE FROM skills; UPDATE skills_catalog_state SET generation = 0 WHERE singleton = 1; DELETE FROM user_scm_tokens; DELETE FROM repo_metadata; DELETE FROM repo_secrets; DELETE FROM global_secrets; DELETE FROM commit_signing_configuration; DELETE FROM integration_settings; DELETE FROM integration_repo_settings; DELETE FROM model_preferences; DELETE FROM mcp_servers; DELETE FROM keyboard_shortcut_preferences; DELETE FROM authorization_audit_events; DELETE FROM user_role_assignments; DELETE FROM role_permissions WHERE role_id IN (SELECT id FROM roles WHERE is_system = 0); DELETE FROM roles WHERE is_system = 0; DELETE FROM user_identities; DELETE FROM users; DELETE FROM image_builds; DELETE FROM environment_secrets; DELETE FROM environment_repositories; DELETE FROM environments;" + ); + await env.DB.exec( + "DELETE FROM provider_credential_redaction_history; DELETE FROM mcp_credential_redaction_history; DELETE FROM scm_credential_redaction_history; DELETE FROM managed_secret_redaction_history;" ); } diff --git a/packages/control-plane/test/integration/cli-auth.test.ts b/packages/control-plane/test/integration/cli-auth.test.ts new file mode 100644 index 0000000000..ab2c443ea8 --- /dev/null +++ b/packages/control-plane/test/integration/cli-auth.test.ts @@ -0,0 +1,425 @@ +import { SELF, env } from "cloudflare:test"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + CLI_API_VERSION_HEADER, + CLI_CLIENT_SURFACE_HEADER, + CLI_CLIENT_VERSION_HEADER, + CLI_EXTERNAL_API_VERSION, + cliDeviceAuthorizationExchangeResponseSchema, + cliMeResponseSchema, + pendingCliDeviceAuthorizationResponseSchema, + startCliDeviceAuthorizationResponseSchema, +} from "@open-inspect/shared/types/cli-auth"; +import { cleanD1Tables } from "./cleanup"; +import { hashToken } from "../../src/auth/crypto"; +import { + CLI_CREDENTIAL_RETENTION_MS, + CLI_DEVICE_ATTEMPT_RETENTION_MS, +} from "../../src/cli-auth/device-authorization-service"; +import { CLI_AUTH_RATE_LIMITS } from "../../src/routes/cli-auth"; +import { serviceFetch } from "./helpers"; + +const API = "https://cp.test/external/v1/cli"; +const clientMetadata = { + [CLI_API_VERSION_HEADER]: CLI_EXTERNAL_API_VERSION, + [CLI_CLIENT_VERSION_HEADER]: "integration-test", + [CLI_CLIENT_SURFACE_HEADER]: "cli", +}; + +function authorization(credential: string) { + return { ...clientMetadata, Authorization: `Bearer ${credential}` }; +} + +async function start() { + const response = await SELF.fetch(`${API}/device-authorizations`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ deviceName: "integration laptop" }), + }); + expect(response.status).toBe(201); + return startCliDeviceAuthorizationResponseSchema.parse(await response.json()); +} + +async function exchange(deviceSecret: string): Promise<Response> { + return SELF.fetch(`${API}/device-authorizations/exchange`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ deviceSecret }), + }); +} + +async function revokeIssued(deviceSecret: string): Promise<Response> { + return SELF.fetch(`${API}/device-authorizations/revoke`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ deviceSecret }), + }); +} + +async function approve(userCode: string): Promise<Response> { + return serviceFetch(`${API}/device-authorizations/approve`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ userCode }), + }); +} + +async function pending(userCode: string): Promise<Response> { + return serviceFetch( + `${API}/device-authorizations/pending?user_code=${encodeURIComponent(userCode)}` + ); +} + +describe("external v1 CLI authentication", () => { + beforeEach(cleanD1Tables); + afterEach(async () => { + vi.restoreAllMocks(); + await cleanD1Tables(); + }); + + it("discloses the authoritative installation on pending approval", async () => { + const started = await start(); + const pendingDetails = await pending(started.userCode.toLowerCase()); + + expect(pendingDetails.status).toBe(200); + expect(pendingCliDeviceAuthorizationResponseSchema.parse(await pendingDetails.json())).toEqual({ + installation: { name: "integration-test" }, + deviceName: "integration laptop", + expiresAt: started.expiresAt, + }); + }); + + it("stores hashes only and atomically issues one 30-day user credential", async () => { + const started = await start(); + expect(started.deviceSecret).toMatch(/^[0-9a-f]{64}$/); + expect(started.userCode).toMatch(/^[A-Z0-9]{4}-[A-Z0-9]{4}$/); + expect(started.expiresAt - Date.now()).toBeGreaterThan(9 * 60 * 1000); + expect(started.expiresAt - Date.now()).toBeLessThanOrEqual(10 * 60 * 1000); + + const storedAttempt = await env.DB.prepare( + "SELECT * FROM cli_device_authorization_attempts" + ).first<Record<string, unknown>>(); + expect(storedAttempt).toMatchObject({ device_name: "integration laptop" }); + expect(Object.values(storedAttempt!)).not.toContain(started.deviceSecret); + expect(Object.values(storedAttempt!)).not.toContain(started.userCode); + + const pendingExchange = await exchange(started.deviceSecret); + expect(pendingExchange.status).toBe(202); + expect( + cliDeviceAuthorizationExchangeResponseSchema.parse(await pendingExchange.json()) + ).toMatchObject({ + status: "pending", + }); + + expect((await approve(started.userCode.toLowerCase())).status).toBe(204); + expect((await pending(started.userCode)).status).toBe(409); + + // Isolate the atomic exchange race from the independently tested polling backoff. + await env.DB.prepare("DELETE FROM cli_auth_rate_limits WHERE rate_key = ?") + .bind(`exchange-secret-burst:${await hashToken(started.deviceSecret)}`) + .run(); + + const exchanges = await Promise.all([ + exchange(started.deviceSecret), + exchange(started.deviceSecret), + ]); + expect(exchanges.map((response) => response.status).sort()).toEqual([200, 410]); + const winner = exchanges.find((response) => response.status === 200)!; + const issued = cliDeviceAuthorizationExchangeResponseSchema.parse(await winner.json()); + expect(issued.status).toBe("authorized"); + if (issued.status !== "authorized") throw new Error("Expected authorized exchange"); + expect(issued.expiresAt - Date.now()).toBeGreaterThan(29 * 24 * 60 * 60 * 1000); + + const storedCredential = await env.DB.prepare("SELECT * FROM cli_credentials").first< + Record<string, unknown> + >(); + expect(Object.values(storedCredential!)).not.toContain(issued.credential); + expect(storedCredential).toMatchObject({ user_id: "11111111111111111111111111111111" }); + + const me = await SELF.fetch(`${API}/me`, { + headers: authorization(issued.credential), + }); + expect(me.status).toBe(200); + expect(cliMeResponseSchema.parse(await me.json())).toMatchObject({ + installation: { name: "integration-test" }, + user: { id: "11111111111111111111111111111111" }, + credential: { id: issued.credentialId, expiresAt: issued.expiresAt }, + }); + + const internalBrowserRoute = await SELF.fetch("https://cp.test/sessions", { + headers: { Authorization: `Bearer ${issued.credential}` }, + }); + expect(internalBrowserRoute.status).toBe(401); + }); + + it("denies suspended and missing-role users through current RBAC policy", async () => { + const started = await start(); + expect((await approve(started.userCode)).status).toBe(204); + const response = await exchange(started.deviceSecret); + const issued = cliDeviceAuthorizationExchangeResponseSchema.parse(await response.json()); + if (issued.status !== "authorized") throw new Error("Expected authorized exchange"); + const authorizationHeaders = authorization(issued.credential); + + await env.DB.prepare("UPDATE users SET suspended_at = 1").run(); + const suspended = await SELF.fetch(`${API}/me`, { headers: authorizationHeaders }); + expect(suspended.status).toBe(403); + await expect(suspended.json()).resolves.toMatchObject({ code: "active_user_required" }); + + await env.DB.prepare("UPDATE users SET suspended_at = NULL").run(); + await env.DB.prepare("DELETE FROM user_role_assignments").run(); + const unassigned = await SELF.fetch(`${API}/me`, { headers: authorizationHeaders }); + expect(unassigned.status).toBe(403); + await expect(unassigned.json()).resolves.toMatchObject({ code: "assignment_required" }); + }); + + it("fails closed for expired attempts, expired credentials, and revocation", async () => { + const expiredAttempt = await start(); + await env.DB.prepare("UPDATE cli_device_authorization_attempts SET expires_at = 1").run(); + expect((await pending(expiredAttempt.userCode)).status).toBe(410); + expect((await approve(expiredAttempt.userCode)).status).toBe(410); + expect((await exchange(expiredAttempt.deviceSecret)).status).toBe(410); + + const started = await start(); + expect((await approve(started.userCode)).status).toBe(204); + const issuedResponse = await exchange(started.deviceSecret); + const issued = cliDeviceAuthorizationExchangeResponseSchema.parse(await issuedResponse.json()); + if (issued.status !== "authorized") throw new Error("Expected authorized exchange"); + const authorizationHeaders = authorization(issued.credential); + + const revoke = await SELF.fetch(`${API}/credentials/current`, { + method: "DELETE", + headers: authorizationHeaders, + }); + expect(revoke.status).toBe(204); + expect((await SELF.fetch(`${API}/me`, { headers: authorizationHeaders })).status).toBe(401); + + const second = await start(); + expect((await approve(second.userCode)).status).toBe(204); + const secondIssuedResponse = await exchange(second.deviceSecret); + const secondIssued = cliDeviceAuthorizationExchangeResponseSchema.parse( + await secondIssuedResponse.json() + ); + if (secondIssued.status !== "authorized") throw new Error("Expected authorized exchange"); + await env.DB.prepare("UPDATE cli_credentials SET expires_at = 1").run(); + expect( + ( + await SELF.fetch(`${API}/me`, { + headers: authorization(secondIssued.credential), + }) + ).status + ).toBe(401); + }); + + it("capability revocation before issuance prevents later credential creation", async () => { + const started = await start(); + + expect((await revokeIssued(started.deviceSecret)).status).toBe(204); + expect((await approve(started.userCode)).status).toBe(409); + expect((await exchange(started.deviceSecret)).status).toBe(410); + expect(await env.DB.prepare("SELECT 1 FROM cli_credentials").first()).toBeNull(); + }); + + it("capability revocation links and revokes an issued credential idempotently", async () => { + const started = await start(); + expect((await approve(started.userCode)).status).toBe(204); + const issuedResponse = await exchange(started.deviceSecret); + const issued = cliDeviceAuthorizationExchangeResponseSchema.parse(await issuedResponse.json()); + if (issued.status !== "authorized") throw new Error("Expected authorized exchange"); + + expect((await revokeIssued(started.deviceSecret)).status).toBe(204); + expect((await revokeIssued(started.deviceSecret)).status).toBe(204); + expect( + ( + await SELF.fetch(`${API}/me`, { + headers: authorization(issued.credential), + }) + ).status + ).toBe(401); + await expect( + env.DB.prepare( + "SELECT issued_credential_id, capability_revoked_at FROM cli_device_authorization_attempts" + ).first() + ).resolves.toMatchObject({ + issued_credential_id: issued.credentialId, + capability_revoked_at: expect.any(Number), + }); + }); + + it("returns the same capability result for a wrong secret without revoking the known attempt", async () => { + const started = await start(); + expect((await approve(started.userCode)).status).toBe(204); + const issuedResponse = await exchange(started.deviceSecret); + const issued = cliDeviceAuthorizationExchangeResponseSchema.parse(await issuedResponse.json()); + if (issued.status !== "authorized") throw new Error("Expected authorized exchange"); + + const [knownShape, unknownShape] = await Promise.all([ + revokeIssued(started.deviceSecret), + revokeIssued("f".repeat(64)), + ]); + expect(knownShape.status).toBe(204); + expect(unknownShape.status).toBe(204); + expect(await knownShape.text()).toBe(""); + expect(await unknownShape.text()).toBe(""); + expect( + ( + await SELF.fetch(`${API}/me`, { + headers: authorization(issued.credential), + }) + ).status + ).toBe(401); + }); + + it("returns not found for a well-formed unknown human code", async () => { + expect((await pending("ZZZZ-ZZZZ")).status).toBe(404); + }); + + it("rate limits start by installation/IP and approval by user", async () => { + const headers = { "Content-Type": "application/json", "CF-Connecting-IP": "192.0.2.10" }; + const starts = await Promise.all( + Array.from({ length: CLI_AUTH_RATE_LIMITS.startPerIp.limit + 1 }, () => + SELF.fetch(`${API}/device-authorizations`, { + method: "POST", + headers, + body: JSON.stringify({ deviceName: "rate-limit laptop" }), + }) + ) + ); + expect(starts.filter((response) => response.status === 201)).toHaveLength( + CLI_AUTH_RATE_LIMITS.startPerIp.limit + ); + expect(starts.filter((response) => response.status === 429)).toHaveLength(1); + + for (let index = 0; index < CLI_AUTH_RATE_LIMITS.approvalPerUser.limit; index += 1) { + const code = index.toString(36).toUpperCase().padStart(4, "A"); + expect((await approve(`AAAA-${code}`)).status).toBe(404); + } + const blockedApproval = await approve("BBBB-BBBB"); + expect(blockedApproval.status).toBe(429); + expect(blockedApproval.headers.get("Retry-After")).toMatch(/^\d+$/); + }); + + it("applies the same secret-keyed exchange limit without revealing attempt existence", async () => { + const started = await start(); + const unknownSecret = "f".repeat(64); + const now = Date.now(); + const windowMs = CLI_AUTH_RATE_LIMITS.exchangePerSecret.windowMs; + const windowStartedAt = Math.floor(now / windowMs) * windowMs; + for (const secret of [started.deviceSecret, unknownSecret]) { + await env.DB.prepare( + `INSERT INTO cli_auth_rate_limits + (rate_key, window_started_at, request_count, expires_at) VALUES (?, ?, ?, ?)` + ) + .bind( + `exchange-secret:${await hashToken(secret)}`, + windowStartedAt, + CLI_AUTH_RATE_LIMITS.exchangePerSecret.limit, + windowStartedAt + windowMs + ) + .run(); + } + const [known, unknown] = await Promise.all([ + exchange(started.deviceSecret), + exchange(unknownSecret), + ]); + expect(known.status).toBe(429); + expect(unknown.status).toBe(429); + await expect(known.json()).resolves.toEqual({ error: "Too many requests" }); + await expect(unknown.json()).resolves.toEqual({ error: "Too many requests" }); + }); + + it("rate limits capability revocation identically for known and unknown secrets", async () => { + const started = await start(); + const unknownSecret = "f".repeat(64); + const now = Date.now(); + const windowMs = CLI_AUTH_RATE_LIMITS.capabilityRevokePerSecret.windowMs; + const windowStartedAt = Math.floor(now / windowMs) * windowMs; + for (const secret of [started.deviceSecret, unknownSecret]) { + await env.DB.prepare( + `INSERT INTO cli_auth_rate_limits + (rate_key, window_started_at, request_count, expires_at) VALUES (?, ?, ?, ?)` + ) + .bind( + `capability-revoke-secret:${await hashToken(secret)}`, + windowStartedAt, + CLI_AUTH_RATE_LIMITS.capabilityRevokePerSecret.limit, + windowStartedAt + windowMs + ) + .run(); + } + + const [known, unknown] = await Promise.all([ + revokeIssued(started.deviceSecret), + revokeIssued(unknownSecret), + ]); + expect(known.status).toBe(429); + expect(unknown.status).toBe(429); + await expect(known.json()).resolves.toEqual({ error: "Too many requests" }); + await expect(unknown.json()).resolves.toEqual({ error: "Too many requests" }); + }); + + it("allows correctly paced one-second polling beyond sixty attempts", async () => { + let now = Date.now(); + vi.spyOn(Date, "now").mockImplementation(() => now); + const started = await start(); + + for (let poll = 0; poll < 61; poll += 1) { + now += 1_001; + expect((await exchange(started.deviceSecret)).status).toBe(202); + } + }, 15_000); + + it("opportunistically prunes retained attempts, credentials, and stale counters", async () => { + await pending("ZZZZ-ZZZZ"); + const now = Date.now(); + const userId = "11111111111111111111111111111111"; + await env.DB.batch([ + env.DB.prepare( + `INSERT INTO cli_device_authorization_attempts + (id, device_name, device_secret_hash, user_code_hash, created_at, expires_at) + VALUES ('old-attempt', 'old', 'old-secret-hash', 'old-code-hash', 1, ?), + ('recent-attempt', 'recent', 'recent-secret-hash', 'recent-code-hash', 1, ?)` + ).bind(now - CLI_DEVICE_ATTEMPT_RETENTION_MS - 1, now - 1), + env.DB.prepare( + `INSERT INTO cli_credentials + (id, token_hash, user_id, created_at, expires_at, revoked_at) + VALUES ('old-expired', 'old-expired-hash', ?, 1, ?, NULL), + ('old-revoked', 'old-revoked-hash', ?, 1, ?, ?), + ('active', 'active-hash', ?, 1, ?, NULL)` + ).bind( + userId, + now - CLI_CREDENTIAL_RETENTION_MS - 1, + userId, + now + CLI_CREDENTIAL_RETENTION_MS, + now - CLI_CREDENTIAL_RETENTION_MS - 1, + userId, + now + CLI_CREDENTIAL_RETENTION_MS + ), + env.DB.prepare( + `INSERT INTO cli_auth_rate_limits + (rate_key, window_started_at, request_count, expires_at) + VALUES ('stale', 1, 1, 1)` + ), + ]); + + await start(); + + const attempts = await env.DB.prepare( + "SELECT id FROM cli_device_authorization_attempts ORDER BY id" + ).all<{ id: string }>(); + expect(attempts.results.map((row) => row.id)).toContain("recent-attempt"); + expect(attempts.results.map((row) => row.id)).not.toContain("old-attempt"); + const credentials = await env.DB.prepare("SELECT id FROM cli_credentials ORDER BY id").all<{ + id: string; + }>(); + expect(credentials.results.map((row) => row.id)).toEqual(["active"]); + expect( + await env.DB.prepare("SELECT 1 FROM cli_auth_rate_limits WHERE rate_key = 'stale'").first() + ).toBeNull(); + }); + + it("does not accept a user code as a polling credential", async () => { + const started = await start(); + const response = await exchange(started.userCode); + expect(response.status).toBe(400); + }); +}); diff --git a/packages/control-plane/test/integration/environment-secrets.test.ts b/packages/control-plane/test/integration/environment-secrets.test.ts index 0c569aa749..9b8ff035ce 100644 --- a/packages/control-plane/test/integration/environment-secrets.test.ts +++ b/packages/control-plane/test/integration/environment-secrets.test.ts @@ -40,6 +40,11 @@ describe("EnvironmentSecretsStore", () => { expect(await store.deleteSecret(ENV_ID, "TOKEN")).toBe(true); expect((await store.listSecretKeys(ENV_ID)).map((k) => k.key)).toEqual(["API_URL"]); + expect( + await env.DB.prepare("SELECT COUNT(*) AS count FROM managed_secret_redaction_history").first<{ + count: number; + }>() + ).toEqual({ count: 1 }); }); it("scopes secrets per environment", async () => { diff --git a/packages/control-plane/test/integration/environment-store.test.ts b/packages/control-plane/test/integration/environment-store.test.ts index 4e5d4850a3..b9c7c6a164 100644 --- a/packages/control-plane/test/integration/environment-store.test.ts +++ b/packages/control-plane/test/integration/environment-store.test.ts @@ -155,6 +155,11 @@ describe("EnvironmentStore", () => { .bind(row.id) .first<{ c: number }>(); expect(secretCount?.c).toBe(0); + expect( + await env.DB.prepare("SELECT encrypted_value FROM managed_secret_redaction_history").first<{ + encrypted_value: string; + }>() + ).toEqual({ encrypted_value: "cipher" }); const ready = await env.DB.prepare( "SELECT status FROM image_builds WHERE id = 'img_ready'" ).first<{ status: string }>(); @@ -195,5 +200,10 @@ describe("EnvironmentStore", () => { .bind(row.id) .first<{ c: number }>(); expect(secretCount?.c).toBe(0); + expect( + await env.DB.prepare("SELECT encrypted_value FROM managed_secret_redaction_history").first<{ + encrypted_value: string; + }>() + ).toEqual({ encrypted_value: "cipher" }); }); }); diff --git a/packages/control-plane/test/integration/events-messages-list.test.ts b/packages/control-plane/test/integration/events-messages-list.test.ts index 8420cb3acf..104320b832 100644 --- a/packages/control-plane/test/integration/events-messages-list.test.ts +++ b/packages/control-plane/test/integration/events-messages-list.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, it, expect } from "vitest"; import { cleanD1Tables } from "./cleanup"; -import { initSession, seedEvents } from "./helpers"; +import { initSession, queryDO, seedEvents } from "./helpers"; describe("GET /internal/events", () => { beforeEach(cleanD1Tables); @@ -262,6 +262,51 @@ describe("GET /internal/events", () => { }); describe("GET /internal/messages", () => { + it("paginates tied timestamps deterministically across intervening inserts", async () => { + const { stub } = await initSession(); + const [{ id: authorId }] = await queryDO<{ id: string }>( + stub, + "SELECT id FROM participants LIMIT 1" + ); + for (const id of ["message-a", "message-b", "message-c"]) { + await queryDO( + stub, + `INSERT INTO messages (id, author_id, content, source, status, created_at) + VALUES (?, ?, ?, 'web', 'completed', 1000)`, + id, + authorId, + id + ); + } + + const firstResponse = await stub.fetch("http://internal/internal/messages?limit=2"); + const first = await firstResponse.json<{ + messages: Array<{ id: string }>; + cursor: string; + hasMore: boolean; + }>(); + expect(first.messages.map(({ id }) => id)).toEqual(["message-c", "message-b"]); + expect(first.hasMore).toBe(true); + expect(first.cursor).not.toContain("message-b"); + + await queryDO( + stub, + `INSERT INTO messages (id, author_id, content, source, status, created_at) + VALUES ('message-new', ?, 'new', 'web', 'completed', 2000)`, + authorId + ); + + const secondResponse = await stub.fetch( + `http://internal/internal/messages?limit=2&cursor=${encodeURIComponent(first.cursor)}` + ); + const second = await secondResponse.json<{ + messages: Array<{ id: string }>; + hasMore: boolean; + }>(); + expect(second.messages.map(({ id }) => id)).toEqual(["message-a"]); + expect(second.hasMore).toBe(false); + }); + it("lists messages with status filter", async () => { const { stub } = await initSession(); diff --git a/packages/control-plane/test/integration/external-discovery.test.ts b/packages/control-plane/test/integration/external-discovery.test.ts new file mode 100644 index 0000000000..187b4409fb --- /dev/null +++ b/packages/control-plane/test/integration/external-discovery.test.ts @@ -0,0 +1,295 @@ +import { SELF, env } from "cloudflare:test"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { hashToken } from "../../src/auth/crypto"; +import { cleanD1Tables } from "./cleanup"; +import { seedActiveUser } from "./helpers"; + +const API = "https://cp.test/external/v1"; +const USER_ID = "44444444444444444444444444444444"; + +async function externalHeaders(roleId = "role_builtin_member"): Promise<Record<string, string>> { + await seedActiveUser(USER_ID); + await env.DB.prepare("UPDATE user_role_assignments SET role_id = ? WHERE user_id = ?") + .bind(roleId, USER_ID) + .run(); + const credential = `oi_cli_${crypto.randomUUID().replaceAll("-", "")}${crypto.randomUUID().replaceAll("-", "")}`; + await env.DB.prepare( + `INSERT INTO cli_credentials (id, token_hash, user_id, created_at, expires_at) + VALUES (?, ?, ?, ?, ?)` + ) + .bind( + "discovery-credential", + await hashToken(credential), + USER_ID, + Date.now(), + Date.now() + 60_000 + ) + .run(); + return { + Authorization: `Bearer ${credential}`, + "X-Open-Inspect-API-Version": "1", + "X-Open-Inspect-Client-Version": "0.1.0-test", + "X-Open-Inspect-Client-Surface": "cli", + }; +} + +async function seedSkills(names: string[]): Promise<void> { + await env.DB.batch( + names.map((name, index) => + env.DB.prepare( + `INSERT INTO skills + (id, name, enabled, created_by, updated_by, created_at, updated_at) + VALUES (?, ?, 1, ?, ?, 1, 1)` + ).bind(`skill-${index}`, name, USER_ID, USER_ID) + ) + ); + await env.DB.batch( + names.map((_, index) => + env.DB.prepare( + `INSERT INTO skill_revisions + (id, skill_id, revision_number, revision_sha256, description, body, + metadata_json, total_bytes, created_by, created_at) + VALUES (?, ?, 1, ?, 'Description', 'Body', '{}', 4, ?, 1)` + ).bind(`revision-${index}`, `skill-${index}`, String(index).padStart(64, "0"), USER_ID) + ) + ); + await env.DB.batch( + names.map((_, index) => + env.DB.prepare("UPDATE skills SET current_revision_id = ? WHERE id = ?").bind( + `revision-${index}`, + `skill-${index}` + ) + ) + ); +} + +async function seedProfiles(names: string[]): Promise<void> { + await env.DB.batch( + names.map((name, index) => + env.DB.prepare( + `INSERT INTO skill_profiles (id, user_id, name, created_at, updated_at) + VALUES (?, ?, ?, 1, 1)` + ).bind(`profile-${index}`, USER_ID, name) + ) + ); +} + +describe("external V1 discovery API", () => { + beforeEach(cleanD1Tables); + afterEach(cleanD1Tables); + + it("paginates environments and returns ordered repository members", async () => { + const headers = await externalHeaders(); + const now = Date.now(); + await env.DB.batch([ + env.DB.prepare( + `INSERT INTO environments + (id, name, description, prebuild_enabled, channel_associations, created_at, updated_at) + VALUES ('env_old', 'Old', NULL, 0, NULL, ?, ?)` + ).bind(now - 1, now - 1), + env.DB.prepare( + `INSERT INTO environments + (id, name, description, prebuild_enabled, channel_associations, created_at, updated_at) + VALUES ('env_new', 'New', 'Newest', 1, NULL, ?, ?)` + ).bind(now, now), + env.DB.prepare( + `INSERT INTO environment_repositories + (environment_id, position, repo_owner, repo_name, repo_id, base_branch) + VALUES ('env_new', 1, 'acme', 'second', 2, 'main')` + ), + env.DB.prepare( + `INSERT INTO environment_repositories + (environment_id, position, repo_owner, repo_name, repo_id, base_branch) + VALUES ('env_new', 0, 'acme', 'first', 1, 'develop')` + ), + ]); + + const first = await SELF.fetch(`${API}/environments?limit=1`, { headers }); + expect(first.status).toBe(200); + await expect(first.json()).resolves.toMatchObject({ + environments: [ + { + id: "env_new", + repositories: [{ repoName: "first" }, { repoName: "second" }], + }, + ], + hasMore: true, + continuationOffset: 1, + }); + + const second = await SELF.fetch(`${API}/environments?limit=1&offset=1`, { headers }); + await expect(second.json()).resolves.toMatchObject({ + environments: [{ id: "env_old" }], + hasMore: false, + }); + const detail = await SELF.fetch(`${API}/environments/env_new`, { headers }); + expect(detail.status).toBe(200); + await expect(detail.json()).resolves.toMatchObject({ environment: { id: "env_new" } }); + }); + + it("returns only enabled model metadata and reasoning options to an active user", async () => { + const headers = await externalHeaders("role_builtin_viewer"); + await env.DB.prepare( + "INSERT INTO model_preferences (id, enabled_models, updated_at) VALUES ('global', ?, ?)" + ) + .bind(JSON.stringify(["openai/gpt-5.6-sol"]), Date.now()) + .run(); + + const response = await SELF.fetch(`${API}/models`, { headers }); + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ + models: [ + { + id: "openai/gpt-5.6-sol", + name: "GPT 5.6 Sol", + description: "Frontier model for complex professional work", + category: "OpenAI", + reasoning: { + efforts: ["none", "low", "medium", "high", "xhigh"], + default: "medium", + }, + }, + ], + }); + }); + + it("preserves skill discovery while omitting profiles without profile permission", async () => { + const roleId = "role_skill_reader"; + await env.DB.batch([ + env.DB.prepare( + `INSERT INTO roles (id, key, name, normalized_name, description, is_system) + VALUES (?, NULL, 'Skill reader', 'skill reader', NULL, 0)` + ).bind(roleId), + env.DB.prepare( + "INSERT INTO role_permissions (role_id, permission_id) VALUES (?, 'skills.read')" + ).bind(roleId), + ]); + const headers = await externalHeaders(roleId); + await seedProfiles(["Owned profile"]); + const response = await SELF.fetch(`${API}/skills`, { headers }); + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ + skills: [], + profiles: [], + hasMore: false, + }); + }); + + it("paginates skills and owned profiles as one bounded collection", async () => { + const headers = await externalHeaders(); + await seedSkills(["Alpha", "Bravo", "Charlie"]); + await seedProfiles(["First profile", "Second profile"]); + + const first = await SELF.fetch(`${API}/skills?limit=2`, { headers }); + await expect(first.json()).resolves.toMatchObject({ + skills: [{ name: "Alpha" }, { name: "Bravo" }], + profiles: [], + hasMore: true, + continuationOffset: 2, + }); + + const second = await SELF.fetch(`${API}/skills?limit=2&offset=2`, { headers }); + await expect(second.json()).resolves.toMatchObject({ + skills: [{ name: "Charlie" }], + profiles: [{ name: "First profile" }], + hasMore: true, + continuationOffset: 4, + }); + + const third = await SELF.fetch(`${API}/skills?limit=2&offset=4`, { headers }); + await expect(third.json()).resolves.toMatchObject({ + skills: [], + profiles: [{ name: "Second profile" }], + hasMore: false, + }); + }); + + it("uses the default list limit across the combined skill collection", async () => { + const headers = await externalHeaders(); + await seedProfiles( + Array.from({ length: 51 }, (_, index) => `Profile ${String(index).padStart(2, "0")}`) + ); + + const response = await SELF.fetch(`${API}/skills`, { headers }); + const body = await response.json<{ + skills: unknown[]; + profiles: unknown[]; + hasMore: boolean; + continuationOffset: number; + }>(); + expect(body).toMatchObject({ + skills: [], + hasMore: true, + continuationOffset: 50, + }); + expect(body.profiles).toHaveLength(50); + }); + + it("projects provider accounts without identity, audit, or credential fields", async () => { + const headers = await externalHeaders(); + const accountId = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + const now = Date.now(); + await env.DB.batch([ + env.DB.prepare( + `INSERT INTO model_provider_accounts + (id, provider, display_name, external_account_id, status, created_by, updated_by, + last_verified_at, created_at, updated_at) + VALUES (?, 'openai', 'Team account', 'external-secret-looking-id', 'active', ?, ?, ?, ?, ?)` + ).bind(accountId, USER_ID, USER_ID, now, now, now), + env.DB.prepare( + `INSERT INTO model_provider_account_defaults + (provider, provider_account_id, unattended_mode, created_by, updated_by, created_at, updated_at) + VALUES ('openai', ?, 'provider_account', ?, ?, ?, ?)` + ).bind(accountId, USER_ID, USER_ID, now, now), + ]); + + const response = await SELF.fetch(`${API}/provider-accounts`, { headers }); + expect(response.status).toBe(200); + const body = await response.json<Record<string, unknown>>(); + expect(JSON.stringify(body)).not.toContain("external-secret-looking-id"); + expect(body).toEqual({ + accounts: [ + { + id: accountId, + provider: "openai", + displayName: "Team account", + status: "active", + isDefault: true, + unattendedMode: "provider_account", + }, + ], + hasMore: false, + }); + }); + + it("rejects invalid list bounds before querying resources", async () => { + const headers = await externalHeaders(); + for (const query of [ + "limit=0", + "limit=101", + "limit=1.5", + "limit=", + "offset=-1", + "offset=1e2", + "offset=9007199254740991", + "offset=9007199254740992", + "limit=1&limit=2", + "offset=0&offset=1", + "unknown=1", + ]) { + const response = await SELF.fetch(`${API}/environments?${query}`, { headers }); + expect(response.status, query).toBe(400); + } + }); + + it("rejects query parameters on discovery endpoints without list parameters", async () => { + const headers = await externalHeaders(); + const modelResponse = await SELF.fetch(`${API}/models?unknown=1`, { headers }); + expect(modelResponse.status).toBe(400); + + const environmentResponse = await SELF.fetch(`${API}/environments/missing?limit=1`, { + headers, + }); + expect(environmentResponse.status).toBe(400); + }); +}); diff --git a/packages/control-plane/test/integration/external-session-api.test.ts b/packages/control-plane/test/integration/external-session-api.test.ts new file mode 100644 index 0000000000..01c26a3480 --- /dev/null +++ b/packages/control-plane/test/integration/external-session-api.test.ts @@ -0,0 +1,1085 @@ +import { SELF, env } from "cloudflare:test"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { hashToken } from "../../src/auth/crypto"; +import { externalCreateSessionRequestSchema } from "@open-inspect/shared/types/external-session-api"; +import { GlobalSecretsStore } from "../../src/db/global-secrets"; +import { EnvironmentSecretsStore } from "../../src/db/environment-secrets"; +import { RepoSecretsStore } from "../../src/db/repo-secrets"; +import { cleanD1Tables } from "./cleanup"; +import { SessionIndexStore } from "../../src/db/session-index"; +import { deriveExternalSessionId } from "../../src/routes/external-sessions"; +import { encodeEventChangeCursor, parseEventChangeCursor } from "../../src/session/event-stream"; +import { + deleteEvent, + queryDO, + renameEvent, + seedActiveUser, + seedEvents, + updateEventData, + waitForSandboxStatus, + serviceFetch, +} from "./helpers"; + +const API = "https://cp.test/external/v1/sessions"; +const USER_ID = "33333333333333333333333333333333"; + +function externalSessionIdSecret(): string { + if (!env.EXTERNAL_SESSION_ID_SECRET) throw new Error("Missing test external session ID secret"); + return env.EXTERNAL_SESSION_ID_SECRET; +} + +async function externalHeaders(roleId = "role_builtin_member"): Promise<Record<string, string>> { + await seedActiveUser(USER_ID); + await env.DB.prepare("UPDATE user_role_assignments SET role_id = ? WHERE user_id = ?") + .bind(roleId, USER_ID) + .run(); + const credential = `oi_cli_${crypto.randomUUID().replaceAll("-", "")}${crypto.randomUUID().replaceAll("-", "")}`; + await env.DB.prepare( + `INSERT INTO cli_credentials (id, token_hash, user_id, created_at, expires_at) + VALUES (?, ?, ?, ?, ?)` + ) + .bind("credential-1", await hashToken(credential), USER_ID, Date.now(), Date.now() + 60_000) + .run(); + return { + Authorization: `Bearer ${credential}`, + "Content-Type": "application/json", + "X-Open-Inspect-API-Version": "1", + "X-Open-Inspect-Client-Version": "0.1.0-test", + "X-Open-Inspect-Client-Surface": "cli", + }; +} + +function createBody(overrides: Record<string, unknown> = {}): string { + return JSON.stringify({ + title: "External session", + model: "openai/gpt-5.6-sol", + reasoningEffort: "high", + idempotencyKey: `create-${crypto.randomUUID()}`, + ...overrides, + }); +} + +describe("external v1 session API", () => { + beforeEach(cleanD1Tables); + afterEach(cleanD1Tables); + + it("rejects unsupported fields and invalid explicit model settings before side effects", async () => { + const headers = await externalHeaders(); + for (const body of [ + createBody({ repoOwner: "acme" }), + createBody({ attachments: [] }), + createBody({ model: "unknown/model" }), + createBody({ model: "anthropic/claude-haiku-4-5", reasoningEffort: "low" }), + ]) { + const response = await SELF.fetch(API, { method: "POST", headers, body }); + expect(response.status, body).toBe(400); + } + expect(await env.DB.prepare("SELECT COUNT(*) AS count FROM sessions").first()).toEqual({ + count: 0, + }); + }); + + it("rejects models disabled by the effective workspace policy on create and follow-up", async () => { + const headers = await externalHeaders(); + await env.DB.prepare( + "INSERT INTO model_preferences (id, enabled_models, updated_at) VALUES ('global', ?, ?)" + ) + .bind(JSON.stringify(["anthropic/claude-sonnet-4-6"]), Date.now()) + .run(); + + const deniedCreate = await SELF.fetch(API, { method: "POST", headers, body: createBody() }); + expect(deniedCreate.status).toBe(400); + await expect(deniedCreate.json()).resolves.toMatchObject({ + error: 'Model "openai/gpt-5.6-sol" is not enabled', + }); + expect(await env.DB.prepare("SELECT COUNT(*) AS count FROM sessions").first()).toEqual({ + count: 0, + }); + + await env.DB.prepare("DELETE FROM model_preferences").run(); + const created = await SELF.fetch(API, { method: "POST", headers, body: createBody() }); + const { sessionId } = (await created.json()) as { sessionId: string }; + await env.DB.prepare( + "INSERT INTO model_preferences (id, enabled_models, updated_at) VALUES ('global', ?, ?)" + ) + .bind(JSON.stringify(["anthropic/claude-sonnet-4-6"]), Date.now()) + .run(); + const deniedPrompt = await SELF.fetch(`${API}/${sessionId}/messages`, { + method: "POST", + headers, + body: JSON.stringify({ + content: "Continue", + clientRequestId: "disabled-model", + model: "openai/gpt-5.6-sol", + reasoningEffort: "high", + }), + }); + expect(deniedPrompt.status).toBe(400); + const deniedWebPrompt = await serviceFetch(`https://cp.test/sessions/${sessionId}/prompt`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + content: "Continue", + model: "openai/gpt-5.6-sol", + reasoningEffort: "high", + }), + }); + expect(deniedWebPrompt.status).toBe(deniedPrompt.status); + await expect(deniedWebPrompt.json()).resolves.toMatchObject({ + error: 'Model "openai/gpt-5.6-sol" is not enabled', + }); + const stub = env.SESSION.get(env.SESSION.idFromName(sessionId)); + expect((await queryDO(stub, "SELECT id FROM messages")).length).toBe(0); + }); + + it("redacts current repository and environment secrets from external errors", async () => { + const headers = await externalHeaders(); + const created = await SELF.fetch(API, { method: "POST", headers, body: createBody() }); + const { sessionId } = await created.json<{ sessionId: string }>(); + const repoSecret = "current-repo-secret"; + const environmentSecret = "current-environment-secret"; + await new RepoSecretsStore(env.DB, env.REPO_SECRETS_ENCRYPTION_KEY!).setSecrets( + 42, + "acme", + "repo", + { TOKEN: repoSecret } + ); + const now = Date.now(); + await env.DB.prepare( + "INSERT INTO environments (id, name, prebuild_enabled, created_at, updated_at) VALUES (?, ?, 0, ?, ?)" + ) + .bind("env_external_error", "External error", now, now) + .run(); + await new EnvironmentSecretsStore(env.DB, env.REPO_SECRETS_ENCRYPTION_KEY!).setSecrets( + "env_external_error", + { TOKEN: environmentSecret } + ); + + for (const [index, secret] of [repoSecret, environmentSecret].entries()) { + const response = await SELF.fetch(`${API}/${sessionId}/messages`, { + method: "POST", + headers, + body: JSON.stringify({ + content: "Trigger model validation", + clientRequestId: `redaction-${index}`, + model: secret, + }), + }); + expect(response.status).toBe(400); + const body = await response.text(); + expect(body).not.toContain(secret); + expect(body).toContain("[REDACTED]"); + } + }); + + it("allows create without reasoning for a non-reasoning model", async () => { + const headers = await externalHeaders(); + const response = await SELF.fetch(API, { + method: "POST", + headers, + body: createBody({ + model: "anthropic/claude-haiku-4-5", + reasoningEffort: undefined, + }), + }); + expect(response.status).toBe(201); + const { sessionId } = await response.json<{ sessionId: string }>(); + expect( + await env.DB.prepare("SELECT reasoning_effort FROM sessions WHERE id = ?") + .bind(sessionId) + .first() + ).toEqual({ reasoning_effort: null }); + }); + + it("requires create and collaborate permissions before an initial-prompt side effect", async () => { + const roleId = "role_external_create_only"; + await env.DB.batch([ + env.DB.prepare( + `INSERT INTO roles (id, key, name, normalized_name, description, is_system) + VALUES (?, NULL, 'External Create', 'external create', NULL, 0)` + ).bind(roleId), + env.DB.prepare( + "INSERT INTO role_permissions (role_id, permission_id) VALUES (?, 'sessions.create')" + ).bind(roleId), + ]); + const headers = await externalHeaders(roleId); + const denied = await SELF.fetch(API, { + method: "POST", + headers, + body: createBody({ initialPrompt: "Start work" }), + }); + expect(denied.status).toBe(403); + await expect(denied.json()).resolves.toMatchObject({ + code: "permission_required", + permission: "sessions.collaborate", + }); + expect(await env.DB.prepare("SELECT COUNT(*) AS count FROM sessions").first()).toEqual({ + count: 0, + }); + }); + + it("creates idempotently with a deterministic session and prompt response", async () => { + const headers = await externalHeaders(); + const body = createBody({ + idempotencyKey: "initial-prompt-retry", + initialPrompt: "Start work", + }); + const first = await SELF.fetch(API, { method: "POST", headers, body }); + expect(first.status).toBe(201); + const result = (await first.json()) as { sessionId: string; messageId: string }; + + const retry = await SELF.fetch(API, { method: "POST", headers, body }); + expect(retry.status).toBe(200); + await expect(retry.json()).resolves.toEqual(result); + const conflict = await SELF.fetch(API, { + method: "POST", + headers, + body: createBody({ idempotencyKey: "initial-prompt-retry", initialPrompt: "Different work" }), + }); + expect(conflict.status).toBe(409); + expect(await env.DB.prepare("SELECT COUNT(*) AS count FROM sessions").first()).toEqual({ + count: 1, + }); + expect( + await env.DB.prepare("SELECT external_request_fingerprint FROM sessions WHERE id = ?") + .bind(result.sessionId) + .first() + ).toEqual({ + external_request_fingerprint: await hashToken( + JSON.stringify(externalCreateSessionRequestSchema.parse(JSON.parse(body))) + ), + }); + const stub = env.SESSION.get(env.SESSION.idFromName(result.sessionId)); + expect((await queryDO(stub, "SELECT id FROM messages")).length).toBe(1); + }); + + it("reuses an omitted model default after workspace preferences change", async () => { + const headers = await externalHeaders(); + await env.DB.prepare( + "INSERT INTO model_preferences (id, enabled_models, updated_at) VALUES ('global', ?, ?)" + ) + .bind(JSON.stringify(["openai/gpt-5.6-sol"]), Date.now()) + .run(); + const body = createBody({ + idempotencyKey: `default-model-retry-${crypto.randomUUID()}`, + model: undefined, + reasoningEffort: undefined, + initialPrompt: "Start with the original default", + }); + + const first = await SELF.fetch(API, { method: "POST", headers, body }); + expect(first.status).toBe(201); + const result = await first.json<{ sessionId: string; messageId: string }>(); + await env.DB.prepare("UPDATE model_preferences SET enabled_models = ?, updated_at = ?") + .bind(JSON.stringify(["anthropic/claude-sonnet-4-6"]), Date.now()) + .run(); + + const retry = await SELF.fetch(API, { method: "POST", headers, body }); + expect(retry.status).toBe(200); + await expect(retry.json()).resolves.toEqual(result); + expect( + await env.DB.prepare("SELECT model, external_bootstrap_snapshot FROM sessions WHERE id = ?") + .bind(result.sessionId) + .first<{ model: string; external_bootstrap_snapshot: string }>() + ).toMatchObject({ + model: "openai/gpt-5.6-sol", + external_bootstrap_snapshot: expect.stringContaining('"model":"openai/gpt-5.6-sol"'), + }); + const stub = env.SESSION.get(env.SESSION.idFromName(result.sessionId)); + expect(await queryDO(stub, "SELECT model FROM session")).toEqual([ + { model: "openai/gpt-5.6-sol" }, + ]); + expect((await queryDO(stub, "SELECT id FROM messages")).length).toBe(1); + }); + + it("recovers a D1-only reservation from its original environment bootstrap", async () => { + const headers = await externalHeaders(); + const environmentId = `env-retry-${crypto.randomUUID()}`; + const idempotencyKey = `partial-retry-${crypto.randomUUID()}`; + const body = createBody({ + idempotencyKey, + environmentId, + model: undefined, + reasoningEffort: undefined, + initialPrompt: "Recover the original operation", + }); + const fingerprint = await hashToken( + JSON.stringify(externalCreateSessionRequestSchema.parse(JSON.parse(body))) + ); + const sessionId = await deriveExternalSessionId( + USER_ID, + idempotencyKey, + externalSessionIdSecret() + ); + const now = Date.now(); + const originalRepositories = [ + { + repoOwner: "acme", + repoName: "original", + repoId: 101, + baseBranch: "main", + }, + ]; + const snapshot = { + sessionId, + repoOwner: "acme", + repoName: "original", + repoId: 101, + defaultBranch: "main", + branch: null, + repositories: originalRepositories, + environmentId, + title: "External session", + model: "openai/gpt-5.6-sol", + reasoningEffort: null, + codeServerEnabled: false, + vncEnabled: false, + sandboxSettings: { sandboxTimeoutMs: 120_000, terminalEnabled: true }, + participantUserId: USER_ID, + platformUserId: USER_ID, + scmTokenEncrypted: null, + scmRefreshTokenEncrypted: null, + requestFingerprint: fingerprint, + }; + await env.DB.batch([ + env.DB.prepare( + `INSERT INTO environments + (id, name, description, prebuild_enabled, channel_associations, created_at, updated_at) + VALUES (?, 'Retry environment', NULL, 0, NULL, ?, ?)` + ).bind(environmentId, now, now), + env.DB.prepare( + `INSERT INTO environment_repositories + (environment_id, position, repo_owner, repo_name, repo_id, base_branch) + VALUES (?, 0, 'acme', 'original', 101, 'main')` + ).bind(environmentId), + env.DB.prepare( + `INSERT INTO integration_environment_settings + (integration_id, environment_id, settings, created_at, updated_at) + VALUES ('sandbox', ?, ?, ?, ?)` + ).bind( + environmentId, + JSON.stringify({ sandboxTimeoutMs: 120_000, terminalEnabled: true }), + now, + now + ), + ]); + await new SessionIndexStore(env.DB).create({ + id: sessionId, + title: "External session", + repoOwner: "acme", + repoName: "original", + model: "openai/gpt-5.6-sol", + reasoningEffort: null, + baseBranch: "main", + repositories: originalRepositories, + environmentId, + status: "created", + userId: USER_ID, + externalRequestFingerprint: fingerprint, + externalBootstrapSnapshot: JSON.stringify(snapshot), + createdAt: now, + updatedAt: now, + }); + + await env.DB.batch([ + env.DB.prepare("DELETE FROM environment_repositories WHERE environment_id = ?").bind( + environmentId + ), + env.DB.prepare( + `INSERT INTO environment_repositories + (environment_id, position, repo_owner, repo_name, repo_id, base_branch) + VALUES (?, 0, 'acme', 'changed', 202, 'develop')` + ).bind(environmentId), + env.DB.prepare( + `UPDATE integration_environment_settings + SET settings = ?, updated_at = ? WHERE integration_id = 'sandbox' AND environment_id = ?` + ).bind( + JSON.stringify({ sandboxTimeoutMs: 300_000, terminalEnabled: false }), + now + 1, + environmentId + ), + env.DB.prepare( + "INSERT INTO model_preferences (id, enabled_models, updated_at) VALUES ('global', ?, ?)" + ).bind(JSON.stringify(["anthropic/claude-sonnet-4-6"]), now + 1), + ]); + + const retry = await SELF.fetch(API, { method: "POST", headers, body }); + expect(retry.status).toBe(200); + await expect(retry.json()).resolves.toMatchObject({ sessionId, messageId: expect.any(String) }); + const stub = env.SESSION.get(env.SESSION.idFromName(sessionId)); + expect(await queryDO(stub, "SELECT repo_name, model, sandbox_settings FROM session")).toEqual([ + { + repo_name: "original", + model: "openai/gpt-5.6-sol", + sandbox_settings: JSON.stringify({ + terminalEnabled: true, + sandboxTimeoutMs: 120_000, + }), + }, + ]); + expect(await queryDO(stub, "SELECT repo_name, base_branch FROM session_repositories")).toEqual([ + { repo_name: "original", base_branch: "main" }, + ]); + expect(await queryDO(stub, "SELECT content FROM messages")).toEqual([ + { content: "Recover the original operation" }, + ]); + + const roleId = `role-no-environment-${crypto.randomUUID()}`; + await env.DB.batch([ + env.DB.prepare( + `INSERT INTO roles (id, key, name, normalized_name, description, is_system) + VALUES (?, NULL, ?, ?, NULL, 0)` + ).bind(roleId, roleId, roleId), + ...["sessions.create", "sessions.collaborate", "skills.read"].map((permission) => + env.DB.prepare("INSERT INTO role_permissions (role_id, permission_id) VALUES (?, ?)").bind( + roleId, + permission + ) + ), + env.DB.prepare("UPDATE user_role_assignments SET role_id = ? WHERE user_id = ?").bind( + roleId, + USER_ID + ), + ]); + const unauthorizedRetry = await SELF.fetch(API, { method: "POST", headers, body }); + expect(unauthorizedRetry.status).toBe(403); + await expect(unauthorizedRetry.json()).resolves.toMatchObject({ + code: "permission_required", + permission: "environments.use", + }); + }); + + it("reserves the full request fingerprint before bootstrap and rejects changed crash retries", async () => { + const headers = await externalHeaders(); + const idempotencyKey = `d1-crash-${crypto.randomUUID()}`; + const body = createBody({ idempotencyKey, initialPrompt: "Original prompt" }); + const fingerprint = await hashToken( + JSON.stringify(externalCreateSessionRequestSchema.parse(JSON.parse(body))) + ); + const sessionId = await deriveExternalSessionId( + USER_ID, + idempotencyKey, + externalSessionIdSecret() + ); + const now = Date.now(); + await new SessionIndexStore(env.DB).create({ + id: sessionId, + title: "External session", + repoOwner: null, + repoName: null, + model: "openai/gpt-5.6-sol", + reasoningEffort: "high", + baseBranch: null, + status: "created", + userId: USER_ID, + externalRequestFingerprint: fingerprint, + createdAt: now, + updatedAt: now, + }); + + const changed = await SELF.fetch(API, { + method: "POST", + headers, + body: createBody({ idempotencyKey, initialPrompt: "Changed prompt" }), + }); + expect(changed.status).toBe(409); + await expect(changed.json()).resolves.toMatchObject({ error: "Idempotency key conflict" }); + const stub = env.SESSION.get(env.SESSION.idFromName(sessionId)); + expect( + await queryDO( + stub, + "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session'" + ) + ).toEqual([]); + }); + + it("derives IDs only from the dedicated external-session secret", async () => { + const key = `secret-separation-${crypto.randomUUID()}`; + const expected = await deriveExternalSessionId(USER_ID, key, externalSessionIdSecret()); + expect(await deriveExternalSessionId(USER_ID, key, externalSessionIdSecret())).toBe(expected); + expect(await deriveExternalSessionId(USER_ID, key, env.TOKEN_ENCRYPTION_KEY)).not.toBe( + expected + ); + }); + + it("converges concurrent identical creates onto one bootstrap aggregate", async () => { + const headers = await externalHeaders(); + const body = createBody({ idempotencyKey: `concurrent-${crypto.randomUUID()}` }); + + const responses = await Promise.all( + Array.from({ length: 4 }, () => SELF.fetch(API, { method: "POST", headers, body })) + ); + expect(responses.map((response) => response.status).sort()).toEqual([200, 200, 200, 201]); + const results = await Promise.all( + responses.map((response) => response.json<{ sessionId: string }>()) + ); + expect(new Set(results.map((result) => result.sessionId)).size).toBe(1); + expect(await env.DB.prepare("SELECT COUNT(*) AS count FROM sessions").first()).toEqual({ + count: 1, + }); + const stub = env.SESSION.get(env.SESSION.idFromName(results[0]!.sessionId)); + expect((await queryDO(stub, "SELECT id FROM sandbox")).length).toBe(1); + expect((await queryDO(stub, "SELECT id FROM participants")).length).toBe(1); + expect((await queryDO(stub, "SELECT id FROM messages")).length).toBe(0); + }); + + it("lets exactly one differing concurrent create reserve and bootstrap", async () => { + const headers = await externalHeaders(); + const idempotencyKey = `concurrent-conflict-${crypto.randomUUID()}`; + const bodies = [ + createBody({ idempotencyKey, initialPrompt: "Prompt A" }), + createBody({ idempotencyKey, initialPrompt: "Prompt B" }), + ]; + + const responses = await Promise.all( + bodies.map((body) => SELF.fetch(API, { method: "POST", headers, body })) + ); + expect(responses.map((response) => response.status).sort()).toEqual([201, 409]); + const winnerIndex = responses.findIndex((response) => response.status === 201); + const loserIndex = 1 - winnerIndex; + const winner = await responses[winnerIndex]!.json<{ sessionId: string }>(); + await expect(responses[loserIndex]!.json()).resolves.toMatchObject({ + error: "Idempotency key conflict", + }); + + const winnerInput = externalCreateSessionRequestSchema.parse(JSON.parse(bodies[winnerIndex]!)); + const winnerFingerprint = await hashToken(JSON.stringify(winnerInput)); + expect( + await env.DB.prepare("SELECT external_request_fingerprint FROM sessions WHERE id = ?") + .bind(winner.sessionId) + .first() + ).toEqual({ external_request_fingerprint: winnerFingerprint }); + + const stub = env.SESSION.get(env.SESSION.idFromName(winner.sessionId)); + const doFingerprint = await queryDO<{ initialization_fingerprint: string }>( + stub, + "SELECT initialization_fingerprint FROM session_bootstrap WHERE singleton = 1" + ); + expect(doFingerprint).toHaveLength(1); + expect(await queryDO(stub, "SELECT content FROM messages")).toEqual([ + { content: winnerInput.initialPrompt }, + ]); + + const winnerRetry = await SELF.fetch(API, { + method: "POST", + headers, + body: bodies[winnerIndex], + }); + expect(winnerRetry.status).toBe(200); + expect( + await queryDO( + stub, + "SELECT initialization_fingerprint FROM session_bootstrap WHERE singleton = 1" + ) + ).toEqual(doFingerprint); + }); + + it.each(["active", "completed", "failed"] as const)( + "does not regress an existing %s session when retrying runtime initialization", + async (status) => { + const headers = await externalHeaders(); + const idempotencyKey = `status-${status}`; + const body = createBody({ idempotencyKey }); + const created = await SELF.fetch(API, { method: "POST", headers, body }); + const { sessionId } = (await created.json()) as { sessionId: string }; + await env.DB.prepare("UPDATE sessions SET status = ? WHERE id = ?") + .bind(status, sessionId) + .run(); + const stub = env.SESSION.get(env.SESSION.idFromName(sessionId)); + await queryDO(stub, "UPDATE session SET status = ?", status); + const retry = await SELF.fetch(API, { method: "POST", headers, body }); + expect(retry.status).toBe(200); + expect( + await env.DB.prepare("SELECT status FROM sessions WHERE id = ?").bind(sessionId).first() + ).toEqual({ status }); + expect(await queryDO(stub, "SELECT status FROM session")).toEqual([{ status }]); + } + ); + + it("re-drives warming after the aggregate committed before warm scheduling", async () => { + const headers = await externalHeaders(); + const body = createBody({ idempotencyKey: "warm-recovery" }); + const created = await SELF.fetch(API, { method: "POST", headers, body }); + const { sessionId } = (await created.json()) as { sessionId: string }; + const stub = env.SESSION.get(env.SESSION.idFromName(sessionId)); + await waitForSandboxStatus(stub, "failed"); + const aggregateBefore = { + session: await queryDO(stub, "SELECT id, created_at FROM session"), + sandbox: await queryDO(stub, "SELECT id FROM sandbox"), + participants: await queryDO(stub, "SELECT id FROM participants ORDER BY id"), + }; + await queryDO(stub, "UPDATE sandbox SET status = 'pending'"); + const retry = await SELF.fetch(API, { method: "POST", headers, body }); + + expect(retry.status).toBe(200); + expect(await queryDO(stub, "SELECT status FROM sandbox")).toEqual([{ status: "pending" }]); + expect( + await queryDO<{ pending_deadline: number | null }>( + stub, + "SELECT pending_deadline FROM session_alarm_state WHERE singleton = 1" + ) + ).toEqual([expect.objectContaining({ pending_deadline: expect.any(Number) })]); + expect({ + session: await queryDO(stub, "SELECT id, created_at FROM session"), + sandbox: await queryDO(stub, "SELECT id FROM sandbox"), + participants: await queryDO(stub, "SELECT id FROM participants ORDER BY id"), + }).toEqual(aggregateBefore); + }); + + it("rejects a mismatched bootstrap fingerprint without rewriting the aggregate", async () => { + const headers = await externalHeaders(); + const body = createBody({ idempotencyKey: "bootstrap-conflict" }); + const created = await SELF.fetch(API, { method: "POST", headers, body }); + const { sessionId } = (await created.json()) as { sessionId: string }; + const stub = env.SESSION.get(env.SESSION.idFromName(sessionId)); + const aggregateBefore = await queryDO(stub, "SELECT * FROM session"); + await queryDO(stub, "UPDATE session_bootstrap SET initialization_fingerprint = 'mismatch'"); + const retry = await SELF.fetch(API, { method: "POST", headers, body }); + + expect(retry.status).toBe(409); + await expect(retry.json()).resolves.toMatchObject({ + error: "Session runtime conflict", + code: "runtime_conflict", + }); + expect(await queryDO(stub, "SELECT * FROM session")).toEqual(aggregateBefore); + }); + + it("allows workspace-wide read and lifecycle to Member but denies Viewer and custom roles", async () => { + const headers = await externalHeaders(); + const created = await SELF.fetch(API, { method: "POST", headers, body: createBody() }); + const { sessionId } = (await created.json()) as { sessionId: string }; + await env.DB.prepare("UPDATE sessions SET user_id = '44444444444444444444444444444444'").run(); + + expect((await SELF.fetch(API, { headers })).status).toBe(200); + const got = await SELF.fetch(`${API}/${sessionId}`, { headers }); + expect(got.status).toBe(200); + const session = (await got.json()) as Record<string, unknown>; + expect(session).toMatchObject({ id: sessionId }); + expect(session).toMatchObject({ repoOwner: null, repoName: null, repositories: [] }); + expect((await SELF.fetch(`${API}/${sessionId}/stop`, { method: "POST", headers })).status).toBe( + 200 + ); + + await env.DB.prepare("UPDATE user_role_assignments SET role_id = 'role_builtin_viewer'").run(); + expect((await SELF.fetch(API, { method: "POST", headers, body: createBody() })).status).toBe( + 403 + ); + expect((await SELF.fetch(`${API}/${sessionId}/stop`, { method: "POST", headers })).status).toBe( + 403 + ); + expect((await SELF.fetch(API, { headers })).status).toBe(200); + + const roleId = "role_no_session_read"; + await env.DB.batch([ + env.DB.prepare( + `INSERT INTO roles (id, key, name, normalized_name, description, is_system) + VALUES (?, NULL, 'No Session Read', 'no session read', NULL, 0)` + ).bind(roleId), + env.DB.prepare("UPDATE user_role_assignments SET role_id = ?").bind(roleId), + ]); + expect((await SELF.fetch(API, { headers })).status).toBe(403); + }); + + it("returns bounded session-list continuation offsets", async () => { + const headers = await externalHeaders(); + const store = new SessionIndexStore(env.DB); + const now = Date.now(); + for (const [index, id] of ["list-oldest", "list-middle", "list-newest"].entries()) { + await store.create({ + id, + title: id, + repoOwner: null, + repoName: null, + model: "anthropic/claude-haiku-4-5", + reasoningEffort: null, + baseBranch: null, + status: "created", + userId: USER_ID, + createdAt: now + index, + updatedAt: now + index, + }); + } + + const first = await SELF.fetch(`${API}?limit=2&offset=0`, { headers }); + expect(first.status).toBe(200); + await expect(first.json()).resolves.toMatchObject({ + sessions: [{ id: "list-newest" }, { id: "list-middle" }], + hasMore: true, + continuationOffset: 2, + }); + const second = await SELF.fetch(`${API}?limit=2&offset=2`, { headers }); + await expect(second.json()).resolves.toMatchObject({ + sessions: [{ id: "list-oldest" }], + hasMore: false, + }); + expect((await SELF.fetch(`${API}?limit=201`, { headers })).status).toBe(400); + }); + + it("denies suspended users", async () => { + const headers = await externalHeaders(); + await env.DB.prepare("UPDATE users SET suspended_at = 1 WHERE id = ?").bind(USER_ID).run(); + const response = await SELF.fetch(API, { headers }); + expect(response.status).toBe(403); + await expect(response.json()).resolves.toMatchObject({ code: "active_user_required" }); + }); + + it("rejects compound service:web and browser credentials on CLI-only routes", async () => { + const response = await serviceFetch(API); + expect(response.status).toBe(401); + }); + + it("uses Durable Object idempotency for strict text follow-ups", async () => { + const headers = await externalHeaders(); + const created = await SELF.fetch(API, { method: "POST", headers, body: createBody() }); + const { sessionId } = (await created.json()) as { sessionId: string }; + for (const invalid of [ + { content: "Continue", clientRequestId: "invalid-2", model: "unknown/model" }, + { + content: "Continue", + clientRequestId: "invalid-3", + model: "anthropic/claude-haiku-4-5", + reasoningEffort: "low", + }, + ]) { + expect( + ( + await SELF.fetch(`${API}/${sessionId}/messages`, { + method: "POST", + headers, + body: JSON.stringify(invalid), + }) + ).status + ).toBe(400); + } + const stub = env.SESSION.get(env.SESSION.idFromName(sessionId)); + expect((await queryDO(stub, "SELECT id FROM messages")).length).toBe(0); + const body = JSON.stringify({ + content: "Continue", + clientRequestId: "follow-up-1", + model: "openai/gpt-5.6-sol", + reasoningEffort: "high", + }); + const first = await SELF.fetch(`${API}/${sessionId}/messages`, { + method: "POST", + headers, + body, + }); + const retry = await SELF.fetch(`${API}/${sessionId}/messages`, { + method: "POST", + headers, + body, + }); + expect(first.status).toBe(200); + await expect(retry.json()).resolves.toEqual(await first.json()); + expect( + ( + await SELF.fetch(`${API}/${sessionId}/messages`, { + method: "POST", + headers, + body: JSON.stringify({ + content: "Changed", + clientRequestId: "follow-up-1", + model: "openai/gpt-5.6-sol", + reasoningEffort: "high", + }), + }) + ).status + ).toBe(409); + }); + + it("resolves reasoning-only follow-ups against the session model", async () => { + const headers = await externalHeaders(); + const created = await SELF.fetch(API, { method: "POST", headers, body: createBody() }); + const { sessionId } = await created.json<{ sessionId: string }>(); + const before = await env.DB.prepare("SELECT updated_at FROM sessions WHERE id = ?") + .bind(sessionId) + .first<{ updated_at: number }>(); + + const response = await SELF.fetch(`${API}/${sessionId}/messages`, { + method: "POST", + headers, + body: JSON.stringify({ + content: "Reason more", + clientRequestId: "reasoning-only", + reasoningEffort: "xhigh", + }), + }); + expect(response.status).toBe(200); + expect( + (await env.DB.prepare("SELECT updated_at FROM sessions WHERE id = ?") + .bind(sessionId) + .first<{ updated_at: number }>())!.updated_at + ).toBeGreaterThanOrEqual(before!.updated_at); + }); + + it("projects event snapshots conservatively and reports one-shot settled status", async () => { + const headers = await externalHeaders(); + const created = await SELF.fetch(API, { method: "POST", headers, body: createBody() }); + const { sessionId } = (await created.json()) as { sessionId: string }; + const stub = env.SESSION.get(env.SESSION.idFromName(sessionId)); + const managedSecret = "managed-value-123"; + const secrets = new GlobalSecretsStore(env.DB, env.REPO_SECRETS_ENCRYPTION_KEY!); + await secrets.setSecrets({ + EXTERNAL_TEST_SECRET: managedSecret, + }); + await seedEvents(stub, [ + { + id: "event-1", + type: "tool_call", + messageId: "message-1", + createdAt: 1, + data: JSON.stringify({ + type: "tool_call", + sandboxId: "sandbox-identity", + timestamp: 1, + messageId: "message-1", + tool: "shell", + callId: "call-1", + args: { command: "safe", authorization: "Bearer credential" }, + accessToken: "credential", + }), + }, + { + id: "event-2", + type: "step_finish", + messageId: "message-1", + createdAt: 2, + data: JSON.stringify({ + type: "step_finish", + sandboxId: "sandbox-identity", + timestamp: 2, + messageId: "message-1", + tokens: { input: 7, output: 3 }, + reason: `completed with ${managedSecret}`, + }), + }, + ]); + await secrets.setSecrets({ EXTERNAL_TEST_SECRET: "rotated-value-456" }); + const events = await SELF.fetch(`${API}/${sessionId}/events`, { headers }); + expect(events.status).toBe(200); + const eventBody = (await events.json()) as { + changes: Array<{ kind: "upsert"; event: { data: Record<string, unknown> } }>; + }; + const projectedEvents = eventBody.changes.map((change) => change.event); + const toolCall = projectedEvents.find((event) => event.data.type === "tool_call"); + const stepFinish = projectedEvents.find((event) => event.data.type === "step_finish"); + expect(toolCall?.data).toEqual({ + type: "tool_call", + timestamp: 1, + messageId: "message-1", + tool: "shell", + callId: "call-1", + args: { command: "safe" }, + }); + expect(JSON.stringify(eventBody)).not.toContain("sandbox-identity"); + expect(JSON.stringify(eventBody)).not.toContain("Bearer credential"); + expect(stepFinish?.data.tokens).toEqual({ input: 7, output: 3 }); + expect(stepFinish?.data.reason).toBe("completed with [REDACTED]"); + expect(JSON.stringify(eventBody)).not.toContain(managedSecret); + expect(JSON.stringify(eventBody)).not.toContain("rotated-value-456"); + + const waiting = await SELF.fetch(`${API}/${sessionId}/wait`, { headers }); + await expect(waiting.json()).resolves.toMatchObject({ status: "created", settled: false }); + await env.DB.prepare("UPDATE sessions SET status = 'completed' WHERE id = ?") + .bind(sessionId) + .run(); + const settled = await SELF.fetch(`${API}/${sessionId}/wait`, { headers }); + await expect(settled.json()).resolves.toMatchObject({ status: "completed", settled: true }); + }); + + it("pages a snapshot checkpoint and resumes older-event updates without gaps", async () => { + const headers = await externalHeaders(); + const created = await SELF.fetch(API, { + method: "POST", + headers, + body: createBody({ idempotencyKey: "event-feed" }), + }); + const { sessionId } = (await created.json()) as { sessionId: string }; + const stub = env.SESSION.get(env.SESSION.idFromName(sessionId)); + const eventData = (id: number, content = `event-${id}`) => + JSON.stringify({ + type: "token", + sandboxId: "sandbox-1", + timestamp: id, + messageId: "message-1", + content, + }); + await seedEvents( + stub, + [3, 1, 2].map((id) => ({ + id: `event-${id}`, + type: "token", + messageId: "message-1", + createdAt: id, + data: eventData(id), + })) + ); + + const first = (await ( + await SELF.fetch(`${API}/${sessionId}/events?limit=2`, { headers }) + ).json()) as { + changes: Array<{ kind: "upsert"; revision: number; event: { id: string } }>; + checkpoint: number; + cursor: string; + hasMore: boolean; + }; + expect(first).toMatchObject({ + changes: [ + { kind: "upsert", event: { id: "event-1" }, revision: 2 }, + { kind: "upsert", event: { id: "event-2" }, revision: 3 }, + ], + checkpoint: 3, + hasMore: true, + }); + + await updateEventData(stub, "event-1", eventData(1, "updated older event")); + await seedEvents(stub, [ + { + id: "event-4", + type: "token", + messageId: "message-1", + createdAt: 4, + data: eventData(4), + }, + ]); + await deleteEvent(stub, "event-3"); + + const continuation = (await ( + await SELF.fetch(`${API}/${sessionId}/events?cursor=${encodeURIComponent(first.cursor)}`, { + headers, + }) + ).json()) as { + changes: Array<{ kind: "upsert"; event: { id: string } }>; + checkpoint: number; + hasMore: boolean; + }; + expect(continuation).toMatchObject({ + changes: [{ kind: "upsert", event: { id: "event-3" }, revision: 1 }], + checkpoint: 3, + hasMore: false, + }); + + const resumed = (await ( + await SELF.fetch(`${API}/${sessionId}/events?after=${continuation.checkpoint}`, { headers }) + ).json()) as { + changes: Array<{ + kind: "upsert"; + revision: number; + event: { id: string; data: { content: string } }; + }>; + checkpoint: number; + hasMore: boolean; + }; + expect(resumed).toMatchObject({ + changes: [ + { + kind: "upsert", + revision: 4, + event: { id: "event-1" }, + }, + { kind: "upsert", revision: 5, event: { id: "event-4" } }, + { kind: "delete", revision: 6, eventId: "event-3" }, + ], + checkpoint: 6, + hasMore: false, + }); + + await updateEventData(stub, "event-1", eventData(1, "second update")); + await updateEventData(stub, "event-1", eventData(1, "third update")); + await deleteEvent(stub, "event-2"); + await renameEvent(stub, "event-4", "event-4-renamed"); + const later = (await ( + await SELF.fetch(`${API}/${sessionId}/events?after=5`, { headers }) + ).json()) as { changes: unknown[]; checkpoint: number }; + expect(later).toMatchObject({ + checkpoint: 11, + changes: [ + { kind: "delete", revision: 6, eventId: "event-3" }, + { + kind: "upsert", + revision: 7, + event: { id: "event-1" }, + }, + { + kind: "upsert", + revision: 8, + event: { id: "event-1" }, + }, + { kind: "delete", revision: 9, eventId: "event-2" }, + { kind: "delete", revision: 10, eventId: "event-4" }, + { kind: "upsert", revision: 11, event: { id: "event-4-renamed" } }, + ], + }); + + const parsedCursor = parseEventChangeCursor(first.cursor)!; + const futureCursor = encodeEventChangeCursor({ ...parsedCursor, checkpoint: 999 }); + expect( + ( + await SELF.fetch(`${API}/${sessionId}/events?cursor=${encodeURIComponent(futureCursor)}`, { + headers, + }) + ).status + ).toBe(400); + + const otherCreated = await SELF.fetch(API, { + method: "POST", + headers, + body: createBody({ idempotencyKey: "foreign-cursor" }), + }); + const { sessionId: otherSessionId } = (await otherCreated.json()) as { sessionId: string }; + expect( + ( + await SELF.fetch( + `${API}/${otherSessionId}/events?cursor=${encodeURIComponent(first.cursor)}`, + { headers } + ) + ).status + ).toBe(400); + }); + + it("does not decrypt global secrets for an empty event change page", async () => { + const headers = await externalHeaders(); + const created = await SELF.fetch(API, { + method: "POST", + headers, + body: createBody({ idempotencyKey: "empty-events" }), + }); + const { sessionId } = (await created.json()) as { sessionId: string }; + await env.DB.prepare( + `INSERT INTO global_secrets (key, encrypted_value, created_at, updated_at) + VALUES ('BROKEN', 'not-ciphertext', 1, 1)` + ).run(); + + const response = await SELF.fetch(`${API}/${sessionId}/events`, { headers }); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toMatchObject({ + changes: [], + checkpoint: 0, + hasMore: false, + }); + }); + + it("returns an explicit error for an expired event checkpoint", async () => { + const headers = await externalHeaders(); + const created = await SELF.fetch(API, { + method: "POST", + headers, + body: createBody({ idempotencyKey: "expired-events" }), + }); + const { sessionId } = (await created.json()) as { sessionId: string }; + const stub = env.SESSION.get(env.SESSION.idFromName(sessionId)); + await queryDO( + stub, + "UPDATE event_feed_state SET current_revision = 2, retention_floor = 2 WHERE singleton = 1" + ); + + const response = await SELF.fetch(`${API}/${sessionId}/events?after=1`, { headers }); + + expect(response.status).toBe(410); + await expect(response.json()).resolves.toMatchObject({ + error: "Event checkpoint expired", + code: "event_checkpoint_expired", + }); + }); +}); diff --git a/packages/control-plane/test/integration/helpers.ts b/packages/control-plane/test/integration/helpers.ts index 3f25f015c8..bdbd1edee7 100644 --- a/packages/control-plane/test/integration/helpers.ts +++ b/packages/control-plane/test/integration/helpers.ts @@ -295,16 +295,145 @@ export async function seedEvents( ): Promise<void> { await runInSessionDO(stub, (instance: SessionDO, state) => { for (const e of events) { + state.storage.transactionSync(() => { + const revision = state.storage.sql + .exec( + `UPDATE event_feed_state SET current_revision = current_revision + 1 + WHERE singleton = 1 RETURNING current_revision` + ) + .one().current_revision as number; + state.storage.sql.exec( + `INSERT INTO events + (id, type, data, message_id, created_at, timeline_sequence, change_revision) + VALUES (?, ?, ?, ?, ?, + (SELECT COALESCE(MAX(timeline_sequence), 0) + 1 FROM events), ?)`, + e.id, + e.type, + e.data, + e.messageId ?? null, + e.createdAt, + revision + ); + state.storage.sql.exec( + `INSERT INTO event_changes + (revision, kind, event_id, type, data, message_id, created_at, timeline_sequence, + changed_at, journal_bytes) + SELECT ?, 'upsert', id, type, data, message_id, created_at, timeline_sequence, ?, + 64 + length(CAST(id AS BLOB)) + length(CAST(type AS BLOB)) + + length(CAST(data AS BLOB)) + COALESCE(length(CAST(message_id AS BLOB)), 0) + FROM events WHERE id = ?`, + revision, + Date.now(), + e.id + ); + }); + } + }); +} + +export async function updateEventData( + stub: DurableObjectStub, + eventId: string, + data: string +): Promise<void> { + await runInSessionDO(stub, (instance: SessionDO, state) => { + state.storage.transactionSync(() => { + const revision = state.storage.sql + .exec( + `UPDATE event_feed_state SET current_revision = current_revision + 1 + WHERE singleton = 1 RETURNING current_revision` + ) + .one().current_revision as number; state.storage.sql.exec( - `INSERT INTO events (id, type, data, message_id, created_at, timeline_sequence) - VALUES (?, ?, ?, ?, ?, (SELECT COALESCE(MAX(timeline_sequence), 0) + 1 FROM events))`, - e.id, - e.type, - e.data, - e.messageId ?? null, - e.createdAt + `UPDATE events SET data = ?, change_revision = ? WHERE id = ?`, + data, + revision, + eventId ); - } + state.storage.sql.exec( + `INSERT INTO event_changes + (revision, kind, event_id, type, data, message_id, created_at, timeline_sequence, + changed_at, journal_bytes) + SELECT ?, 'upsert', id, type, data, message_id, created_at, timeline_sequence, ?, + 64 + length(CAST(id AS BLOB)) + length(CAST(type AS BLOB)) + + length(CAST(data AS BLOB)) + COALESCE(length(CAST(message_id AS BLOB)), 0) + FROM events WHERE id = ?`, + revision, + Date.now(), + eventId + ); + }); + }); +} + +export async function deleteEvent(stub: DurableObjectStub, eventId: string): Promise<void> { + await runInSessionDO(stub, (instance: SessionDO, state) => { + state.storage.transactionSync(() => { + const revision = state.storage.sql + .exec( + `UPDATE event_feed_state SET current_revision = current_revision + 1 + WHERE singleton = 1 RETURNING current_revision` + ) + .one().current_revision as number; + state.storage.sql.exec(`DELETE FROM events WHERE id = ?`, eventId); + state.storage.sql.exec( + `INSERT INTO event_changes (revision, kind, event_id, changed_at, journal_bytes) + VALUES (?, 'delete', ?, ?, 64 + length(CAST(? AS BLOB)))`, + revision, + eventId, + Date.now(), + eventId + ); + }); + }); +} + +export async function renameEvent( + stub: DurableObjectStub, + oldEventId: string, + newEventId: string +): Promise<void> { + await runInSessionDO(stub, (instance: SessionDO, state) => { + state.storage.transactionSync(() => { + const deleteRevision = state.storage.sql + .exec( + `UPDATE event_feed_state SET current_revision = current_revision + 1 + WHERE singleton = 1 RETURNING current_revision` + ) + .one().current_revision as number; + const upsertRevision = state.storage.sql + .exec( + `UPDATE event_feed_state SET current_revision = current_revision + 1 + WHERE singleton = 1 RETURNING current_revision` + ) + .one().current_revision as number; + state.storage.sql.exec( + `UPDATE events SET id = ?, change_revision = ? WHERE id = ?`, + newEventId, + upsertRevision, + oldEventId + ); + state.storage.sql.exec( + `INSERT INTO event_changes (revision, kind, event_id, changed_at, journal_bytes) + VALUES (?, 'delete', ?, ?, 64 + length(CAST(? AS BLOB)))`, + deleteRevision, + oldEventId, + Date.now(), + oldEventId + ); + state.storage.sql.exec( + `INSERT INTO event_changes + (revision, kind, event_id, type, data, message_id, created_at, timeline_sequence, + changed_at, journal_bytes) + SELECT ?, 'upsert', id, type, data, message_id, created_at, timeline_sequence, ?, + 64 + length(CAST(id AS BLOB)) + length(CAST(type AS BLOB)) + + length(CAST(data AS BLOB)) + COALESCE(length(CAST(message_id AS BLOB)), 0) + FROM events WHERE id = ?`, + upsertRevision, + Date.now(), + newEventId + ); + }); }); } diff --git a/packages/control-plane/test/integration/migration-0075-cli-authentication.test.ts b/packages/control-plane/test/integration/migration-0075-cli-authentication.test.ts new file mode 100644 index 0000000000..2f1441c306 --- /dev/null +++ b/packages/control-plane/test/integration/migration-0075-cli-authentication.test.ts @@ -0,0 +1,71 @@ +import { env } from "cloudflare:test"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { cleanD1Tables } from "./cleanup"; + +beforeEach(cleanD1Tables); +afterEach(cleanD1Tables); + +describe("migration 0075: CLI authentication", () => { + it("creates hash-only attempts and revocable user credentials", async () => { + const attemptColumns = await env.DB.prepare( + "PRAGMA table_info(cli_device_authorization_attempts)" + ).all<{ name: string }>(); + const credentialColumns = await env.DB.prepare("PRAGMA table_info(cli_credentials)").all<{ + name: string; + }>(); + const rateLimitColumns = await env.DB.prepare("PRAGMA table_info(cli_auth_rate_limits)").all<{ + name: string; + }>(); + const attemptIndexes = await env.DB.prepare( + "PRAGMA index_list(cli_device_authorization_attempts)" + ).all<{ name: string }>(); + const credentialIndexes = await env.DB.prepare("PRAGMA index_list(cli_credentials)").all<{ + name: string; + }>(); + const rateLimitIndexes = await env.DB.prepare("PRAGMA index_list(cli_auth_rate_limits)").all<{ + name: string; + }>(); + + expect(attemptColumns.results.map((column) => column.name)).toEqual([ + "id", + "device_name", + "device_secret_hash", + "user_code_hash", + "approved_user_id", + "exchange_claim_id", + "issued_credential_id", + "created_at", + "expires_at", + "approved_at", + "exchanged_at", + "capability_revoked_at", + ]); + expect(credentialColumns.results.map((column) => column.name)).toEqual([ + "id", + "token_hash", + "user_id", + "created_at", + "expires_at", + "last_seen_at", + "revoked_at", + ]); + expect(attemptColumns.results.map((column) => column.name)).not.toContain("device_secret"); + expect(credentialColumns.results.map((column) => column.name)).not.toContain("token"); + expect(rateLimitColumns.results.map((column) => column.name)).toEqual([ + "rate_key", + "window_started_at", + "request_count", + "expires_at", + ]); + expect(attemptIndexes.results.map((index) => index.name)).toContain( + "idx_cli_device_authorization_expiry" + ); + expect(credentialIndexes.results.map((index) => index.name)).toEqual( + expect.arrayContaining(["idx_cli_credentials_expiry", "idx_cli_credentials_revoked"]) + ); + expect(rateLimitIndexes.results.map((index) => index.name)).toContain( + "idx_cli_auth_rate_limits_expiry" + ); + expect((await env.DB.prepare("PRAGMA foreign_key_check").all()).results).toEqual([]); + }); +}); diff --git a/packages/control-plane/test/integration/migration-0076-external-session-request-fingerprint.test.ts b/packages/control-plane/test/integration/migration-0076-external-session-request-fingerprint.test.ts new file mode 100644 index 0000000000..cb6fe8f901 --- /dev/null +++ b/packages/control-plane/test/integration/migration-0076-external-session-request-fingerprint.test.ts @@ -0,0 +1,14 @@ +import { env } from "cloudflare:test"; +import { describe, expect, it } from "vitest"; + +describe("migration 0076: external session request fingerprint", () => { + it("adds a nullable canonical request fingerprint to sessions", async () => { + const columns = await env.DB.prepare("PRAGMA table_info(sessions)").all<{ + name: string; + notnull: number; + }>(); + expect(columns.results).toContainEqual( + expect.objectContaining({ name: "external_request_fingerprint", notnull: 0 }) + ); + }); +}); diff --git a/packages/control-plane/test/integration/migration-0078-external-session-bootstrap-snapshot.test.ts b/packages/control-plane/test/integration/migration-0078-external-session-bootstrap-snapshot.test.ts new file mode 100644 index 0000000000..df8b2d66f4 --- /dev/null +++ b/packages/control-plane/test/integration/migration-0078-external-session-bootstrap-snapshot.test.ts @@ -0,0 +1,14 @@ +import { env } from "cloudflare:test"; +import { describe, expect, it } from "vitest"; + +describe("migration 0078: external session bootstrap snapshot", () => { + it("adds a nullable resolved bootstrap snapshot to sessions", async () => { + const columns = await env.DB.prepare("PRAGMA table_info(sessions)").all<{ + name: string; + notnull: number; + }>(); + expect(columns.results).toContainEqual( + expect.objectContaining({ name: "external_bootstrap_snapshot", notnull: 0 }) + ); + }); +}); diff --git a/packages/control-plane/test/integration/user-merge.test.ts b/packages/control-plane/test/integration/user-merge.test.ts index b23bdc5aef..93b426bc72 100644 --- a/packages/control-plane/test/integration/user-merge.test.ts +++ b/packages/control-plane/test/integration/user-merge.test.ts @@ -91,6 +91,20 @@ describe("mergeUsers", () => { await insertScmToken("583231", LOSER); await insertSkillProfile("profile-loser", LOSER, "Personal profile"); await insertAuthSession({ id: "authsess-loser", userId: LOSER }); + await env.DB.batch([ + env.DB.prepare( + `INSERT INTO cli_credentials + (id, token_hash, user_id, created_at, expires_at, revoked_at) + VALUES ('cli-active', 'active-hash', ?, 1, 9999999999999, NULL), + ('cli-revoked', 'revoked-hash', ?, 1, 9999999999999, 2)` + ).bind(LOSER, LOSER), + env.DB.prepare( + `INSERT INTO cli_device_authorization_attempts + (id, device_name, device_secret_hash, user_code_hash, approved_user_id, + created_at, expires_at, approved_at) + VALUES ('approved-attempt', 'laptop', 'device-hash', 'code-hash', ?, 1, 9999999999999, 2)` + ).bind(LOSER), + ]); // Survivor: the email-owning row the user already signs into. await insertCanonicalUser({ id: SURVIVOR, email: "person@example.com", emailVerified: 1 }); await insertIdentity({ @@ -112,6 +126,8 @@ describe("mergeUsers", () => { identitiesRepointed: 1, sessionsRepointed: 1, authSessionsDeleted: 1, + cliCredentialsRepointed: 2, + cliApprovedAttemptsRepointed: 1, automationsOwnedRepointed: 1, automationsCreatedRepointed: 1, scmTokensRepointed: 1, @@ -138,6 +154,19 @@ describe("mergeUsers", () => { userId: string; }>() ).toBeNull(); + expect( + await env.DB.prepare("SELECT id, user_id, revoked_at FROM cli_credentials ORDER BY id").all() + ).toMatchObject({ + results: [ + { id: "cli-active", user_id: SURVIVOR, revoked_at: null }, + { id: "cli-revoked", user_id: SURVIVOR, revoked_at: 2 }, + ], + }); + expect( + await env.DB.prepare( + "SELECT approved_user_id FROM cli_device_authorization_attempts WHERE id = 'approved-attempt'" + ).first() + ).toEqual({ approved_user_id: SURVIVOR }); expect( await env.DB.prepare( `SELECT user_id, created_by FROM automations WHERE id = 'auto-1'` diff --git a/packages/control-plane/vitest.integration.config.ts b/packages/control-plane/vitest.integration.config.ts index 6c1de05188..bc08c40972 100644 --- a/packages/control-plane/vitest.integration.config.ts +++ b/packages/control-plane/vitest.integration.config.ts @@ -134,6 +134,7 @@ export default defineConfig({ // capture encrypts with it inline (fail-closed) rather than // inside a swallowed waitUntil. TOKEN_ENCRYPTION_KEY: generateTestEncryptionKey(), + EXTERNAL_SESSION_ID_SECRET: generateTestEncryptionKey(), REPO_SECRETS_ENCRYPTION_KEY: generateTestEncryptionKey(), PROVIDER_ACCOUNTS_ENCRYPTION_KEY: generateTestEncryptionKey(), DEPLOYMENT_NAME: "integration-test", diff --git a/packages/shared/package.json b/packages/shared/package.json index 7da96d46da..bd6266b54c 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -70,6 +70,14 @@ "import": "./dist/types/environments.js", "types": "./dist/types/environments.d.ts" }, + "./types/external-session-api": { + "import": "./dist/types/external-session-api.js", + "types": "./dist/types/external-session-api.d.ts" + }, + "./types/external-resources-api": { + "import": "./dist/types/external-resources-api.js", + "types": "./dist/types/external-resources-api.d.ts" + }, "./types/github-identity": { "import": "./dist/types/github-identity.js", "types": "./dist/types/github-identity.d.ts" @@ -186,6 +194,10 @@ "import": "./dist/types/analytics.js", "types": "./dist/types/analytics.d.ts" }, + "./types/cli-auth": { + "import": "./dist/types/cli-auth.js", + "types": "./dist/types/cli-auth.d.ts" + }, "./types/audit-events": { "import": "./dist/types/audit-events.js", "types": "./dist/types/audit-events.d.ts" diff --git a/packages/shared/src/types/cli-auth.test.ts b/packages/shared/src/types/cli-auth.test.ts new file mode 100644 index 0000000000..dd9615a18f --- /dev/null +++ b/packages/shared/src/types/cli-auth.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it } from "vitest"; +import { + CLI_CREDENTIAL_PATTERN, + CLI_DEVICE_SECRET_PATTERN, + approveCliDeviceAuthorizationRequestSchema, + cliDeviceAuthorizationExchangeRequestSchema, + cliDeviceAuthorizationExchangeResponseSchema, + cliMeResponseSchema, + pendingCliDeviceAuthorizationResponseSchema, + revokeCliDeviceAuthorizationRequestSchema, + startCliDeviceAuthorizationRequestSchema, + startCliDeviceAuthorizationResponseSchema, +} from "./cli-auth"; + +const DEVICE_SECRET = "a".repeat(64); +const CREDENTIAL = `oi_cli_${"b".repeat(64)}`; + +describe("CLI authentication contracts", () => { + it("accepts the separate device authorization inputs and start response", () => { + expect(startCliDeviceAuthorizationRequestSchema.parse({ deviceName: "dev laptop" })).toEqual({ + deviceName: "dev laptop", + }); + expect( + startCliDeviceAuthorizationResponseSchema.parse({ + deviceSecret: DEVICE_SECRET, + userCode: "ABCD-EFGH", + verificationUrl: "https://app.example.com/cli/authorize?user_code=ABCD-EFGH", + expiresAt: 1234, + pollIntervalMs: 1000, + }) + ).toMatchObject({ deviceSecret: DEVICE_SECRET, userCode: "ABCD-EFGH" }); + expect(CLI_DEVICE_SECRET_PATTERN.test(DEVICE_SECRET)).toBe(true); + }); + + it("does not allow the human code to be used as the device exchange secret", () => { + expect(approveCliDeviceAuthorizationRequestSchema.parse({ userCode: "abcd-efgh" })).toEqual({ + userCode: "ABCD-EFGH", + }); + expect( + cliDeviceAuthorizationExchangeRequestSchema.safeParse({ deviceSecret: "ABCD-EFGH" }).success + ).toBe(false); + expect( + revokeCliDeviceAuthorizationRequestSchema.parse({ deviceSecret: DEVICE_SECRET }) + ).toEqual({ deviceSecret: DEVICE_SECRET }); + }); + + it("validates pending and authorized exchange responses", () => { + expect( + cliDeviceAuthorizationExchangeResponseSchema.parse({ status: "pending", expiresAt: 1234 }) + ).toEqual({ status: "pending", expiresAt: 1234 }); + expect( + cliDeviceAuthorizationExchangeResponseSchema.parse({ + status: "authorized", + credential: CREDENTIAL, + credentialId: "credential-id", + expiresAt: 5678, + }) + ).toMatchObject({ status: "authorized", credential: CREDENTIAL }); + }); + + it("limits pending authorization details to safe display metadata", () => { + expect( + pendingCliDeviceAuthorizationResponseSchema.parse({ + installation: { name: "Acme Open-Inspect" }, + deviceName: "dev laptop", + expiresAt: 1234, + }) + ).toEqual({ + installation: { name: "Acme Open-Inspect" }, + deviceName: "dev laptop", + expiresAt: 1234, + }); + expect( + pendingCliDeviceAuthorizationResponseSchema.safeParse({ + installation: { name: "Acme Open-Inspect" }, + deviceName: "dev laptop", + expiresAt: 1234, + deviceSecretHash: "secret", + }).success + ).toBe(false); + expect( + pendingCliDeviceAuthorizationResponseSchema.safeParse({ + installation: { name: "Acme Open-Inspect", internalId: "secret" }, + deviceName: "dev laptop", + expiresAt: 1234, + }).success + ).toBe(false); + }); + + it("validates credential and current-user responses without exposing hashes", () => { + expect(CLI_CREDENTIAL_PATTERN.test(CREDENTIAL)).toBe(true); + expect( + cliMeResponseSchema.parse({ + installation: { name: "Acme Open-Inspect" }, + user: { id: "1".repeat(32), displayName: "Alice", email: "alice@example.com" }, + credential: { id: "credential-id", expiresAt: 5678 }, + }) + ).toMatchObject({ user: { id: "1".repeat(32) } }); + }); + + it("rejects unknown fields and malformed secrets", () => { + expect( + startCliDeviceAuthorizationRequestSchema.safeParse({ deviceName: "laptop", scope: "admin" }) + .success + ).toBe(false); + expect( + cliDeviceAuthorizationExchangeResponseSchema.safeParse({ + status: "authorized", + credential: "not-a-cli-credential", + credentialId: "credential-id", + expiresAt: 5678, + }).success + ).toBe(false); + }); +}); diff --git a/packages/shared/src/types/cli-auth.ts b/packages/shared/src/types/cli-auth.ts new file mode 100644 index 0000000000..ea7c5a380a --- /dev/null +++ b/packages/shared/src/types/cli-auth.ts @@ -0,0 +1,81 @@ +import { z } from "zod"; +import { isCanonicalUserId } from "../user-id"; + +export const CLI_EXTERNAL_API_V1_PATH = "/external/v1/cli"; +export const CLI_EXTERNAL_API_VERSION = "1"; +export const CLI_API_VERSION_HEADER = "X-Open-Inspect-API-Version"; +export const CLI_CLIENT_VERSION_HEADER = "X-Open-Inspect-Client-Version"; +export const CLI_CLIENT_SURFACE_HEADER = "X-Open-Inspect-Client-Surface"; +export const CLI_DEVICE_SECRET_PATTERN = /^[0-9a-f]{64}$/; +export const CLI_CREDENTIAL_PATTERN = /^oi_cli_[0-9a-f]{64}$/; +export const CLI_USER_CODE_PATTERN = /^[A-Z0-9]{4}-[A-Z0-9]{4}$/; + +const timestampSchema = z.number().int().nonnegative(); +const credentialFields = { + credential: z.string().regex(CLI_CREDENTIAL_PATTERN), + credentialId: z.string().min(1), + expiresAt: timestampSchema, +} as const; + +export const startCliDeviceAuthorizationRequestSchema = z.strictObject({ + deviceName: z.string().trim().min(1).max(100), +}); + +export const startCliDeviceAuthorizationResponseSchema = z.strictObject({ + deviceSecret: z.string().regex(CLI_DEVICE_SECRET_PATTERN), + userCode: z.string().regex(CLI_USER_CODE_PATTERN), + verificationUrl: z.url(), + expiresAt: timestampSchema, + pollIntervalMs: z.number().int().positive(), +}); + +export const approveCliDeviceAuthorizationRequestSchema = z.strictObject({ + userCode: z + .string() + .transform((value) => value.trim().toUpperCase()) + .pipe(z.string().regex(CLI_USER_CODE_PATTERN)), +}); + +export const pendingCliDeviceAuthorizationResponseSchema = z.strictObject({ + installation: z.strictObject({ name: z.string().min(1) }), + deviceName: z.string().min(1).max(100), + expiresAt: timestampSchema, +}); + +export const cliDeviceAuthorizationExchangeRequestSchema = z.strictObject({ + deviceSecret: z.string().regex(CLI_DEVICE_SECRET_PATTERN), +}); + +export const revokeCliDeviceAuthorizationRequestSchema = z.strictObject({ + deviceSecret: z.string().regex(CLI_DEVICE_SECRET_PATTERN), +}); + +export const cliDeviceAuthorizationExchangeResponseSchema = z.discriminatedUnion("status", [ + z.strictObject({ status: z.literal("pending"), expiresAt: timestampSchema }), + z.strictObject({ status: z.literal("authorized"), ...credentialFields }), +]); + +export const cliMeResponseSchema = z.strictObject({ + installation: z.strictObject({ name: z.string().min(1) }), + user: z.strictObject({ + id: z.string().refine(isCanonicalUserId, "Invalid canonical user ID"), + displayName: z.string().nullable(), + email: z.string().nullable(), + }), + credential: z.strictObject({ id: z.string().min(1), expiresAt: timestampSchema }), + serverVersion: z.string().min(1).optional(), +}); + +export type StartCliDeviceAuthorizationRequest = z.infer< + typeof startCliDeviceAuthorizationRequestSchema +>; +export type StartCliDeviceAuthorizationResponse = z.infer< + typeof startCliDeviceAuthorizationResponseSchema +>; +export type CliDeviceAuthorizationExchangeResponse = z.infer< + typeof cliDeviceAuthorizationExchangeResponseSchema +>; +export type PendingCliDeviceAuthorizationResponse = z.infer< + typeof pendingCliDeviceAuthorizationResponseSchema +>; +export type CliMeResponse = z.infer<typeof cliMeResponseSchema>; diff --git a/packages/shared/src/types/external-resources-api.test.ts b/packages/shared/src/types/external-resources-api.test.ts new file mode 100644 index 0000000000..3750fcf796 --- /dev/null +++ b/packages/shared/src/types/external-resources-api.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import { + externalArtifactListResponseSchema, + externalDiffStateResponseSchema, + externalMessageListResponseSchema, +} from "./external-resources-api"; + +describe("external resource pagination schemas", () => { + it("requires an opaque cursor for truncated message pages", () => { + expect( + externalMessageListResponseSchema.safeParse({ messages: [], hasMore: true }).success + ).toBe(false); + expect( + externalMessageListResponseSchema.safeParse({ + messages: [], + hasMore: true, + cursor: "opaque", + }).success + ).toBe(true); + }); + + it("requires continuation state for truncated artifact and diff pages", () => { + expect( + externalArtifactListResponseSchema.safeParse({ artifacts: [], hasMore: true }).success + ).toBe(false); + expect( + externalDiffStateResponseSchema.safeParse({ + version: 1, + current: null, + lastError: null, + unavailableReason: null, + hasMore: true, + continuationOffset: 1, + }).success + ).toBe(false); + }); +}); diff --git a/packages/shared/src/types/external-resources-api.ts b/packages/shared/src/types/external-resources-api.ts new file mode 100644 index 0000000000..5bc5c3d580 --- /dev/null +++ b/packages/shared/src/types/external-resources-api.ts @@ -0,0 +1,238 @@ +import { z } from "zod"; +import { sessionStatusSchema } from "./sessions"; +import { sessionDiffStateSchema } from "./session-diffs"; + +const text = z.string().min(1); + +export const externalListQuerySchema = z.strictObject({ + limit: z.number().int().min(1).max(100).optional(), + offset: z.number().int().nonnegative().optional(), +}); +export type ExternalListQuery = z.infer<typeof externalListQuerySchema>; + +export const externalKeysetListQuerySchema = z.strictObject({ + limit: z.number().int().min(1).max(100).optional(), + cursor: z.string().min(1).optional(), +}); +export type ExternalKeysetListQuery = z.infer<typeof externalKeysetListQuerySchema>; + +export const externalDiffListQuerySchema = z.strictObject({ + limit: z.number().int().min(1).max(100).optional(), + offset: z.number().int().nonnegative().optional(), + revisionId: z.string().min(1).optional(), +}); +export type ExternalDiffListQuery = z.infer<typeof externalDiffListQuerySchema>; + +function page<T extends z.ZodTypeAny>(key: string, item: T) { + return z + .object({ + [key]: z.array(item), + hasMore: z.boolean(), + continuationOffset: z.number().int().nonnegative().optional(), + }) + .refine((value) => !value.hasMore || value.continuationOffset !== undefined, { + message: "continuationOffset is required when hasMore is true", + path: ["continuationOffset"], + }); +} + +export const externalRepositorySchema = z.object({ + id: z.union([z.string(), z.number()]), + owner: text, + name: text, + fullName: text, + description: z.string().nullable(), + private: z.boolean(), + defaultBranch: text, + archived: z.boolean(), +}); +export const externalRepositoryListResponseSchema = page("repositories", externalRepositorySchema); + +export const externalEnvironmentRepositorySchema = z.object({ + repoOwner: text, + repoName: text, + repoId: z.number().nullable(), + baseBranch: text, +}); +export const externalEnvironmentSchema = z.object({ + id: text, + name: text, + description: z.string().nullable(), + prebuildEnabled: z.boolean(), + repositories: z.array(externalEnvironmentRepositorySchema), + createdAt: z.number(), + updatedAt: z.number(), +}); +export const externalEnvironmentListResponseSchema = page( + "environments", + externalEnvironmentSchema +); +export const externalEnvironmentResponseSchema = z.object({ + environment: externalEnvironmentSchema, +}); + +export const externalModelSchema = z.object({ + id: text, + name: text, + description: z.string(), + category: z.string(), + default: z.boolean().optional(), + reasoning: z.object({ efforts: z.array(z.string()), default: z.string().optional() }).nullable(), +}); +export const externalModelListResponseSchema = z.object({ models: z.array(externalModelSchema) }); + +export const externalSkillSchema = z.object({ + id: text, + name: text, + description: z.string().optional(), + enabled: z.boolean().optional(), +}); +export const externalSkillProfileSchema = z.object({ + id: text, + name: text, + skillIds: z.array(z.string()), +}); +export const externalSkillListResponseSchema = z.object({ + skills: z.array(externalSkillSchema), + profiles: z.array(externalSkillProfileSchema), + hasMore: z.boolean(), + continuationOffset: z.number().int().nonnegative().optional(), +}); + +export const externalProviderAccountSchema = z.object({ + id: text, + provider: text, + displayName: text, + status: text, + isDefault: z.boolean(), + unattendedMode: z.string().nullable(), +}); +export const externalProviderAccountListResponseSchema = page( + "accounts", + externalProviderAccountSchema +); + +export const externalMessageSchema = z.object({ + id: text, + authorId: text, + content: z.string(), + source: z.string(), + attachments: z.array(z.unknown()).nullable(), + status: z.string(), + createdAt: z.number(), + startedAt: z.number().nullable(), + completedAt: z.number().nullable(), +}); +export const externalMessageListResponseSchema = z + .object({ + messages: z.array(externalMessageSchema), + cursor: z.string().min(1).optional(), + hasMore: z.boolean(), + }) + .refine((page) => !page.hasMore || page.cursor !== undefined, { + message: "cursor is required when hasMore is true", + path: ["cursor"], + }); + +export const externalArtifactSchema = z.object({ + id: text, + type: z.enum(["pr", "screenshot", "video", "preview", "branch"]), + url: z.string().nullable(), + metadata: z.record(z.string(), z.unknown()).nullable(), + createdAt: z.number(), + updatedAt: z.number(), +}); +export const externalArtifactListResponseSchema = z + .object({ + artifacts: z.array(externalArtifactSchema), + cursor: z.string().min(1).optional(), + hasMore: z.boolean(), + }) + .refine((page) => !page.hasMore || page.cursor !== undefined, { + message: "cursor is required when hasMore is true", + path: ["cursor"], + }); +export const externalArtifactContentResponseSchema = z.object({ + contentType: text, + contentBase64: z.string(), + offset: z.number().int().nonnegative(), + hasMore: z.boolean(), + continuationOffset: z.number().int().nonnegative().optional(), +}); + +export const externalDiffStateResponseSchema = sessionDiffStateSchema + .extend({ + hasMore: z.boolean(), + continuationOffset: z.number().int().nonnegative().optional(), + continuationRevisionId: z.string().min(1).optional(), + }) + .refine( + (page) => + !page.hasMore || + (page.continuationOffset !== undefined && page.continuationRevisionId !== undefined), + { + message: "continuationOffset and continuationRevisionId are required when hasMore is true", + path: ["continuationOffset"], + } + ); +export const externalDiffContentResponseSchema = z.object({ + content: z.string(), + truncated: z.boolean(), + hasMore: z.boolean(), + continuationOffset: z.number().int().nonnegative().optional(), +}); + +export const externalPullRequestSchema = z + .object({ + id: text, + provider: text, + repoOwner: text, + repoName: text, + number: z.number().int().positive(), + url: z.string(), + state: z.string(), + headBranch: z.string().nullable(), + baseBranch: z.string().nullable(), + }) + .passthrough(); +export const externalPullRequestListResponseSchema = z.object({ + pullRequests: z.array(externalPullRequestSchema), + hasMore: z.boolean(), + continuationOffset: z.number().int().nonnegative().optional(), +}); + +export const externalChildSessionSchema = z + .object({ + id: text, + title: z.string().nullable(), + status: sessionStatusSchema, + model: text, + reasoningEffort: z.string().nullable(), + repoOwner: z.string().nullable(), + repoName: z.string().nullable(), + environmentId: z.string().nullable(), + parentSessionId: z.string().nullable(), + createdAt: z.number(), + updatedAt: z.number(), + }) + .passthrough(); +export const externalChildSessionListResponseSchema = z.object({ + children: z.array(externalChildSessionSchema), + hasMore: z.boolean(), + continuationOffset: z.number().int().nonnegative().optional(), +}); + +export const externalChildPromptRequestSchema = z.strictObject({ + content: text, + clientRequestId: text.max(128), +}); + +export type ExternalRepository = z.infer<typeof externalRepositorySchema>; +export type ExternalEnvironment = z.infer<typeof externalEnvironmentSchema>; +export type ExternalModel = z.infer<typeof externalModelSchema>; +export type ExternalSkill = z.infer<typeof externalSkillSchema>; +export type ExternalProviderAccount = z.infer<typeof externalProviderAccountSchema>; +export type ExternalMessage = z.infer<typeof externalMessageSchema>; +export type ExternalArtifact = z.infer<typeof externalArtifactSchema>; +export type ExternalPullRequest = z.infer<typeof externalPullRequestSchema>; +export type ExternalChildSession = z.infer<typeof externalChildSessionSchema>; diff --git a/packages/shared/src/types/external-session-api.test.ts b/packages/shared/src/types/external-session-api.test.ts new file mode 100644 index 0000000000..757cf9e93f --- /dev/null +++ b/packages/shared/src/types/external-session-api.test.ts @@ -0,0 +1,175 @@ +import { describe, expect, it } from "vitest"; +import { + externalCreateSessionRequestSchema, + externalCreateSessionResponseSchema, + externalEventFeedQuerySchema, + externalEventPageSchema, + externalFollowUpRequestSchema, + externalFollowUpResponseSchema, + externalSessionListQuerySchema, +} from "./external-session-api"; + +describe("external session API schemas", () => { + const validCreate = { + title: "Investigate an issue", + model: "openai/gpt-5.6-sol", + reasoningEffort: "high", + initialPrompt: "Inspect the behavior", + idempotencyKey: "create-1", + }; + + it("accepts the repository-less text create contract", () => { + expect(externalCreateSessionRequestSchema.parse(validCreate)).toEqual(validCreate); + }); + + it.each([ + ["repoOwner", "acme"], + ["repoName", "app"], + ["repositories", []], + ["branch", "main"], + ["attachments", []], + ["provider", "openai"], + ])("explicitly rejects unsupported create field %s", (field, value) => { + expect( + externalCreateSessionRequestSchema.safeParse({ ...validCreate, [field]: value }).success + ).toBe(false); + }); + + it("accepts mutually exclusive V1 targets and execution selections", () => { + expect( + externalCreateSessionRequestSchema.parse({ + ...validCreate, + environmentId: "env-1", + skillSelection: { mode: "all" }, + providerSelections: {}, + }) + ).toMatchObject({ environmentId: "env-1", skillSelection: { mode: "all" } }); + expect( + externalCreateSessionRequestSchema.safeParse({ + ...validCreate, + environmentId: "env-1", + repoOwner: "acme", + repoName: "app", + }).success + ).toBe(false); + }); + + it("keeps model and reasoning fields structural rather than embedding server policy", () => { + expect( + externalCreateSessionRequestSchema.safeParse({ ...validCreate, model: "unknown/model" }) + .success + ).toBe(true); + expect( + externalCreateSessionRequestSchema.safeParse({ + ...validCreate, + model: "anthropic/claude-haiku-4-5", + reasoningEffort: "low", + }).success + ).toBe(true); + const { reasoningEffort: _reasoningEffort, ...withoutReasoning } = validCreate; + expect(externalCreateSessionRequestSchema.safeParse(withoutReasoning).success).toBe(true); + }); + + it("requires content or attachments and a clientRequestId", () => { + expect( + externalFollowUpRequestSchema.parse({ + content: "Continue", + clientRequestId: "request-1", + model: "openai/gpt-5.6-sol", + reasoningEffort: "xhigh", + }) + ).toMatchObject({ content: "Continue", clientRequestId: "request-1" }); + expect(externalFollowUpRequestSchema.safeParse({ content: "Continue" }).success).toBe(false); + expect( + externalFollowUpRequestSchema.safeParse({ + content: " ", + clientRequestId: "request-1", + }).success + ).toBe(false); + expect( + externalFollowUpRequestSchema.safeParse({ + clientRequestId: "request-1", + attachments: [{ attachmentId: "attachment-1", name: "image.png" }], + }).success + ).toBe(true); + }); + + it("requires exact success response shapes", () => { + expect( + externalCreateSessionResponseSchema.parse({ sessionId: "session-1", status: "created" }) + ).toEqual({ sessionId: "session-1", status: "created" }); + expect( + externalCreateSessionResponseSchema.parse({ + sessionId: "session-1", + messageId: "message-1", + status: "queued", + }) + ).toMatchObject({ status: "queued" }); + expect( + externalCreateSessionResponseSchema.safeParse({ + sessionId: "session-1", + status: "queued", + }).success + ).toBe(false); + expect( + externalFollowUpResponseSchema.safeParse({ messageId: "message-1", status: "running" }) + .success + ).toBe(false); + }); + + it("requires a stable typed event envelope and pagination cursor", () => { + const change = { + kind: "upsert", + revision: 1, + event: { + id: "event-1", + type: "step_finish", + messageId: "message-1", + createdAt: 1, + data: { type: "step_finish", tokens: { input: 3, output: 2 } }, + }, + }; + expect( + externalEventPageSchema.safeParse({ changes: [change], checkpoint: 1, hasMore: false }) + .success + ).toBe(true); + expect( + externalEventPageSchema.safeParse({ changes: [change], checkpoint: 1, hasMore: true }).success + ).toBe(false); + expect( + externalEventPageSchema.safeParse({ + changes: [{ ...change, internal: true }], + checkpoint: 1, + hasMore: false, + }).success + ).toBe(false); + expect( + externalEventPageSchema.safeParse({ + changes: [{ kind: "delete", revision: 2, eventId: "event-1" }], + checkpoint: 2, + hasMore: false, + }).success + ).toBe(true); + }); + + it("types bounded forward event feed parameters", () => { + expect(externalEventFeedQuerySchema.parse({ after: 12, limit: 200 })).toEqual({ + after: 12, + limit: 200, + }); + expect(externalEventFeedQuerySchema.safeParse({ after: -1 }).success).toBe(false); + expect(externalEventFeedQuerySchema.safeParse({ limit: 501 }).success).toBe(false); + expect(externalEventFeedQuerySchema.safeParse({ after: 1, cursor: "0:1:1" }).success).toBe( + false + ); + }); + + it("types bounded session-list pagination", () => { + expect(externalSessionListQuerySchema.parse({ limit: 100, offset: 50 })).toEqual({ + limit: 100, + offset: 50, + }); + expect(externalSessionListQuerySchema.safeParse({ limit: 101 }).success).toBe(false); + expect(externalSessionListQuerySchema.safeParse({ offset: -1 }).success).toBe(false); + }); +}); diff --git a/packages/shared/src/types/external-session-api.ts b/packages/shared/src/types/external-session-api.ts new file mode 100644 index 0000000000..85bcc947b9 --- /dev/null +++ b/packages/shared/src/types/external-session-api.ts @@ -0,0 +1,264 @@ +import { z } from "zod"; +import { clientRequestIdSchema, promptContentSchema } from "./prompts"; +import { modelProviderSelectionsSchema } from "./provider-accounts"; +import { sessionRepositoriesInputSchema } from "./repositories"; +import { eventTypeSchema } from "./sandbox-events"; +import { sessionAttachmentReferencesSchema } from "./session-attachments"; +import { sessionSkillSelectionSchema } from "./skills"; +import { sessionStatusSchema } from "./sessions"; + +const requiredTextSchema = z.string().trim().min(1); +const idempotencyKeySchema = z.string().min(1).max(128); +export const externalEventCheckpointSchema = z.number().int().nonnegative(); + +export const externalEventFeedQuerySchema = z + .strictObject({ + after: externalEventCheckpointSchema.optional(), + cursor: requiredTextSchema.optional(), + limit: z.number().int().min(1).max(500).optional(), + }) + .refine((query) => query.after === undefined || query.cursor === undefined, { + message: "after and cursor are mutually exclusive", + }); + +export type ExternalEventFeedQuery = z.infer<typeof externalEventFeedQuerySchema>; + +export const externalSessionListQuerySchema = z.strictObject({ + limit: z.number().int().min(1).max(100).optional(), + offset: z.number().int().nonnegative().optional(), + status: sessionStatusSchema.optional(), + excludeStatus: sessionStatusSchema.optional(), + excludeAutomationLineage: z.boolean().optional(), + createdBy: requiredTextSchema.optional(), +}); + +export type ExternalSessionListQuery = z.infer<typeof externalSessionListQuerySchema>; + +const externalCreateSessionRequestBaseSchema = z.strictObject({ + repoOwner: requiredTextSchema.optional(), + repoName: requiredTextSchema.optional(), + branch: requiredTextSchema.optional(), + repositories: sessionRepositoriesInputSchema.optional(), + environmentId: requiredTextSchema.optional(), + title: requiredTextSchema.optional(), + model: requiredTextSchema.optional(), + reasoningEffort: requiredTextSchema.optional(), + skillSelection: sessionSkillSelectionSchema.optional(), + providerSelections: modelProviderSelectionsSchema.optional(), + initialPrompt: promptContentSchema.optional(), + initialAttachments: sessionAttachmentReferencesSchema.optional(), + initialAttachmentCount: z.number().int().min(1).max(6).optional(), + idempotencyKey: idempotencyKeySchema, +}); + +export const externalCreateSessionRequestSchema = externalCreateSessionRequestBaseSchema + .refine((value) => Boolean(value.repoOwner) === Boolean(value.repoName), { + message: "repoOwner and repoName must be provided together", + path: ["repoName"], + }) + .refine((value) => !value.branch || Boolean(value.repoOwner), { + message: "branch requires repoOwner and repoName", + path: ["branch"], + }) + .refine( + (value) => + [ + Boolean(value.repoOwner), + value.repositories !== undefined, + Boolean(value.environmentId), + ].filter(Boolean).length <= 1, + { + message: "repository, repositories, and environmentId are mutually exclusive", + path: ["repositories"], + } + ) + .refine( + (value) => + value.initialPrompt === undefined || + value.initialPrompt.trim().length > 0 || + Boolean(value.initialAttachments?.length) || + Boolean(value.initialAttachmentCount), + { + message: "initialPrompt must not be blank without attachments", + path: ["initialPrompt"], + } + ); + +export type ExternalCreateSessionRequest = z.infer<typeof externalCreateSessionRequestSchema>; + +export const externalFollowUpRequestSchema = z + .strictObject({ + content: promptContentSchema.optional(), + attachments: sessionAttachmentReferencesSchema.optional(), + clientRequestId: clientRequestIdSchema, + model: requiredTextSchema.optional(), + reasoningEffort: requiredTextSchema.optional(), + }) + .refine((value) => Boolean(value.content?.trim()) || Boolean(value.attachments?.length), { + message: "content or attachments are required", + path: ["content"], + }); + +export type ExternalFollowUpRequest = z.infer<typeof externalFollowUpRequestSchema>; + +export const externalSessionSchema = z.strictObject({ + id: z.string(), + title: z.string().nullable(), + model: z.string(), + reasoningEffort: z.string().nullable(), + status: sessionStatusSchema, + repoOwner: z.string().nullable().optional(), + repoName: z.string().nullable().optional(), + repositories: z + .array( + z.strictObject({ + repoOwner: z.string(), + repoName: z.string(), + repoId: z.number().nullable(), + baseBranch: z.string(), + }) + ) + .optional(), + environmentId: z.string().nullable().optional(), + parentSessionId: z.string().nullable().optional(), + creatorId: z.string().nullable().optional(), + archived: z.boolean().optional(), + url: z.string().optional(), + sandboxStatus: z.string().nullable().optional(), + resources: z + .strictObject({ + messages: z.string(), + events: z.string(), + artifacts: z.string(), + diff: z.string(), + pullRequests: z.string(), + children: z.string(), + }) + .optional(), + createdAt: z.number(), + updatedAt: z.number(), +}); + +export type ExternalSession = z.infer<typeof externalSessionSchema>; + +export const externalCreateSessionResponseSchema = z.discriminatedUnion("status", [ + z.strictObject({ + sessionId: requiredTextSchema, + status: z.literal("created"), + url: z.string().optional(), + }), + z.strictObject({ + sessionId: requiredTextSchema, + messageId: requiredTextSchema, + status: z.literal("queued"), + url: z.string().optional(), + }), +]); + +export type ExternalCreateSessionResponse = z.infer<typeof externalCreateSessionResponseSchema>; + +export const externalFollowUpResponseSchema = z.strictObject({ + messageId: requiredTextSchema, + status: z.literal("queued"), +}); + +export const externalSessionListResponseSchema = z + .strictObject({ + sessions: z.array(externalSessionSchema), + hasMore: z.boolean(), + continuationOffset: z.number().int().nonnegative().optional(), + }) + .refine((response) => !response.hasMore || response.continuationOffset !== undefined, { + message: "continuationOffset is required when hasMore is true", + path: ["continuationOffset"], + }); + +export const externalStopSessionResponseSchema = z.strictObject({ + status: z.literal("stopping"), +}); + +export type ExternalJsonValue = + | null + | boolean + | number + | string + | ExternalJsonValue[] + | { [key: string]: ExternalJsonValue }; + +const externalJsonValueSchema: z.ZodType<ExternalJsonValue> = z.lazy(() => + z.union([ + z.null(), + z.boolean(), + z.number(), + z.string(), + z.array(externalJsonValueSchema), + z.record(z.string(), externalJsonValueSchema), + ]) +); + +export const externalEventSchema = z.strictObject({ + id: requiredTextSchema, + type: eventTypeSchema, + messageId: z.string().nullable(), + createdAt: z.number(), + data: z.record(z.string(), externalJsonValueSchema), +}); + +export type ExternalEvent = z.infer<typeof externalEventSchema>; + +export const externalEventChangeSchema = z.discriminatedUnion("kind", [ + z.strictObject({ + kind: z.literal("upsert"), + revision: externalEventCheckpointSchema, + event: externalEventSchema, + }), + z.strictObject({ + kind: z.literal("delete"), + revision: externalEventCheckpointSchema, + eventId: requiredTextSchema, + }), +]); + +export type ExternalEventChange = z.infer<typeof externalEventChangeSchema>; + +/** Event changes are retained for 24 hours or 50,000 revisions, whichever is reached first. */ +export const externalEventPageSchema = z + .strictObject({ + changes: z.array(externalEventChangeSchema), + checkpoint: externalEventCheckpointSchema, + cursor: requiredTextSchema.optional(), + hasMore: z.boolean(), + }) + .refine((page) => !page.hasMore || page.cursor !== undefined, { + message: "cursor is required when hasMore is true", + path: ["cursor"], + }); + +export type ExternalEventPage = z.infer<typeof externalEventPageSchema>; + +export const externalApiErrorResponseSchema = z + .strictObject({ + error: requiredTextSchema, + code: requiredTextSchema.optional(), + message: requiredTextSchema.optional(), + requestId: requiredTextSchema.optional(), + details: z.record(z.string(), externalJsonValueSchema).optional(), + permission: requiredTextSchema.optional(), + }) + .refine((response) => response.message === undefined || response.error === response.message, { + message: "error and message must match", + path: ["message"], + }); + +export const externalSessionWaitResponseSchema = z.strictObject({ + sessionId: requiredTextSchema, + status: sessionStatusSchema, + settled: z.boolean(), + timedOut: z.boolean().optional(), + latestAssistantMessage: z + .strictObject({ id: z.string(), content: z.string(), completedAt: z.number().nullable() }) + .nullable() + .optional(), + artifactIds: z.array(z.string()).optional(), + pullRequestIds: z.array(z.string()).optional(), +}); diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index ba48afe404..e0a8210319 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -169,6 +169,9 @@ export type { } from "./automations"; export type { AutomationTriggerType } from "../triggers/types"; +export * from "./cli-auth"; +export * from "./external-session-api"; + export { MAX_AUDIT_EVENT_TIMESTAMP_MS, auditEventTimestampSchema, diff --git a/packages/web/src/app/api/cli/device-authorizations/approve/route.test.ts b/packages/web/src/app/api/cli/device-authorizations/approve/route.test.ts new file mode 100644 index 0000000000..bcc66eb8fd --- /dev/null +++ b/packages/web/src/app/api/cli/device-authorizations/approve/route.test.ts @@ -0,0 +1,84 @@ +import type { NextRequest } from "next/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/server-auth-session", () => ({ + getServerAuthSession: vi.fn(), +})); + +vi.mock("@/lib/control-plane", () => ({ + controlPlaneUserFetch: vi.fn(), +})); + +import { controlPlaneUserFetch } from "@/lib/control-plane"; +import { getServerAuthSession } from "@/lib/server-auth-session"; +import { POST } from "./route"; + +function request(body: unknown): NextRequest { + return { json: async () => body } as unknown as NextRequest; +} + +describe("CLI device authorization approval BFF", () => { + beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(getServerAuthSession).mockResolvedValue({ + user: { id: "user-1", name: "Ada", email: "ada@example.com", image: null }, + }); + }); + + it("requires a browser session without contacting the control plane", async () => { + vi.mocked(getServerAuthSession).mockResolvedValue(null); + + const response = await POST(request({ userCode: "ABCD-EFGH" })); + + expect(response.status).toBe(401); + expect(controlPlaneUserFetch).not.toHaveBeenCalled(); + }); + + it("normalizes and forwards only the user code through the user-authenticated service request", async () => { + vi.mocked(controlPlaneUserFetch).mockResolvedValue(new Response(null, { status: 204 })); + + const response = await POST( + request({ userCode: " abcd-efgh ", deviceSecret: "must-not-cross-boundary" }) + ); + + expect(response.status).toBe(400); + expect(controlPlaneUserFetch).not.toHaveBeenCalled(); + + const accepted = await POST(request({ userCode: " abcd-efgh " })); + expect(accepted.status).toBe(204); + expect(controlPlaneUserFetch).toHaveBeenCalledWith( + "/external/v1/cli/device-authorizations/approve", + { + method: "POST", + body: JSON.stringify({ userCode: "ABCD-EFGH" }), + } + ); + }); + + it.each([404, 409, 410, 429])("preserves the expected control-plane %i state", async (status) => { + vi.mocked(controlPlaneUserFetch).mockResolvedValue( + Response.json({ error: "sensitive upstream detail" }, { status }) + ); + + const response = await POST(request({ userCode: "ABCD-EFGH" })); + + expect(response.status).toBe(status); + expect(await response.json()).toEqual({ + error: + status === 404 + ? "invalid" + : status === 409 + ? "already_used" + : status === 410 + ? "expired" + : "rate_limited", + }); + }); + + it("rejects malformed codes before forwarding", async () => { + const response = await POST(request({ userCode: "not-a-code" })); + + expect(response.status).toBe(400); + expect(controlPlaneUserFetch).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/web/src/app/api/cli/device-authorizations/approve/route.ts b/packages/web/src/app/api/cli/device-authorizations/approve/route.ts new file mode 100644 index 0000000000..5fef3eb4f8 --- /dev/null +++ b/packages/web/src/app/api/cli/device-authorizations/approve/route.ts @@ -0,0 +1,43 @@ +import { + CLI_EXTERNAL_API_V1_PATH, + approveCliDeviceAuthorizationRequestSchema, +} from "@open-inspect/shared/types/cli-auth"; +import type { NextRequest } from "next/server"; +import { controlPlaneUserFetch } from "@/lib/control-plane"; +import { getServerAuthSession } from "@/lib/server-auth-session"; + +const APPROVAL_PATH = `${CLI_EXTERNAL_API_V1_PATH}/device-authorizations/approve`; + +export async function POST(request: NextRequest): Promise<Response> { + const session = await getServerAuthSession(); + if (!session) return Response.json({ error: "unauthorized" }, { status: 401 }); + + let input; + try { + input = approveCliDeviceAuthorizationRequestSchema.parse(await request.json()); + } catch { + return Response.json({ error: "invalid_request" }, { status: 400 }); + } + + try { + const response = await controlPlaneUserFetch(APPROVAL_PATH, { + method: "POST", + body: JSON.stringify(input), + }); + if (response.status === 204) return new Response(null, { status: 204 }); + + const error = + response.status === 404 + ? "invalid" + : response.status === 409 + ? "already_used" + : response.status === 410 + ? "expired" + : response.status === 429 + ? "rate_limited" + : "approval_failed"; + return Response.json({ error }, { status: response.status }); + } catch { + return Response.json({ error: "approval_unavailable" }, { status: 503 }); + } +} diff --git a/packages/web/src/app/api/cli/device-authorizations/pending/route.test.ts b/packages/web/src/app/api/cli/device-authorizations/pending/route.test.ts new file mode 100644 index 0000000000..f2be082871 --- /dev/null +++ b/packages/web/src/app/api/cli/device-authorizations/pending/route.test.ts @@ -0,0 +1,81 @@ +import type { NextRequest } from "next/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/server-auth-session", () => ({ getServerAuthSession: vi.fn() })); +vi.mock("@/lib/control-plane", () => ({ controlPlaneUserFetch: vi.fn() })); + +import { controlPlaneUserFetch } from "@/lib/control-plane"; +import { getServerAuthSession } from "@/lib/server-auth-session"; +import { GET } from "./route"; + +function request(userCode: string): NextRequest { + return { + nextUrl: new URL( + `https://app.test/api/cli/device-authorizations/pending?user_code=${userCode}` + ), + } as unknown as NextRequest; +} + +describe("CLI pending device authorization BFF", () => { + beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(getServerAuthSession).mockResolvedValue({ + user: { id: "user-1", name: "Ada", email: "ada@example.com", image: null }, + }); + }); + + it("requires a browser session", async () => { + vi.mocked(getServerAuthSession).mockResolvedValue(null); + expect((await GET(request("ABCD-EFGH"))).status).toBe(401); + expect(controlPlaneUserFetch).not.toHaveBeenCalled(); + }); + + it("passes through only validated installation and device metadata", async () => { + vi.mocked(controlPlaneUserFetch).mockResolvedValue( + Response.json({ + installation: { name: "Acme Open-Inspect" }, + deviceName: "Ada's laptop", + expiresAt: 1234, + }) + ); + const response = await GET(request("abcd-efgh")); + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ + installation: { name: "Acme Open-Inspect" }, + deviceName: "Ada's laptop", + expiresAt: 1234, + }); + expect(controlPlaneUserFetch).toHaveBeenCalledWith( + "/external/v1/cli/device-authorizations/pending?user_code=ABCD-EFGH" + ); + }); + + it.each([404, 409, 410, 429])("preserves safe upstream state %i", async (status) => { + vi.mocked(controlPlaneUserFetch).mockResolvedValue(new Response(null, { status })); + const response = await GET(request("ABCD-EFGH")); + expect(response.status).toBe(status); + await expect(response.json()).resolves.toEqual({ + error: + status === 404 + ? "invalid" + : status === 409 + ? "already_used" + : status === 410 + ? "expired" + : "rate_limited", + }); + }); + + it("rejects malformed or unsafe upstream data", async () => { + expect((await GET(request("bad"))).status).toBe(400); + vi.mocked(controlPlaneUserFetch).mockResolvedValue( + Response.json({ + installation: { name: "Acme Open-Inspect" }, + deviceName: "laptop", + expiresAt: 1234, + deviceSecret: "leak", + }) + ); + expect((await GET(request("ABCD-EFGH"))).status).toBe(503); + }); +}); diff --git a/packages/web/src/app/api/cli/device-authorizations/pending/route.ts b/packages/web/src/app/api/cli/device-authorizations/pending/route.ts new file mode 100644 index 0000000000..e319d71083 --- /dev/null +++ b/packages/web/src/app/api/cli/device-authorizations/pending/route.ts @@ -0,0 +1,43 @@ +import { + CLI_EXTERNAL_API_V1_PATH, + approveCliDeviceAuthorizationRequestSchema, + pendingCliDeviceAuthorizationResponseSchema, +} from "@open-inspect/shared/types/cli-auth"; +import type { NextRequest } from "next/server"; +import { controlPlaneUserFetch } from "@/lib/control-plane"; +import { getServerAuthSession } from "@/lib/server-auth-session"; + +const PENDING_PATH = `${CLI_EXTERNAL_API_V1_PATH}/device-authorizations/pending`; + +export async function GET(request: NextRequest): Promise<Response> { + const session = await getServerAuthSession(); + if (!session) return Response.json({ error: "unauthorized" }, { status: 401 }); + + const parsed = approveCliDeviceAuthorizationRequestSchema.safeParse({ + userCode: request.nextUrl.searchParams.get("user_code") ?? "", + }); + if (!parsed.success) return Response.json({ error: "invalid_request" }, { status: 400 }); + + try { + const response = await controlPlaneUserFetch( + `${PENDING_PATH}?user_code=${encodeURIComponent(parsed.data.userCode)}` + ); + if (!response.ok) { + const error = + response.status === 404 + ? "invalid" + : response.status === 409 + ? "already_used" + : response.status === 410 + ? "expired" + : response.status === 429 + ? "rate_limited" + : "lookup_failed"; + return Response.json({ error }, { status: response.status }); + } + const pending = pendingCliDeviceAuthorizationResponseSchema.parse(await response.json()); + return Response.json(pending, { headers: { "Cache-Control": "private, no-store" } }); + } catch { + return Response.json({ error: "lookup_unavailable" }, { status: 503 }); + } +} diff --git a/packages/web/src/app/cli/authorize/page.test.tsx b/packages/web/src/app/cli/authorize/page.test.tsx new file mode 100644 index 0000000000..4f4fdf7faa --- /dev/null +++ b/packages/web/src/app/cli/authorize/page.test.tsx @@ -0,0 +1,66 @@ +// @vitest-environment jsdom +/// <reference types="@testing-library/jest-dom" /> + +import { cleanup, render, screen } from "@testing-library/react"; +import * as matchers from "@testing-library/jest-dom/matchers"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + getServerAuthSession: vi.fn(), + getEnabledSignInProviders: vi.fn(), + signIn: vi.fn(), +})); + +vi.mock("@/lib/server-auth-session", () => ({ getServerAuthSession: mocks.getServerAuthSession })); +vi.mock("@/lib/sign-in-providers", () => ({ + getEnabledSignInProviders: mocks.getEnabledSignInProviders, +})); +vi.mock("@/lib/auth-session", () => ({ signIn: mocks.signIn })); + +import AuthorizePage, { dynamic } from "./page"; + +expect.extend(matchers); + +beforeEach(() => { + vi.resetAllMocks(); + mocks.getServerAuthSession.mockResolvedValue(null); + mocks.getEnabledSignInProviders.mockResolvedValue(["github", "google"]); +}); + +afterEach(cleanup); + +describe("CLI authorize page", () => { + it("normalizes the code and offers configured sign-in providers with the exact callback", async () => { + render(await AuthorizePage({ searchParams: Promise.resolve({ user_code: " abcd-efgh " }) })); + + expect(screen.getByText("ABCD-EFGH")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Sign in with GitHub" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Sign in with Google" })).toBeInTheDocument(); + expect(screen.getByText(/sign in before choosing whether to approve/i)).toBeInTheDocument(); + }); + + it("shows an invalid state without querying auth for a malformed code", async () => { + render(await AuthorizePage({ searchParams: Promise.resolve({ user_code: "bad" }) })); + + expect(screen.getByRole("alert")).toHaveTextContent("This authorization link is invalid."); + expect(mocks.getServerAuthSession).not.toHaveBeenCalled(); + expect(mocks.getEnabledSignInProviders).not.toHaveBeenCalled(); + }); + + it("shows the authenticated identity and explicit approval controls", async () => { + mocks.getServerAuthSession.mockResolvedValue({ + user: { id: "user-1", name: "Ada", email: "ada@example.com", image: null }, + }); + + render(await AuthorizePage({ searchParams: Promise.resolve({ user_code: "ABCD-EFGH" }) })); + + expect(screen.getByText("Ada")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Approve" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Cancel" })).toBeInTheDocument(); + expect(mocks.getEnabledSignInProviders).not.toHaveBeenCalled(); + }); + + it("is request-time rendered", () => { + expect(dynamic).toBe("force-dynamic"); + }); +}); diff --git a/packages/web/src/app/cli/authorize/page.tsx b/packages/web/src/app/cli/authorize/page.tsx new file mode 100644 index 0000000000..1ba4a402c3 --- /dev/null +++ b/packages/web/src/app/cli/authorize/page.tsx @@ -0,0 +1,104 @@ +import Link from "next/link"; +import { approveCliDeviceAuthorizationRequestSchema } from "@open-inspect/shared/types/cli-auth"; +import { CliDeviceAuthorization } from "@/components/cli-device-authorization"; +import { SignInProviderButtons } from "@/components/sign-in-provider-buttons"; +import { ErrorBanner } from "@/components/ui/error-banner"; +import { AuthenticationUnavailableError } from "@/lib/authentication-unavailable-error"; +import { getServerAuthSession } from "@/lib/server-auth-session"; +import { getEnabledSignInProviders } from "@/lib/sign-in-providers"; +import { APP_NAME } from "@/lib/site-config"; + +export const dynamic = "force-dynamic"; + +interface AuthorizePageProps { + searchParams: Promise<{ user_code?: string | string[] }>; +} + +function PageShell({ children }: { children: React.ReactNode }) { + return ( + <main className="min-h-screen bg-background px-6 py-12 flex items-center justify-center"> + {children} + </main> + ); +} + +function Unavailable({ retryHref }: { retryHref: string }) { + return ( + <PageShell> + <div className="w-full max-w-lg space-y-4 text-center"> + <ErrorBanner role="alert">Authorization is temporarily unavailable.</ErrorBanner> + <Link href={retryHref} className="text-accent hover:underline"> + Try again + </Link> + </div> + </PageShell> + ); +} + +export default async function AuthorizePage({ searchParams }: AuthorizePageProps) { + const rawCode = (await searchParams).user_code; + const parsed = approveCliDeviceAuthorizationRequestSchema.safeParse({ + userCode: typeof rawCode === "string" ? rawCode : "", + }); + if (!parsed.success) { + return ( + <PageShell> + <ErrorBanner role="alert" className="w-full max-w-lg text-center"> + This authorization link is invalid. Return to your terminal and start sign-in again. + </ErrorBanner> + </PageShell> + ); + } + + const { userCode } = parsed.data; + const callbackURL = `/cli/authorize?user_code=${encodeURIComponent(userCode)}`; + let session; + try { + session = await getServerAuthSession(); + } catch (error) { + if (error instanceof AuthenticationUnavailableError) + return <Unavailable retryHref={callbackURL} />; + throw error; + } + + if (session) { + return ( + <PageShell> + <CliDeviceAuthorization userCode={userCode} user={session.user} /> + </PageShell> + ); + } + + let providers; + try { + providers = await getEnabledSignInProviders(); + } catch (error) { + if (error instanceof AuthenticationUnavailableError) + return <Unavailable retryHref={callbackURL} />; + throw error; + } + + return ( + <PageShell> + <section className="w-full max-w-lg rounded-xl border border-border bg-card p-8 text-center shadow-sm"> + <p className="text-sm font-medium uppercase tracking-widest text-muted-foreground"> + Device authorization + </p> + <h1 className="mt-3 text-2xl font-semibold text-foreground"> + Sign in to authorize the CLI + </h1> + <p className="mt-3 text-muted-foreground"> + A CLI device requested access to this {APP_NAME} installation. Sign in before choosing + whether to approve it. + </p> + <p + aria-label="Authorization code" + className="my-7 font-mono text-xl font-semibold tracking-wider" + > + {userCode} + </p> + <SignInProviderButtons providers={providers} callbackURL={callbackURL} /> + </section> + </PageShell> + ); +} diff --git a/packages/web/src/components/cli-device-authorization.test.tsx b/packages/web/src/components/cli-device-authorization.test.tsx new file mode 100644 index 0000000000..3a956a1d76 --- /dev/null +++ b/packages/web/src/components/cli-device-authorization.test.tsx @@ -0,0 +1,152 @@ +// @vitest-environment jsdom +/// <reference types="@testing-library/jest-dom" /> + +import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import * as matchers from "@testing-library/jest-dom/matchers"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { CliDeviceAuthorization } from "./cli-device-authorization"; + +expect.extend(matchers); + +beforeEach(() => { + vi.stubGlobal("fetch", vi.fn()); + vi.spyOn(window, "close").mockImplementation(() => undefined); +}); + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +describe("CliDeviceAuthorization", () => { + function pendingResponse( + overrides?: Partial<{ + installation: { name: string }; + deviceName: string; + expiresAt: number; + }> + ) { + return Response.json({ + installation: { name: "Acme Open-Inspect" }, + deviceName: "Ada's laptop", + expiresAt: Date.now() + 5 * 60 * 1000, + ...overrides, + }); + } + + it("shows verified installation, requesting-device metadata, and expiry before approval", async () => { + vi.mocked(fetch).mockResolvedValue(pendingResponse()); + render( + <CliDeviceAuthorization + userCode="ABCD-EFGH" + user={{ name: "Ada Lovelace", email: "ada@example.com" }} + /> + ); + + expect(screen.getByRole("heading", { name: "Authorize Open-Inspect CLI" })).toBeInTheDocument(); + expect(screen.getByText("Ada Lovelace")).toBeInTheDocument(); + expect(screen.getByText("ada@example.com")).toBeInTheDocument(); + expect(screen.getByLabelText("Authorization code")).toHaveTextContent("ABCD-EFGH"); + expect(screen.getByRole("button", { name: "Approve" })).toBeDisabled(); + expect(await screen.findByText("Acme Open-Inspect")).toBeInTheDocument(); + expect(screen.getByText("Installation")).toBeInTheDocument(); + expect(await screen.findByText("Ada's laptop")).toBeInTheDocument(); + expect(screen.getByText("Expires")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Approve" })).toBeEnabled(); + expect(screen.getByRole("button", { name: "Cancel" })).toBeEnabled(); + expect(fetch).toHaveBeenCalledWith( + "/api/cli/device-authorizations/pending?user_code=ABCD-EFGH", + expect.objectContaining({ credentials: "same-origin", mode: "same-origin" }) + ); + }); + + it("posts only the code after approval and focuses the success status", async () => { + vi.mocked(fetch) + .mockResolvedValueOnce(pendingResponse()) + .mockResolvedValueOnce(new Response(null, { status: 204 })); + const user = userEvent.setup(); + render( + <CliDeviceAuthorization + userCode="ABCD-EFGH" + user={{ name: "Ada", email: "ada@example.com" }} + /> + ); + + const approve = screen.getByRole("button", { name: "Approve" }); + await waitFor(() => expect(approve).toBeEnabled()); + await user.click(approve); + + expect(fetch).toHaveBeenLastCalledWith("/api/cli/device-authorizations/approve", { + method: "POST", + mode: "same-origin", + credentials: "same-origin", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ userCode: "ABCD-EFGH" }), + }); + const status = await screen.findByRole("status"); + expect(status).toHaveTextContent("CLI authorized"); + await waitFor(() => expect(status).toHaveFocus()); + }); + + it.each([ + [404, "This authorization code is invalid."], + [409, "This authorization code has already been used."], + [410, "This authorization code has expired."], + ])("shows and focuses the %i error state", async (statusCode, message) => { + vi.mocked(fetch) + .mockResolvedValueOnce(pendingResponse()) + .mockResolvedValueOnce(Response.json({ error: "safe_code" }, { status: statusCode })); + const user = userEvent.setup(); + render( + <CliDeviceAuthorization + userCode="ABCD-EFGH" + user={{ name: "Ada", email: "ada@example.com" }} + /> + ); + + const approve = screen.getByRole("button", { name: "Approve" }); + await waitFor(() => expect(approve).toBeEnabled()); + await user.click(approve); + + const alert = await screen.findByRole("alert"); + expect(alert).toHaveTextContent(message); + await waitFor(() => expect(alert).toHaveFocus()); + }); + + it("cancels without approving and provides close guidance", async () => { + vi.mocked(fetch).mockResolvedValue(pendingResponse()); + const user = userEvent.setup(); + render( + <CliDeviceAuthorization + userCode="ABCD-EFGH" + user={{ name: "Ada", email: "ada@example.com" }} + /> + ); + + await user.click(screen.getByRole("button", { name: "Cancel" })); + + expect(fetch).toHaveBeenCalledOnce(); + expect(window.close).toHaveBeenCalledOnce(); + expect(screen.getByRole("status")).toHaveTextContent( + "Authorization cancelled. You can close this window." + ); + }); + + it.each([ + [404, "This authorization code is invalid."], + [409, "This authorization code has already been used."], + [410, "This authorization code has expired."], + ])("does not allow consent when pending lookup returns %i", async (status, message) => { + vi.mocked(fetch).mockResolvedValue(new Response(null, { status })); + render( + <CliDeviceAuthorization + userCode="ABCD-EFGH" + user={{ name: "Ada", email: "ada@example.com" }} + /> + ); + expect(await screen.findByRole("alert")).toHaveTextContent(message); + expect(screen.getByRole("button", { name: "Approve" })).toBeDisabled(); + }); +}); diff --git a/packages/web/src/components/cli-device-authorization.tsx b/packages/web/src/components/cli-device-authorization.tsx new file mode 100644 index 0000000000..ec703a9245 --- /dev/null +++ b/packages/web/src/components/cli-device-authorization.tsx @@ -0,0 +1,240 @@ +"use client"; + +import { + pendingCliDeviceAuthorizationResponseSchema, + type PendingCliDeviceAuthorizationResponse, +} from "@open-inspect/shared/types/cli-auth"; +import { useEffect, useRef, useState } from "react"; +import { browserApiFetch } from "@/lib/browser-api-fetch"; +import { Button } from "@/components/ui/button"; +import { ErrorBanner } from "@/components/ui/error-banner"; +import { APP_NAME } from "@/lib/site-config"; + +interface CliDeviceAuthorizationProps { + userCode: string; + user: { name?: string | null; email?: string | null }; +} + +type Result = + | { kind: "idle" } + | { kind: "success" | "cancelled" } + | { kind: "error"; message: string }; + +type PendingAuthorization = + | { kind: "loading" } + | ({ kind: "ready" } & PendingCliDeviceAuthorizationResponse) + | { kind: "error"; message: string }; + +const ERROR_MESSAGES: Record<number, string> = { + 404: "This authorization code is invalid.", + 409: "This authorization code has already been used.", + 410: "This authorization code has expired.", +}; + +export function CliDeviceAuthorization({ userCode, user }: CliDeviceAuthorizationProps) { + const [submitting, setSubmitting] = useState(false); + const [result, setResult] = useState<Result>({ kind: "idle" }); + const [pending, setPending] = useState<PendingAuthorization>({ kind: "loading" }); + const resultRef = useRef<HTMLDivElement>(null); + + useEffect(() => { + if (result.kind !== "idle") resultRef.current?.focus(); + }, [result]); + + useEffect(() => { + let active = true; + void browserApiFetch( + `/api/cli/device-authorizations/pending?user_code=${encodeURIComponent(userCode)}` + ) + .then(async (response) => { + if (!active) return; + if (!response.ok) { + setPending({ + kind: "error", + message: + ERROR_MESSAGES[response.status] ?? "CLI authorization details could not be verified.", + }); + return; + } + const authorization = pendingCliDeviceAuthorizationResponseSchema.parse( + await response.json() + ); + setPending( + authorization.expiresAt <= Date.now() + ? { kind: "error", message: ERROR_MESSAGES[410] } + : { kind: "ready", ...authorization } + ); + }) + .catch(() => { + if (active) { + setPending({ + kind: "error", + message: "CLI authorization details are temporarily unavailable.", + }); + } + }); + return () => { + active = false; + }; + }, [userCode]); + + async function approve() { + if (pending.kind !== "ready") return; + setSubmitting(true); + setResult({ kind: "idle" }); + try { + const response = await browserApiFetch("/api/cli/device-authorizations/approve", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ userCode }), + }); + if (response.ok) { + setResult({ kind: "success" }); + return; + } + setResult({ + kind: "error", + message: ERROR_MESSAGES[response.status] ?? "CLI authorization could not be completed.", + }); + } catch { + setResult({ kind: "error", message: "CLI authorization is temporarily unavailable." }); + } finally { + setSubmitting(false); + } + } + + function closeWindow() { + window.close(); + } + + function cancel() { + setResult({ kind: "cancelled" }); + closeWindow(); + } + + if (result.kind === "success" || result.kind === "cancelled") { + const success = result.kind === "success"; + return ( + <section className="w-full max-w-lg rounded-xl border border-border bg-card p-8 shadow-sm"> + <div + ref={resultRef} + role="status" + tabIndex={-1} + className="space-y-3 outline-none focus-visible:ring-2 focus-visible:ring-ring" + > + <h1 className="text-2xl font-semibold text-foreground"> + {success ? "CLI authorized" : "Authorization cancelled"} + </h1> + <p className="text-muted-foreground"> + {success + ? "Return to your terminal to continue. You can close this window." + : "Authorization cancelled. You can close this window."} + </p> + </div> + <Button type="button" variant="outline" className="mt-6 w-full" onClick={closeWindow}> + Close window + </Button> + </section> + ); + } + + return ( + <section className="w-full max-w-lg rounded-xl border border-border bg-card p-8 shadow-sm"> + <div className="space-y-3"> + <p className="text-sm font-medium uppercase tracking-widest text-muted-foreground"> + Device authorization + </p> + <h1 className="text-2xl font-semibold text-foreground">Authorize {APP_NAME} CLI</h1> + <p className="text-muted-foreground"> + A CLI device is requesting access to this {APP_NAME} installation as your account. Only + approve if you started this request. + </p> + <p className="text-sm text-muted-foreground"> + The CLI and connected AI clients inherit your current workspace role. They can use every + operation that role permits on the external interface, including creating, prompting, and + stopping sessions. + </p> + </div> + + <dl className="my-7 space-y-4 rounded-lg bg-muted p-5"> + <div> + <dt className="text-xs font-medium uppercase tracking-wide text-muted-foreground"> + Installation + </dt> + <dd className="mt-1 font-medium text-foreground"> + {pending.kind === "ready" ? pending.installation.name : "Checking request..."} + </dd> + </div> + <div> + <dt className="text-xs font-medium uppercase tracking-wide text-muted-foreground"> + Signed in as + </dt> + <dd className="mt-1 font-medium text-foreground">{user.name || user.email || "User"}</dd> + {user.name && user.email && ( + <dd className="text-sm text-muted-foreground">{user.email}</dd> + )} + </div> + <div> + <dt className="text-xs font-medium uppercase tracking-wide text-muted-foreground"> + Requesting device + </dt> + <dd className="mt-1 font-medium text-foreground"> + {pending.kind === "ready" ? pending.deviceName : "Checking request..."} + </dd> + </div> + {pending.kind === "ready" && ( + <div> + <dt className="text-xs font-medium uppercase tracking-wide text-muted-foreground"> + Expires + </dt> + <dd className="mt-1 text-foreground"> + <time dateTime={new Date(pending.expiresAt).toISOString()}> + {new Date(pending.expiresAt).toLocaleString()} + </time> + </dd> + </div> + )} + <div> + <dt className="text-xs font-medium uppercase tracking-wide text-muted-foreground"> + Authorization code + </dt> + <dd + aria-label="Authorization code" + className="mt-1 font-mono text-xl font-semibold tracking-wider text-foreground" + > + {userCode} + </dd> + </div> + </dl> + + {result.kind === "error" && ( + <ErrorBanner ref={resultRef} role="alert" tabIndex={-1} className="mb-5 outline-none"> + {result.message} + </ErrorBanner> + )} + {pending.kind === "error" && result.kind === "idle" && ( + <ErrorBanner role="alert" className="mb-5"> + {pending.message} + </ErrorBanner> + )} + + {submitting && ( + <p role="status" className="sr-only"> + Approving CLI access + </p> + )} + <div className="grid grid-cols-2 gap-3"> + <Button type="button" variant="outline" disabled={submitting} onClick={cancel}> + Cancel + </Button> + <Button + type="button" + disabled={submitting || pending.kind !== "ready"} + onClick={() => void approve()} + > + {submitting ? "Approving..." : "Approve"} + </Button> + </div> + </section> + ); +} diff --git a/packages/web/src/components/sign-in-provider-buttons.test.tsx b/packages/web/src/components/sign-in-provider-buttons.test.tsx index 872a4df82f..28ba4c8545 100644 --- a/packages/web/src/components/sign-in-provider-buttons.test.tsx +++ b/packages/web/src/components/sign-in-provider-buttons.test.tsx @@ -72,4 +72,19 @@ describe("SignInProviderButtons", () => { await user.click(screen.getByRole("button", { name: "Sign in with GitHub" })); expect(signIn).toHaveBeenCalledTimes(2); }); + + it("passes the approval callback to the selected provider", async () => { + vi.mocked(signIn).mockResolvedValue(); + const user = userEvent.setup(); + render( + <SignInProviderButtons + providers={["github"]} + callbackURL="/cli/authorize?user_code=ABCD-EFGH" + /> + ); + + await user.click(screen.getByRole("button", { name: "Sign in with GitHub" })); + + expect(signIn).toHaveBeenCalledWith("github", "/cli/authorize?user_code=ABCD-EFGH"); + }); }); diff --git a/packages/web/src/components/sign-in-provider-buttons.tsx b/packages/web/src/components/sign-in-provider-buttons.tsx index a361aacb29..7cdefeb9d6 100644 --- a/packages/web/src/components/sign-in-provider-buttons.tsx +++ b/packages/web/src/components/sign-in-provider-buttons.tsx @@ -27,7 +27,13 @@ const PROVIDER_PRESENTATION = { } >; -export function SignInProviderButtons({ providers }: { providers: readonly SignInProvider[] }) { +export function SignInProviderButtons({ + providers, + callbackURL, +}: { + providers: readonly SignInProvider[]; + callbackURL?: string; +}) { const [pending, setPending] = useState(false); const [error, setError] = useState<string | null>(null); @@ -35,7 +41,7 @@ export function SignInProviderButtons({ providers }: { providers: readonly SignI setError(null); setPending(true); try { - await signIn(provider); + await (callbackURL ? signIn(provider, callbackURL) : signIn(provider)); } catch { setError("Could not start sign in. Please try again."); setPending(false); diff --git a/packages/web/src/components/ui/error-banner.tsx b/packages/web/src/components/ui/error-banner.tsx index fda80faf10..7a6d053c84 100644 --- a/packages/web/src/components/ui/error-banner.tsx +++ b/packages/web/src/components/ui/error-banner.tsx @@ -1,3 +1,4 @@ +import * as React from "react"; import { cn } from "@/lib/utils"; interface ErrorBannerProps extends React.HTMLAttributes<HTMLDivElement> { @@ -5,9 +6,10 @@ interface ErrorBannerProps extends React.HTMLAttributes<HTMLDivElement> { className?: string; } -export function ErrorBanner({ children, className, ...props }: ErrorBannerProps) { - return ( +export const ErrorBanner = React.forwardRef<HTMLDivElement, ErrorBannerProps>( + ({ children, className, ...props }, ref) => ( <div + ref={ref} className={cn( "rounded-md border border-destructive-border bg-destructive-muted px-4 py-3 text-sm text-destructive", className @@ -16,5 +18,6 @@ export function ErrorBanner({ children, className, ...props }: ErrorBannerProps) > {children} </div> - ); -} + ) +); +ErrorBanner.displayName = "ErrorBanner"; diff --git a/packages/web/src/lib/auth-session.test.tsx b/packages/web/src/lib/auth-session.test.tsx index b2e86c2937..da585874ad 100644 --- a/packages/web/src/lib/auth-session.test.tsx +++ b/packages/web/src/lib/auth-session.test.tsx @@ -179,6 +179,37 @@ describe("signIn", () => { await expect(signIn("google")).rejects.toThrow("Sign-in failed with status 503"); expect(location.assign).not.toHaveBeenCalled(); }); + + it("preserves a validated same-origin callback path", async () => { + const fetchMock = vi + .fn() + .mockResolvedValue( + Response.json({ url: "https://accounts.google.com/o/oauth2/auth", redirect: true }) + ); + vi.stubGlobal("fetch", fetchMock); + vi.stubGlobal("location", { + origin: "https://app.example", + assign: vi.fn(), + }); + + await signIn("google", "/cli/authorize?user_code=ABCD-EFGH"); + + expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toMatchObject({ + callbackURL: "/cli/authorize?user_code=ABCD-EFGH", + }); + }); + + it.each(["https://evil.example/steal", "//evil.example/steal"])( + "rejects the cross-origin callback %s", + async (callbackURL) => { + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + vi.stubGlobal("location", { origin: "https://app.example", assign: vi.fn() }); + + await expect(signIn("github", callbackURL)).rejects.toThrow("Invalid sign-in callback URL"); + expect(fetchMock).not.toHaveBeenCalled(); + } + ); }); describe("signOut", () => { diff --git a/packages/web/src/lib/auth-session.tsx b/packages/web/src/lib/auth-session.tsx index 8a774dca96..48f275d549 100644 --- a/packages/web/src/lib/auth-session.tsx +++ b/packages/web/src/lib/auth-session.tsx @@ -27,13 +27,22 @@ export type AuthSessionState = status: "loading" | "unauthenticated" | "unavailable"; }; -export async function signIn(provider: SignInProvider): Promise<void> { +function validateSameOriginCallback(callbackURL: string): string { + const callback = new URL(callbackURL, globalThis.location.origin); + if (callback.origin !== globalThis.location.origin) { + throw new Error("Invalid sign-in callback URL"); + } + return `${callback.pathname}${callback.search}${callback.hash}`; +} + +export async function signIn(provider: SignInProvider, callbackURL = "/"): Promise<void> { + const validatedCallbackURL = validateSameOriginCallback(callbackURL); const response = await browserApiFetch("/api/auth/sign-in/social", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ provider, - callbackURL: "/", + callbackURL: validatedCallbackURL, disableRedirect: true, }), }); diff --git a/public/docs/internal/2026-08-29-mcp-external-agent-interfaces-research.md b/public/docs/internal/2026-08-29-mcp-external-agent-interfaces-research.md new file mode 100644 index 0000000000..7e397eb4f9 --- /dev/null +++ b/public/docs/internal/2026-08-29-mcp-external-agent-interfaces-research.md @@ -0,0 +1,934 @@ +# Research: MCP and External Agent Interfaces + +**Date:** 2026-08-29 **Updated:** 2026-08-31 **Status:** Research only **Scope:** Publicly +documented CLI, API, MCP, event, and integration interfaces for Devin, Ona, and Cursor Cloud Agents, +compared with the current Open-Inspect system surface. + +This document is intentionally research-only. It does not include recommendations, implementation +plans, proposed code/API/schema changes, task breakdowns, estimates, or rollout steps. External +product documentation was explicitly included in the requested scope. + +## Summary + +The current Open-Inspect repository exposes MCP configuration, a large internal control-plane API, +and an implemented Increment 1 external surface: revocable user CLI credentials, a versioned +repository-less session API, a first-party `oi` CLI, and a local stdio MCP server. Full V1 +discovery, targets, attachments, output projections, and hosted MCP remain unimplemented. + +The researched products expose different combinations of control surfaces: + +| Product | Public agent API | Agent-management CLI | Product as MCP server | Product as MCP client | Live events | +| ------------------ | ------------------------------------------------------------------ | --------------------------------------------------------------------------- | ----------------------------------------------- | -------------------------------------------------- | -------------------------------------------------------------------------- | +| Devin | REST v3 | Local-agent CLI; cloud handoff | Yes, hosted Streamable HTTP server | Yes | REST polling and MCP event tools; no documented public cloud SSE/WebSocket | +| Ona | Connect/protobuf API | Broad environment and execution CLI | No public Ona management server found | Yes | Connect server stream via `WatchEvents` | +| Cursor | REST v1 public beta; legacy v0 | Local/headless CLI and private workers; no documented hosted-agent CRUD CLI | No public general management server found | Yes | Per-run SSE with resume IDs | +| Open-Inspect today | Versioned Increment 1 session API plus internal HTTP/WebSocket API | Local `oi` core session CLI | Local stdio management server; no hosted server | Yes, configured servers are injected into sessions | Projected checkpoint/change polling; internal session WebSocket | + +Across the three external systems, the common programmatic resource operations are create, list, +get, follow up, observe status, stop or cancel, archive, delete, inspect outputs, and manage related +repository state. Their primary resource models differ: + +- Devin centers a session whose messages, status, attachments, tags, and lifecycle are manipulated + directly. +- Ona centers environments and agent executions. A `sessionId` correlates resources, but no public + standalone session CRUD service was found. +- Cursor v1 separates a durable agent from per-prompt runs. Conversation and workspace state belong + to the agent, while execution status and results belong to runs. + +MCP exposure also differs. Devin publishes a hosted MCP server that lets third-party MCP clients +manage sessions, knowledge, playbooks, schedules, and integration status. Ona and Cursor publicly +document their products as MCP clients/hosts for tools used by agents; no equivalent public, +general-purpose Ona or Cursor management MCP server was found. + +## Research Questions + +1. Which CLI, API, MCP, event, and integration surfaces do Devin, Ona, and Cursor expose? +2. What resource and lifecycle models are visible through those interfaces? +3. What are the documented authentication, request, response, pagination, and streaming shapes? +4. Which system-management functions are exposed through MCP rather than only REST or RPC? +5. What comparable interfaces exist in Open-Inspect today? +6. Which interface details are undocumented, inconsistent, deprecated, or version-sensitive? + +## Comparative Surface + +| Capability | Devin | Ona | Cursor Cloud Agents | +| ------------------------------ | --------------------------------------------------------------------- | ------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------- | +| Create work | `POST /v3/organizations/{org_id}/sessions` | `StartAgent` or `CreateEnvironment` Connect RPC | `POST /v1/agents` creates agent and initial run | +| List/read work | Session list/get/messages/attachments | List/get environments and agent executions | List/get agents and runs | +| Follow up | Post a session message | `SendToAgentExecution` | Create another run on the durable agent | +| Parallelism model | Multiple sessions; MCP gather waits on a set | Multiple executions/environments | Multiple agents; one active run per agent | +| Stop active work | Terminate, sleep, or archive depending on interface | `StopAgentExecution`; environment stop | Cancel a run | +| Reversible retention | Archive session; suspended sessions can resume | Stop/archive environment with persistent disk | Archive/unarchive agent | +| Permanent removal | Terminate/delete semantics | Delete environment | Delete agent | +| Output access | Messages, attachments, PRs, structured output, events through MCP | Transcript/conversation URLs, typed outputs, environment files | Run result, Git state, artifacts, usage | +| Streaming | No public session SSE/WebSocket found | Connect `WatchEvents` stream | Per-run SSE | +| Outbound webhook | No general session-completion webhook found | SCM/Automation webhooks are inbound triggers | Legacy v0 status webhook; v1 says coming soon | +| Public schema | OpenAPI v3 | Generated protobuf/Connect method pages | OpenAPI v1 and SDK bridge protobuf | +| SDKs | REST clients can be generated from OpenAPI | Python, TypeScript, Go | TypeScript and Python plus language-neutral SDK Bridge | +| Local agent CLI | Yes | External agents can use Ona environments; Ona manages environments | Yes | +| Hosted-agent CRUD CLI | No complete cloud CRUD surface; `/handoff` creates cloud work | CLI/API overlap is broad | No documented equivalent to REST agent/run CRUD | +| Product management through MCP | Sessions, events, knowledge, playbooks, schedules, integration status | Not found | Built-in run diagnostics and environment setup/build operations; no public external endpoint found | + +## Devin + +### Interface Families + +Devin exposes: + +- A web application for cloud sessions. +- A local `devin` coding-agent CLI. +- An Agent Client Protocol server over stdio through `devin acp`. +- REST API v3 for organization and enterprise resources. +- A hosted Devin MCP server at `https://mcp.devin.ai/mcp`. +- MCP client support in cloud Devin and the CLI. +- Native source-control, Slack, Teams, Linear, and Jira integrations. +- Event-, schedule-, and webhook-triggered Automations. + +API v3 became generally available in 2026. Legacy v1/v2 APIs and `apk_`/`apk_user_` credentials are +deprecated. Current credentials use the `cog_` prefix. + +### REST Shape + +The organization-scoped base path is: + +```text +https://api.devin.ai/v3/organizations/{org_id}/... +``` + +Authentication is bearer-token based: + +```http +Authorization: Bearer cog_... +``` + +Service-user keys are intended for automation. Personal access tokens act as a human user. Service +users with `ImpersonateOrgSessions` can set `create_as_user_id`. + +The session creation shape includes a prompt and optional repository, knowledge, playbook, secret, +mode, platform, output, and lifecycle controls: + +```http +POST /v3/organizations/{org_id}/sessions +Content-Type: application/json +Authorization: Bearer cog_... +``` + +```json +{ + "prompt": "Create a Python script that analyzes CSV data", + "title": "Analyze CSV data", + "repos": ["owner/repo"], + "attachment_urls": ["https://example.test/input.csv"], + "playbook_id": "playbook-id", + "knowledge_ids": ["knowledge-id"], + "secret_ids": ["secret-id"], + "tags": ["automation"], + "max_acu_limit": 20, + "devin_mode": "normal", + "resumable": true, + "structured_output_schema": {}, + "structured_output_required": true +} +``` + +The current OpenAPI mode enum includes `normal`, `fast`, `lite`, `ultra`, and `fusion`, although +some prose pages mention fewer modes. + +A session response includes durable identity, status, organization, timestamps, consumption, and +pull-request state: + +```json +{ + "session_id": "devin-abc123", + "url": "https://app.devin.ai/sessions/devin-abc123", + "status": "running", + "tags": [], + "org_id": "org-id", + "created_at": 0, + "updated_at": 0, + "acus_consumed": 0, + "pull_requests": [] +} +``` + +Documented session operations include: + +```text +GET /v3/organizations/{org_id}/sessions +POST /v3/organizations/{org_id}/sessions +GET /v3/organizations/{org_id}/sessions/{devin_id} +GET /v3/organizations/{org_id}/sessions/{devin_id}/messages +POST /v3/organizations/{org_id}/sessions/{devin_id}/messages +GET /v3/organizations/{org_id}/sessions/{devin_id}/attachments +GET /v3/organizations/{org_id}/sessions/{devin_id}/tags +POST /v3/organizations/{org_id}/sessions/{devin_id}/tags +PUT /v3/organizations/{org_id}/sessions/{devin_id}/tags +POST /v3/organizations/{org_id}/sessions/{devin_id}/archive +DELETE /v3/organizations/{org_id}/sessions/{devin_id} +``` + +A follow-up message has this general shape: + +```json +{ + "message": "Please also add unit tests", + "attachment_urls": ["https://example.test/spec.png"], + "message_as_user_id": "user-id" +} +``` + +Messages are chronological records with `event_id`, `source`, `message`, and `created_at`. List +operations use cursor pagination with `first`, `after`, `items`, `end_cursor`, and `has_next_page`. +Errors use an RFC 9457-style `application/problem+json` envelope. + +### Session Lifecycle + +Top-level status values are `new`, `claimed`, `running`, `exit`, `error`, `suspended`, and +`resuming`. `status_detail` distinguishes active work, user or approval waits, completion, +inactivity, quota/credit conditions, and errors. + +Sending a message to a suspended session resumes it. Archiving preserves a session but prevents +further modification or resume. Termination is irreversible. A non-resumable session does not +preserve VM state after stopping. A session can report `running` with `status_detail: finished`, so +the two fields describe different layers of state. + +### CLI Shape + +The local CLI entry point is: + +```text +devin [OPTIONS] [prompt] +``` + +Documented flags cover model selection, permission mode, sandboxing, continuation/resume, +non-interactive printing, prompt files, configuration, ATIF export, and workspace trust. Examples of +machine-readable commands include: + +```bash +devin models list --format json +devin list --format json +devin list --format csv +``` + +Air-gapped CLI builds additionally expose `devin doctor --json`. + +Local lifecycle commands create, list, resume, fork, rewind, rename, delete, and export sessions. +`/handoff [task]` creates a cloud Devin session carrying the repository, current branch, +conversation context, tracked changes, untracked changes, and optional task text. The cloud session +then runs independently. + +`devin acp` runs the CLI as an ACP JSON-RPC server over stdio for compatible editor hosts. The +public `CognitionAI/devin-cli` repository points to documentation and does not expose the CLI +implementation or complete protocol schema. + +### MCP Shape + +Devin acts both as an MCP client and as a hosted MCP server. + +Cloud Devin accepts stdio, Streamable HTTP, and legacy SSE MCP connections. The CLI stores user, +project, and local MCP configuration and exposes `add`, `list`, `get`, `remove`, `login`, `logout`, +`enable`, and `disable` commands. CLI MCP tools use names such as `mcp__<server>__<tool>`. + +The hosted server uses Streamable HTTP: + +```text +https://mcp.devin.ai/mcp +``` + +```http +Authorization: Bearer <cog_ credential> +X-Org-Id: <organization id> +``` + +`X-Org-Id` is needed for account-level personal tokens and enterprise service-user keys, but not for +organization-scoped service-user keys. The legacy `/sse` endpoint is deprecated. + +Published hosted tools are grouped as follows: + +| Group | Tools and functionality | +| ------------------------ | ------------------------------------------------------------------------------------------------------------------------ | +| Repository documentation | `read_wiki_structure`, `read_wiki_contents`, `ask_question`, `list_available_repos` | +| Sessions | `devin_session_create`, `devin_session_search`, `devin_session_interact`, `devin_session_events`, `devin_session_gather` | +| Playbooks | `devin_playbook_manage` CRUD | +| Knowledge | `devin_knowledge_manage` CRUD, folders, search, suggestions | +| Schedules | `devin_schedule_manage` CRUD and notification settings | +| Integrations | `list_integrations` for native and MCP installation state | + +`devin_session_interact` covers status, messages, sleep, termination, archive, attachments, and +tags. `devin_session_gather` blocks until multiple sessions settle. Exact MCP JSON Schemas are not +published on the documentation page; authenticated `tools/list` supplies them at runtime. + +### Events and Integrations + +REST clients can poll session and message state. The hosted MCP server exposes detailed event +listing, retrieval, search, and multi-session gathering. No public REST WebSocket, cloud-session SSE +stream, or general outbound completion webhook was identified. + +Local CLI hooks cover `PreToolUse`, `PostToolUse`, `PermissionRequest`, `UserPromptSubmit`, `Stop`, +`PostCompaction`, `SessionStart`, and `SessionEnd`. Hook payloads carry `session_id`; turn-level +events also carry `prompt_id`. Hooks can block, approve, rewrite tool input, or add context. + +Automations can react to Slack, GitHub, Linear, schedules, and incoming HTTPS webhooks. Automation +actions start sessions, message long-running sessions, monitor Slack, or notify external systems. +The v3 API exposes automation CRUD plus schema and template discovery. + +## Ona + +### Interface Families and Naming + +Gitpod became Ona in September 2025, while current API service identifiers and several package or +download names retain Gitpod branding. Public services use the `gitpod.v1` namespace. Current SDK +1.x packages are incompatible with earlier 0.x clients. + +Ona exposes: + +- An `ona` CLI for environments, remote commands, SSH, ports, projects, tasks, services, prebuilds, + webhooks, and organization controls. +- A Connect RPC API over protobuf JSON or protobuf. +- Python, TypeScript, and Go SDKs. +- MCP client/host support for tools available to agents. +- Source-control, Slack, Linear, and Automation integrations. + +No public Ona-hosted MCP server for externally managing Ona resources was found. + +### Connect API Shape + +The base URL is: + +```text +https://app.ona.com/api +``` + +Authentication uses a personal access token or service-account token: + +```http +Authorization: Bearer <token> +``` + +Unary calls are HTTP `POST` requests whose paths encode the protobuf service and method: + +```http +POST /api/gitpod.v1.EnvironmentService/ListEnvironments +``` + +Bodies use protobuf JSON conventions: lower-camel-case fields, symbolic enum names, strings for +64-bit integers, RFC 3339 timestamps, duration strings, base64 bytes, and rejection of unknown +fields. Server streams use Connect envelopes with `application/connect+json` or +`application/connect+proto`. Errors carry Connect codes and messages. + +### Environment Resource + +The environment API includes create, get, list, start, stop, and delete operations. Creation accepts +`spec`, `name`, `sessionId`, and `annotations`: + +```http +POST /api/gitpod.v1.EnvironmentService/CreateEnvironment +``` + +```json +{ + "spec": { + "specVersion": "1", + "machine": { "class": "environment-class-uuid" }, + "timeout": { "disconnected": "7200s" } + }, + "name": "task-environment", + "sessionId": "optional-session-id", + "annotations": {} +} +``` + +The response wraps an environment with `id`, `metadata`, `spec`, and `status`. If `sessionId` is +empty, Ona creates one implicitly. Environment phases include `CREATING`, `STARTING`, `RUNNING`, +`UPDATING`, `STOPPING`, `STOPPED`, `DELETING`, and `DELETED`. + +List operations support token pagination and filters for runner, phase, creator, project, runner +kind, archive state, creation time, role, text, and session ID. Default page size is 25 and maximum +is 100. + +Stopped environments preserve workspace and home-directory disk state. Archive is recoverable until +deletion; deletion is permanent. Dev-container rebuilds recreate most filesystem state while the +repository bind mount and uncommitted changes persist. + +### Agent Execution Resource + +Starting an agent uses: + +```http +POST /api/gitpod.v1.AgentService/StartAgent +``` + +The request can identify an agent, code context, project, existing environment, repository or pull +request, execution name, workflow action, mode, runner, annotations, `sessionId`, model, reasoning, +and first-turn options. The response is: + +```json +{ + "agentExecutionId": "execution-uuid" +} +``` + +An omitted `sessionId` creates a correlated session implicitly. Public API pages expose `sessionId` +on environments and executions, but no standalone `SessionService` or public session CRUD schema was +found. + +Agent execution status contains phase and failure reason, conversation and transcript URLs, support +bundle URLs, conversation-streaming URLs, token and iteration usage, activity, environments, typed +outputs, model/mode, MCP statuses, waiting interests, goals, and subagent state. Phases are +`PENDING`, `RUNNING`, `WAITING_FOR_INPUT`, and `STOPPED`. + +Interaction uses: + +```http +POST /api/gitpod.v1.AgentService/SendToAgentExecution +``` + +The request identifies an execution and supplies one of user input, inter-agent message, wake event, +or control input. User input supports up to ten text/image items; PNG/JPEG images are base64 encoded +and limited to 4 MiB. The response is empty. Another method creates a temporary conversation token +for an execution, but the complete conversation-stream wire protocol is not documented on the method +pages. + +### CLI Shape + +The `ona` CLI supports browser login, token login, multiple host/organization contexts, and +machine-readable JSON or YAML output. Core environment commands include: + +```bash +ona environment create <project-id> +ona environment create <repo-url> --class-id <class-id> +ona environment get <id-or-name> +ona environment list +ona environment start <id-or-name> +ona environment stop <id-or-name> +ona environment archive <id-or-name> +ona environment delete <id-or-name> +ona environment exec <id-or-name> -- <command> +ona environment ssh <id-or-name> +ona environment logs <id-or-name> +``` + +Creation normally waits for readiness; `--dont-wait` returns the ID immediately. `exec` uses an +EnvironmentOps API rather than SSH and propagates the remote process exit code. The CLI can infer +its current environment when run inside Ona. The documentation shows JSON examples but does not +publish a complete stability contract for CLI output schemas. + +### MCP Shape + +Organization administrators can register remote HTTP MCP servers. Authentication uses OAuth with +dynamic client registration or manually configured client metadata. Each user or service account +authenticates separately, so tool calls execute with that principal's permissions. + +Repository-local MCP configuration lives at `.ona/mcp-config.json`: + +```json +{ + "mcpServers": { + "example": { + "command": "npx", + "args": ["-y", "example-mcp-server"], + "env": { "TOKEN": "${exec:printenv TOKEN}" }, + "timeout": 30, + "toolDenyList": ["dangerous_tool"] + } + }, + "globalTimeout": 30 +} +``` + +`command` selects stdio and `url` selects HTTP; they are mutually exclusive. Configuration supports +arguments, headers, environment, working directory, timeout, tool deny lists, disabled state, and +runtime file/command expansion. Local MCP servers execute inside the environment. Configuration is +loaded at the beginning of each agent execution. Organization owners can disable MCP globally. + +### Events and Integrations + +Ona exposes a server-streaming Connect RPC: + +```http +POST /api/gitpod.v1.EventService/WatchEvents +``` + +Streams are scoped to an organization or one environment. Each event reports an operation, resource +type, and resource ID rather than a full resource snapshot: + +```json +{ + "operation": "RESOURCE_OPERATION_UPDATE_STATUS", + "resourceType": "RESOURCE_TYPE_ENVIRONMENT", + "resourceId": "resource-uuid" +} +``` + +Consumers retrieve current state through the corresponding Get RPC. Integrations include GitHub, +GitLab, Bitbucket Cloud, Azure DevOps, Slack, and Linear. Signed SCM webhooks trigger Automations. +The Workflow service can start an Automation with a workflow ID, context override, and up to ten +string parameters. + +## Cursor Cloud Agents + +### Interface Families + +Cursor exposes: + +- Web, desktop, and iOS Cloud Agent interfaces. +- Cloud Agents REST API v1, currently public beta. +- A legacy flat v0 API with webhooks. +- TypeScript and Python SDKs. +- A language-neutral Connect/protobuf SDK Bridge. +- A local/headless `agent` CLI and private cloud worker commands. +- MCP client support in local and cloud agents. +- Source-control, Slack, Teams, Linear, and Automation integrations. + +The v1 resource model separates durable agent state from individual prompt runs. + +### REST v1 Shape + +The base URL is: + +```text +https://api.cursor.com +``` + +Authentication accepts either Basic authentication with an API key as the username and an empty +password, or bearer authentication: + +```http +Authorization: Basic base64(API_KEY:) +Authorization: Bearer API_KEY +``` + +Core endpoints are: + +```text +POST /v1/agents +GET /v1/agents +GET /v1/agents/{id} +DELETE /v1/agents/{id} +POST /v1/agents/{id}/runs +GET /v1/agents/{id}/runs +GET /v1/agents/{id}/runs/{runId} +GET /v1/agents/{id}/runs/{runId}/stream +POST /v1/agents/{id}/runs/{runId}/cancel +GET /v1/agents/{id}/usage +POST /v1/agents/{id}/archive +POST /v1/agents/{id}/unarchive +GET /v1/agents/{id}/artifacts +GET /v1/agents/{id}/artifacts/download +GET /v1/models +GET /v1/repositories +POST /v1/sub-tokens +``` + +Self-hosted worker and pool controls retain `/v0/private-workers` paths. They include worker and +pool list/read operations, pool registration and deregistration, pending-request list and SSE watch, +atomic claim/release operations, and worker-utilization summaries. Pool service accounts can mint +one-hour user-scoped worker tokens through `/v1/sub-tokens`. The pending-request event stream is +explicitly best effort; periodic list results are its source of truth. + +Creating an agent also enqueues its first run: + +```json +{ + "prompt": { "text": "Add setup instructions" }, + "model": { "id": "composer-2.5", "params": [{ "id": "fast", "value": "true" }] }, + "name": "Update documentation", + "repos": [{ "url": "https://github.com/acme/project", "startingRef": "main" }], + "workOnCurrentBranch": false, + "autoCreatePR": true, + "mode": "agent", + "envVars": { "STAGING_TOKEN": "..." }, + "mcpServers": [], + "customSubagents": [] +} +``` + +The response separates the resources: + +```json +{ + "agent": { + "id": "bc-uuid", + "name": "Update documentation", + "status": "ACTIVE", + "env": { "type": "cloud" }, + "url": "https://cursor.com/agents/bc-uuid", + "latestRunId": "run-uuid" + }, + "run": { + "id": "run-uuid", + "agentId": "bc-uuid", + "status": "CREATING" + } +} +``` + +Creation supports up to 20 repositories, image inputs, model discovery, cloud/pool/machine +environments, branch controls, automatic pull requests, encrypted session environment variables, +inline MCP definitions, custom subagents, and agent/plan modes. A caller-supplied `agentId` provides +conflict-based idempotency. Current v1 repository schema text is GitHub-specific even though the +product supports other providers in other interfaces. + +List operations use `items` and an omitted-when-finished `nextCursor`; default page size is 20 and +maximum is 100. Errors use a nested envelope with `code`, `message`, and optional help/provider +data. + +### Agent and Run Lifecycle + +A follow-up creates another run: + +```http +POST /v1/agents/{id}/runs +``` + +```json +{ + "prompt": { "text": "Also add troubleshooting steps" }, + "mode": "agent", + "mcpServers": [] +} +``` + +Conversation and workspace state remain on the agent. Only one run can be active per agent; a +concurrent submission returns `409 agent_busy`. Run states are `CREATING`, `RUNNING`, `FINISHED`, +`ERROR`, `CANCELLED`, and `EXPIRED`. + +Terminal runs expose duration, result text, and aggregate agent Git state. Cancellation is terminal +for the run, while another run can continue the same agent. Archive/unarchive is reversible and +idempotent. Delete is permanent. Documentation prose includes an `IDLE` agent state, while the +published OpenAPI summary enum omits it. + +Artifacts are agent-scoped because the workspace persists across runs. Listing returns relative +paths, byte sizes, and timestamps. Download returns a 15-minute presigned URL. Usage can be read as +total and per-run input, output, cache-write, cache-read, and total token counts. + +### Run SSE Shape + +Each run has a `text/event-stream` endpoint. Documented event types are: + +| Event | Data | +| -------------------- | ----------------------------------------------------------------------- | +| `status` | Run ID and run status | +| `assistant` | Text delta | +| `thinking` | Thinking text delta | +| `tool_call` | Call ID, name, running/completed state, optional args/result/truncation | +| `interaction_update` | Rich SDK-compatible interaction update | +| `heartbeat` | Empty object | +| `result` | Terminal status, result text, duration, Git state | +| `error` | Code and message | +| `done` | Empty object | + +Most events carry an opaque SSE ID. Clients reconnect with `Last-Event-ID`. An ID from another run +returns `invalid_last_event_id`. Stream retention is communicated dynamically through +`X-Cursor-Stream-Retention-Seconds`; after expiration, the endpoint can return `410 stream_expired` +and the run resource remains the durable source of terminal state. + +### CLI and SDK Shape + +The `agent` CLI is primarily a local/headless interface: + +```bash +agent -p "Analyze this repository" +agent -p --force "Modify this repository" +``` + +It supports text, JSON, and NDJSON stream output; resume/continue; model and mode selection; +sandboxing; MCP approval; workspace trust; worktrees; and API-key authentication. Structured +terminal output includes result type, success/error state, durations, text, session ID, and optional +request ID. The CLI command reference does not expose hosted Cloud Agent CRUD matching the REST API. +`agent worker start` and `agent worker debug` manage self-hosted private workers. + +The TypeScript and Python SDKs expose a shared create/send/stream/wait/cancel model across local and +cloud runtimes. SDK streams normalize `system`, `user`, `assistant`, `thinking`, `tool_call`, +`status`, `task`, `request`, and `usage` messages. Tool names and tool-specific arguments/results +are explicitly unstable; their surrounding event envelope is documented as stable. + +The Cursor-owned SDK Bridge publishes a stable `sdk.v1` protobuf contract over Connect HTTP/1.1. It +includes agent, run, artifact, usage, identity, model, repository, control, custom-tool callback, +and custom-store services. + +### MCP Shape + +Cursor documents tools, prompts, resources, roots, elicitation, and MCP Apps over stdio, SSE, and +Streamable HTTP. Configuration is stored in project or user `mcp.json` files. The CLI exposes MCP +login, list, tool-list, enable, and disable commands. + +Cloud Agent create and follow-up requests accept inline MCP definitions. Follow-up definitions +replace create-time inline definitions for that run. Remote HTTP MCP calls are backend-proxied, so +their credentials do not enter the agent VM. Stdio MCP servers run inside the VM and can access +their supplied configuration and environment. OAuth is per user. Enterprise policy can constrain +servers, URLs, commands, tools, and network destinations. + +Cloud Agent runs also receive a built-in Cursor Cloud MCP diagnostics server. Its documented tools +include current-run and environment information, dashboard events, visible Cloud Agent listing, +batched run details and transcripts, automation lookup, environment build listing/logs/triggering, +environment configuration proposals, snapshots, and environment-setup action requests. Access is +checked per request: non-admin users see their own runs, while team administrators can inspect team +runs only where they already have repository and environment access. This server is documented as an +in-run built-in; no public URL for arbitrary external MCP clients is published. + +The Cloud Agent capabilities page says custom Cloud Agent servers support HTTP and stdio but not +SSE, while the v1 OpenAPI accepts `sse`. This is a version-sensitive documentation inconsistency. + +### Webhooks and Integrations + +The current v1 documentation says webhooks are forthcoming. Legacy v0 supports HMAC-SHA256 signed +`statusChange` webhooks for `ERROR` and `FINISHED`, with delivery ID and event headers. Retry timing +and maximum attempts are not documented. + +Integrations launch or follow up on agents through GitHub, GitLab, Azure DevOps, Bitbucket Cloud, +Slack, Teams, and Linear. Automations can react to schedules, source-control events, Slack, private +webhooks, Linear, Sentry, and PagerDuty. An individual agent can also subscribe to source-control, +Slack, Linear, or timer events for up to 180 days. + +## Open-Inspect Current Behavior + +### MCP Configuration + +Open-Inspect currently models local MCP servers as command arrays plus optional environment and +remote servers as URLs plus optional headers. Servers have names, enabled state, and optional +repository scopes. D1 stores server metadata and encrypted credentials. API responses expose +credential presence flags rather than secret values. + +Implemented control-plane routes are: + +```text +GET /mcp-servers +POST /mcp-servers +GET /mcp-servers/:id +PUT /mcp-servers/:id +DELETE /mcp-servers/:id +``` + +These are installation-wide settings rather than user-owned resources. At sandbox spawn, enabled +global servers and repository-matching scoped servers are decrypted and translated into OpenCode MCP +configuration. Local `npx` packages may be preinstalled as a non-fatal optimization. A lookup or +decryption failure degrades to no MCP servers for that spawn rather than failing session creation. + +No hosted Open-Inspect MCP server that exposes Open-Inspect resources to external MCP clients exists +in the inspected code. + +### Session API + +Open-Inspect already has internal HTTP and WebSocket interfaces for session creation, list/read, +prompts, events, artifacts, participants, pull-request creation/refresh metadata, attachments, +media, diffs, stop, title, archive/unarchive, delete, read state, and child sessions. It does not +have a dedicated pull-request read route. + +Session creation accepts no repository, one repository, an ordered repository list, or a saved +environment, plus title, model, reasoning, managed skills, and provider-account selections. The +control plane derives caller identity and source-control credentials rather than trusting them from +the request. + +The WebSocket interface uses a session-specific token and supports subscribe, prompt, stop, typing, +presence, and ping from clients, with state, sandbox, event, artifact, and presence messages from +the server. Child-session routes support create, list, get, cancel, and queued follow-up prompts. + +### External Access Boundary + +Browser calls pass through a Next.js backend-for-frontend that signs requests as `service:web` and +forwards the authenticated browser session. Bot workers use signed service identities. Sandboxes use +session-bound bearer tokens. Explicit external routes accept 30-day revocable CLI bearer credentials +issued through browser-approved device authorization and resolve them directly to canonical human +principals. CLI credentials are not accepted by internal browser, service, or sandbox routes. + +The first-party `oi` CLI and its local MCP server currently cover repository-less text session +create/list/get/prompt/stop/events/wait. Existing sandbox-local commands include `upload-media` and +`oi-git-sign`. + +The current authorization model is one workspace per deployment with a code-owned RBAC permission +registry. Owner, Administrator, Member, Viewer, and custom roles are enforced by the control plane +through explicit route authorization metadata. Suspended or unassigned users fail closed; bot calls +are bounded by both the acting user's permissions and a fixed service ceiling. + +Session permissions are intentionally workspace-wide: creator and participant records are +attribution rather than access boundaries. `sessions.read`, `sessions.collaborate`, +`sessions.lifecycle`, `sessions.sandbox_access`, and `sessions.delete` each apply across all +sessions. Members hold all five grants, while Viewers hold read-only. Existing browser session +WebSockets use a non-renewed five-minute authorization lease, and mutating commands recheck their +specific permission. + +## Existing Interface Patterns + +The researched systems repeatedly expose the following current patterns: + +- A durable work container (`session`, `agent`, or correlated `sessionId`) distinct from, or paired + with, one or more execution turns. +- Separate soft-retention and permanent-removal operations. +- A follow-up operation that preserves conversation and workspace context. +- Cursor-based pagination for potentially large collections. +- Service-account credentials for automation and user credentials for attributed actions. +- Machine-readable status plus a more detailed reason or phase. +- Artifact/output access outside the text conversation. +- Repository context supplied at creation and Git/PR state returned as output. +- Event delivery through polling, server streams, SSE, MCP tools, or product integrations. +- MCP configuration at user, project/repository, organization, or per-execution scope. +- Native chat, ticketing, and source-control integrations as alternative command surfaces. +- Discovery endpoints or generated schemas for models, automation event types, RPC methods, or MCP + tools. + +Only Devin currently documents a hosted MCP management surface spanning the product's core +resources. Ona's broad CLI mirrors much of its environment API. Cursor exposes the clearest public +separation between durable conversation/workspace state and individual executions, along with the +most detailed public run stream. + +## Constraints and Invariants + +- Devin's hosted MCP server requires current `cog_` credentials; legacy keys are unsupported. +- Ona's API is Connect/protobuf rather than conventional REST and rejects unknown protobuf JSON + fields. +- Cursor API v1 is public beta and explicitly permits interface changes before general availability. +- Cursor permits only one active run per durable agent. +- Ona session identity is visible as correlation metadata but not as an independently managed public + resource. +- MCP stdio servers in all three products run within an agent or environment execution boundary; + remote-server credential placement differs by product. +- Public integration behavior can be broader than the provider coverage represented in one API + schema, particularly Cursor's GitHub-specific v1 repository shape. +- Open-Inspect's current HTTP routes are protected by browser/session, signed service, or sandbox + identities rather than a general external API key. +- Open-Inspect human and service routes declare code-owned RBAC authorization policies; session + permissions are workspace-wide rather than creator/participant-scoped. +- Open-Inspect MCP credentials are available inside applicable sandboxes after spawn-time + resolution. +- Open-Inspect MCP changes are not live-reloaded into already running OpenCode processes. + +## Known Gaps and Risks + +### Public Documentation Gaps + +- Devin does not publish the exact input/output schemas of its hosted MCP tools on the overview + page. +- Devin does not document a public cloud session SSE/WebSocket or general outbound completion + webhook. +- Ona does not document a standalone session service or complete conversation stream protocol. +- Ona does not publish a formal stability contract for all CLI JSON output. +- Cursor does not yet expose v1 webhooks and does not document full retry guarantees for v0 + webhooks. +- Cursor run SSE resume semantics are documented, but no exact delivery guarantee is stated. +- All three products have some naming, schema, or transport inconsistencies across documentation + pages. + +### Open-Inspect Gaps Visible in Current State + +- No hosted MCP server exposes Open-Inspect sessions, events, environments, integrations, or + configuration to external MCP clients. +- Increment 1 supports only repository-less text sessions; repository/environment discovery and + targets, attachments, skills/provider selections, children, diffs, artifacts, and pull-request + reads remain outside the implemented external surface. +- Event observation uses sanitized pinned snapshots and a bounded forward change feed with monotonic + checkpoints, coalesced upserts, and delete tombstones. Changes are retained for up to 24 hours and + at most 50,000 revisions per session; expired checkpoints require a fresh snapshot. Hosted live + transport is not implemented. +- MCP configuration is installation-wide and has no separate per-tool permission layer. +- MCP CRUD is gated by `mcp_servers.read` and `mcp_servers.manage`. +- Direct API callers can omit the MCP update revision even though shared types and the web UI treat + it as required. +- Session authorization is intentionally workspace-wide; RBAC does not provide tenant-, project-, + repository-, or creator-isolated session access. + +## Open Questions + +- The full MCP/CLI product contract is documented in `docs/plans/mcp-cli.md`; device credentials, + checkpointed event polling, and the Increment 1 CLI/MCP session loop are implemented, while later + V1 capabilities and live event transport remain proposed. +- The merged RBAC design deliberately grants workspace-wide session access, so the external surface + inherits broad session authority from each role rather than adding per-session grants. + +## Evidence + +### Open-Inspect + +- `packages/shared/src/types/integrations.ts`: Local/remote MCP server types, metadata, credentials, + repository scopes, and revision-bearing request types. +- `packages/control-plane/src/routes/mcp-servers.ts`: MCP CRUD routes and route policy. +- `packages/control-plane/src/db/mcp-servers.ts`: Encryption, persistence, scope resolution, and + optional update revision enforcement. +- `packages/control-plane/src/routes/session-create.ts`: Session creation and source-control + enrichment workflow. +- `packages/shared/src/types/session-api.ts`: Session request and prompt contracts. +- `packages/control-plane/src/routes/session-runtime-proxy.ts`: Runtime, lifecycle, event, artifact, + participant, diff, attachment, media, and pull-request route forwarding. +- `packages/control-plane/src/routes/session-child-spawn.ts`: Child-session creation constraints. +- `packages/control-plane/src/routes/session-children.ts`: Child list, read, cancel, and follow-up + interfaces. +- `packages/control-plane/src/routes/session-ws-token.ts`: Session-specific WebSocket admission. +- `packages/control-plane/src/auth/authenticate.ts`: User, service, and sandbox authentication. +- `packages/shared/src/rbac.ts`: Code-owned permission registry and built-in role grants. +- `packages/control-plane/src/authorization/service.ts`: Effective permission and suspension checks. +- `docs/AUTH.md`: Current workspace-wide session authorization and role behavior. +- `packages/web/src/lib/control-plane.ts`: Browser BFF request signing and cookie forwarding. +- `terraform/d1/migrations/0050_purge_retired_api_tokens.sql`: Removal of historical general API + tokens. +- `packages/sandbox-runtime/src/sandbox_runtime/opencode_server.py`: MCP translation and runtime + permission configuration. +- `packages/control-plane/README.md`: Current documented HTTP and WebSocket interface inventory. + +### Devin Sources + +All external sources were accessed 2026-08-29. + +- [API overview](https://docs.devin.ai/api-reference/overview.md) +- [API authentication](https://docs.devin.ai/api-reference/authentication.md) +- [API migration guide](https://docs.devin.ai/api-reference/getting-started/migration-guide.md) +- [API release notes](https://docs.devin.ai/api-reference/release-notes.md) +- [OpenAPI v3](https://docs.devin.ai/v3-openapi.yaml) +- [Create session](https://docs.devin.ai/api-reference/v3/sessions/post-organizations-sessions.md) +- [List sessions](https://docs.devin.ai/api-reference/v3/sessions/organizations-sessions.md) +- [Get session](https://docs.devin.ai/api-reference/v3/sessions/get-organizations-session.md) +- [List messages](https://docs.devin.ai/api-reference/v3/sessions/get-organizations-session-messages.md) +- [Send message](https://docs.devin.ai/api-reference/v3/sessions/post-organizations-sessions-messages.md) +- [CLI overview](https://docs.devin.ai/cli/index.md) +- [CLI commands](https://docs.devin.ai/cli/reference/commands.md) +- [CLI handoff](https://docs.devin.ai/cli/handoff.md) +- [CLI MCP](https://docs.devin.ai/cli/extensibility/mcp/configuration.md) +- [CLI lifecycle hooks](https://docs.devin.ai/cli/extensibility/hooks/lifecycle-hooks.md) +- [Hosted Devin MCP](https://docs.devin.ai/work-with-devin/devin-mcp.md) +- [MCP client configuration](https://docs.devin.ai/work-with-devin/mcp.md) +- [Automations](https://docs.devin.ai/product-guides/automations.md) +- [Integrations overview](https://docs.devin.ai/integrations/overview.md) +- [Official Devin CLI repository](https://github.com/CognitionAI/devin-cli) + +### Ona Sources + +All external sources were accessed 2026-08-29. + +- [Ona API reference](https://ona.com/docs/api-reference) +- [SDK migration](https://ona.com/docs/api-reference/sdk-migration.md) +- [Create environment](https://ona.com/docs/api-reference/generated/environment/create-environment.md) +- [List environments](https://ona.com/docs/api-reference/generated/environment/list-environments.md) +- [Start agent](https://ona.com/docs/api-reference/generated/agent/start-agent.md) +- [Get agent execution](https://ona.com/docs/api-reference/generated/agent/get-agent-execution.md) +- [Send to agent execution](https://ona.com/docs/api-reference/generated/agent/send-to-agent-execution.md) +- [Watch events](https://ona.com/docs/api-reference/generated/event/watch-events.md) +- [CLI guide](https://ona.com/docs/ona/integrations/cli.md) +- [CLI reference](https://ona.com/docs/ona/reference/cli.md) +- [SDK guide](https://ona.com/docs/ona/integrations/sdk.md) +- [MCP servers](https://ona.com/docs/ona/mcp.md) +- [Integrations overview](https://ona.com/docs/ona/integrations/overview.md) +- [Environment lifecycle](https://ona.com/docs/ona/environments/overview.md) +- [Rename announcement](https://ona.com/stories/gitpod-is-now-ona) +- [TypeScript SDK repository](https://github.com/gitpod-io/gitpod-sdk-typescript) +- [Go SDK repository](https://github.com/gitpod-io/gitpod-sdk-go) + +### Cursor Sources + +All external sources were accessed 2026-08-29. + +- [Cloud Agents overview](https://cursor.com/docs/cloud-agent) +- [Cloud Agent capabilities](https://cursor.com/docs/cloud-agent/capabilities) +- [Cloud Agents API reference](https://cursor.com/docs/cloud-agent/api/endpoints) +- [Cloud Agents OpenAPI](https://cursor.com/docs-static/cloud-agents-openapi.yaml) +- [API overview](https://cursor.com/docs/api) +- [Legacy v0 API](https://cursor.com/docs/cloud-agent/api/v0) +- [Webhooks](https://cursor.com/docs/cloud-agent/api/webhooks) +- [TypeScript SDK](https://cursor.com/docs/sdk/typescript) +- [Python SDK](https://cursor.com/docs/sdk/python) +- [SDK Bridge repository](https://github.com/cursor/sdk-bridge) +- [CLI command reference](https://cursor.com/docs/cli/reference/parameters) +- [CLI headless mode](https://cursor.com/docs/cli/headless) +- [CLI output](https://cursor.com/docs/cli/reference/output-format) +- [MCP documentation](https://cursor.com/docs/mcp) +- [Automations](https://cursor.com/docs/cloud-agent/automations) +- [Slack integration](https://cursor.com/docs/integrations/slack) +- [Linear integration](https://cursor.com/docs/integrations/linear) diff --git a/terraform/d1/migrations/0075_cli_authentication.sql b/terraform/d1/migrations/0075_cli_authentication.sql new file mode 100644 index 0000000000..46d566fb15 --- /dev/null +++ b/terraform/d1/migrations/0075_cli_authentication.sql @@ -0,0 +1,50 @@ +CREATE TABLE cli_device_authorization_attempts ( + id TEXT PRIMARY KEY, + device_name TEXT NOT NULL, + device_secret_hash TEXT NOT NULL UNIQUE, + user_code_hash TEXT NOT NULL UNIQUE, + approved_user_id TEXT REFERENCES users(id) ON DELETE RESTRICT, + exchange_claim_id TEXT UNIQUE, + issued_credential_id TEXT, + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL, + approved_at INTEGER, + exchanged_at INTEGER, + capability_revoked_at INTEGER, + CHECK ((approved_user_id IS NULL) = (approved_at IS NULL)), + CHECK (exchanged_at IS NULL OR (approved_user_id IS NOT NULL AND exchange_claim_id IS NOT NULL)), + CHECK ((exchanged_at IS NULL) = (issued_credential_id IS NULL)) +); + +CREATE INDEX idx_cli_device_authorization_expiry +ON cli_device_authorization_attempts(expires_at); + +CREATE TABLE cli_credentials ( + id TEXT PRIMARY KEY, + token_hash TEXT NOT NULL UNIQUE, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE RESTRICT, + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL, + last_seen_at INTEGER, + revoked_at INTEGER +); + +CREATE INDEX idx_cli_credentials_user +ON cli_credentials(user_id, expires_at); + +CREATE INDEX idx_cli_credentials_expiry +ON cli_credentials(expires_at); + +CREATE INDEX idx_cli_credentials_revoked +ON cli_credentials(revoked_at) WHERE revoked_at IS NOT NULL; + +CREATE TABLE cli_auth_rate_limits ( + rate_key TEXT NOT NULL, + window_started_at INTEGER NOT NULL, + request_count INTEGER NOT NULL, + expires_at INTEGER NOT NULL, + PRIMARY KEY (rate_key, window_started_at) +); + +CREATE INDEX idx_cli_auth_rate_limits_expiry +ON cli_auth_rate_limits(expires_at); diff --git a/terraform/d1/migrations/0076_external_session_request_fingerprint.sql b/terraform/d1/migrations/0076_external_session_request_fingerprint.sql new file mode 100644 index 0000000000..65faab7f4a --- /dev/null +++ b/terraform/d1/migrations/0076_external_session_request_fingerprint.sql @@ -0,0 +1 @@ +ALTER TABLE sessions ADD COLUMN external_request_fingerprint TEXT; diff --git a/terraform/d1/migrations/0077_managed_secret_redaction_history.sql b/terraform/d1/migrations/0077_managed_secret_redaction_history.sql new file mode 100644 index 0000000000..05999d75ec --- /dev/null +++ b/terraform/d1/migrations/0077_managed_secret_redaction_history.sql @@ -0,0 +1,87 @@ +CREATE TABLE managed_secret_redaction_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + encrypted_value TEXT NOT NULL UNIQUE, + created_at INTEGER NOT NULL +); + +CREATE TRIGGER archive_deleted_environment_secret_for_redaction +BEFORE DELETE ON environment_secrets +BEGIN + INSERT OR IGNORE INTO managed_secret_redaction_history (encrypted_value, created_at) + VALUES (OLD.encrypted_value, CAST(strftime('%s', 'now') AS INTEGER) * 1000); +END; + +CREATE TABLE provider_credential_redaction_history ( + provider_account_id TEXT NOT NULL, + provider TEXT NOT NULL, + credential_schema_version INTEGER NOT NULL, + encrypted_payload TEXT NOT NULL, + created_at INTEGER NOT NULL +); + +CREATE TRIGGER archive_provider_credential_for_redaction +BEFORE UPDATE OF encrypted_payload ON model_provider_account_credentials +BEGIN + INSERT INTO provider_credential_redaction_history + (provider_account_id, provider, credential_schema_version, encrypted_payload, created_at) + SELECT OLD.provider_account_id, accounts.provider, OLD.credential_schema_version, + OLD.encrypted_payload, CAST(strftime('%s', 'now') AS INTEGER) * 1000 + FROM model_provider_accounts accounts + WHERE accounts.id = OLD.provider_account_id; +END; + +CREATE TRIGGER archive_deleted_provider_credential_for_redaction +BEFORE DELETE ON model_provider_account_credentials +BEGIN + INSERT INTO provider_credential_redaction_history + (provider_account_id, provider, credential_schema_version, encrypted_payload, created_at) + SELECT OLD.provider_account_id, accounts.provider, OLD.credential_schema_version, + OLD.encrypted_payload, CAST(strftime('%s', 'now') AS INTEGER) * 1000 + FROM model_provider_accounts accounts + WHERE accounts.id = OLD.provider_account_id; +END; + +CREATE TABLE mcp_credential_redaction_history ( + encrypted_env TEXT NOT NULL, + created_at INTEGER NOT NULL +); + +CREATE TRIGGER archive_mcp_credentials_for_redaction +BEFORE UPDATE OF env ON mcp_servers +WHEN OLD.env <> NEW.env AND OLD.env NOT IN ('', '{}', 'null') +BEGIN + INSERT INTO mcp_credential_redaction_history (encrypted_env, created_at) + VALUES (OLD.env, CAST(strftime('%s', 'now') AS INTEGER) * 1000); +END; + +CREATE TRIGGER archive_deleted_mcp_credentials_for_redaction +BEFORE DELETE ON mcp_servers +WHEN OLD.env NOT IN ('', '{}', 'null') +BEGIN + INSERT INTO mcp_credential_redaction_history (encrypted_env, created_at) + VALUES (OLD.env, CAST(strftime('%s', 'now') AS INTEGER) * 1000); +END; + +CREATE TABLE scm_credential_redaction_history ( + access_token_encrypted TEXT NOT NULL, + refresh_token_encrypted TEXT NOT NULL, + created_at INTEGER NOT NULL +); + +CREATE TRIGGER archive_scm_credentials_for_redaction +BEFORE UPDATE OF access_token_encrypted, refresh_token_encrypted ON user_scm_tokens +BEGIN + INSERT INTO scm_credential_redaction_history + (access_token_encrypted, refresh_token_encrypted, created_at) + VALUES (OLD.access_token_encrypted, OLD.refresh_token_encrypted, + CAST(strftime('%s', 'now') AS INTEGER) * 1000); +END; + +CREATE TRIGGER archive_deleted_scm_credentials_for_redaction +BEFORE DELETE ON user_scm_tokens +BEGIN + INSERT INTO scm_credential_redaction_history + (access_token_encrypted, refresh_token_encrypted, created_at) + VALUES (OLD.access_token_encrypted, OLD.refresh_token_encrypted, + CAST(strftime('%s', 'now') AS INTEGER) * 1000); +END; diff --git a/terraform/d1/migrations/0078_external_session_bootstrap_snapshot.sql b/terraform/d1/migrations/0078_external_session_bootstrap_snapshot.sql new file mode 100644 index 0000000000..20297b17dd --- /dev/null +++ b/terraform/d1/migrations/0078_external_session_bootstrap_snapshot.sql @@ -0,0 +1 @@ +ALTER TABLE sessions ADD COLUMN external_bootstrap_snapshot TEXT; diff --git a/terraform/environments/production/service-auth.tf b/terraform/environments/production/service-auth.tf index d9169ce138..c5fe32c0fa 100644 --- a/terraform/environments/production/service-auth.tf +++ b/terraform/environments/production/service-auth.tf @@ -39,6 +39,13 @@ resource "random_bytes" "provider_accounts_encryption_key" { length = 32 } +# Stable installation-scoped key for deterministic external session identity. +# This is intentionally independent from rotatable encryption and auth keys. +resource "random_bytes" "external_session_id_secret" { + length = 32 +} + locals { effective_provider_accounts_encryption_key = trimspace(var.provider_accounts_encryption_key) != "" ? trimspace(var.provider_accounts_encryption_key) : random_bytes.provider_accounts_encryption_key.base64 + effective_external_session_id_secret = trimspace(var.external_session_id_secret) != "" ? trimspace(var.external_session_id_secret) : random_bytes.external_session_id_secret.base64 } diff --git a/terraform/environments/production/terraform.tfvars.example b/terraform/environments/production/terraform.tfvars.example index b35977617e..7b6cd0a6f8 100644 --- a/terraform/environments/production/terraform.tfvars.example +++ b/terraform/environments/production/terraform.tfvars.example @@ -250,6 +250,10 @@ repo_secrets_encryption_key = "" # storing provider account credentials. provider_accounts_encryption_key = "" +# Optional stable key for deterministic external session IDs. Leave blank for +# Terraform to generate and persist one. Never rotate it after creating external sessions. +external_session_id_secret = "" + # Modal API secret (for control plane -> Modal authentication) # Only required when sandbox_provider = "modal" # Generate with: openssl rand -hex 32 diff --git a/terraform/environments/production/variables.tf b/terraform/environments/production/variables.tf index e04071fe5c..eefc10b6b2 100644 --- a/terraform/environments/production/variables.tf +++ b/terraform/environments/production/variables.tf @@ -368,6 +368,22 @@ variable "provider_accounts_encryption_key" { } } +variable "external_session_id_secret" { + description = "Optional existing stable key for deterministic external session IDs; when blank, Terraform generates and persists a dedicated key" + type = string + sensitive = true + nullable = false + default = "" + + validation { + condition = ( + trimspace(var.external_session_id_secret) == "" || + can(regex("^[A-Za-z0-9+/]{43}=$", trimspace(var.external_session_id_secret))) + ) + error_message = "external_session_id_secret must be blank or a Base64-encoded 32-byte key." + } +} + variable "modal_api_secret" { description = "Shared secret for authenticating control plane to Modal API calls (generate with: openssl rand -hex 32)" type = string diff --git a/terraform/environments/production/workers-control-plane.tf b/terraform/environments/production/workers-control-plane.tf index 0bb48918ae..a26d02b712 100644 --- a/terraform/environments/production/workers-control-plane.tf +++ b/terraform/environments/production/workers-control-plane.tf @@ -168,6 +168,7 @@ module "control_plane_worker" { # avoids coupling secret rotation to the browser-auth cutover. { name = "BROWSER_AUTH_SECRET", value = var.nextauth_secret }, { name = "TOKEN_ENCRYPTION_KEY", value = var.token_encryption_key }, + { name = "EXTERNAL_SESSION_ID_SECRET", value = local.effective_external_session_id_secret }, { name = "REPO_SECRETS_ENCRYPTION_KEY", value = var.repo_secrets_encryption_key }, { name = "PROVIDER_ACCOUNTS_ENCRYPTION_KEY", value = local.effective_provider_accounts_encryption_key }, # Pepper for image-build callback token hashes (see service-auth.tf)