diff --git a/.changeset/bright-commands-retry.md b/.changeset/bright-commands-retry.md new file mode 100644 index 0000000..79c1d84 --- /dev/null +++ b/.changeset/bright-commands-retry.md @@ -0,0 +1,5 @@ +--- +"@understudy/connector": patch +--- + +Expose retryable command timeouts as a typed connector error. diff --git a/.changeset/calm-sessions-close.md b/.changeset/calm-sessions-close.md new file mode 100644 index 0000000..8430e1c --- /dev/null +++ b/.changeset/calm-sessions-close.md @@ -0,0 +1,5 @@ +--- +"@understudy/protocol": minor +--- + +Add strict device-control closure acknowledgements for durable, replayable session retirement. diff --git a/README.md b/README.md index e04057e..c8afbe0 100644 --- a/README.md +++ b/README.md @@ -1,64 +1,77 @@ -# understudy - -A governed **browser-execution service** that puppets a user's *already-logged-in* browser -via a Chromium extension. The Cloudflare-hosted service holds the live sessions and exposes -`POST /v1/sessions/:sessionId/commands`; the extension executes each command in the user's -real tab via CDP and reports back. understudy runs **no LLM** — the agent brain and all -governance (approvals, RBAC, audit via breakwater/flowsafe) live in the consumer apps that -drive it over HTTP (Topology 1). - -**Full design + build plan: [`docs/technical-plan.md`](docs/technical-plan.md).** Read it first. - -## Repository layout - -- **`packages/protocol`** — the shared command/event protocol (TypeScript + zod 4, published - `@understudy/protocol`). The stable contract between the service, the extension, and - consumer connectors; the core IP. -- **`packages/connector`** — **M4** the reference `@proofoftech/breakwater` connectors - (`@understudy/connector`): `observe` / `act` / `fill_credential`, approval-gated via - flowsafe grants, egress-pinned to the service host. What consumer apps import to turn - browser actions into governed Mastra tools. See its README. -- **`apps/cdp-spike`** — **M0** throwaway harness: a buildless MV3 extension that verifies the - `chrome.debugger` CDP command surface (the plan's one gating technical risk). See its README. -- **`apps/extension`** — **M2** the real extension: a WXT + React MV3 extension that puppets a - logged-in Chromium tab over a WebSocket. See its README. -- **`apps/backend`** — **M3** the browser-execution service: a Cloudflare Worker (Hono) plus one - Agents-SDK Durable Object per session, terminating the extension's WebSocket and exposing - `POST /v1/sessions/:id/commands` for consumer apps (metamind, smart-compliance) to drive. Runs - no LLM and embeds no agent framework — the brain and governance (breakwater/flowsafe) live in - the consumers. See its README. - -M4 is complete. `@understudy/protocol@0.6.0` and -`@understudy/connector@0.4.0` are published on npm. On 2026-07-25 UTC -(2026-07-26 Asia/Dubai), Metamind completed the production cross-repository -proof against Understudy -`master@797d0e4` and Metamind `master@0814deb`: a connected Chromium extension -executed public-page observation, an approved login using a vaulted credential, -and authenticated-page observation with correlated flowsafe audit evidence. The -labeled proof batch remains in `draft`; no email or Gmail draft was created. The -agent loop and governance stay in the consumer, per Topology 1. - -## Develop - -```sh + + +# Run governed browser commands in user-controlled Chromium + +Understudy is a model-free browser-execution service. A Cloudflare Worker coordinates attended and unattended sessions while an installed Manifest V3 extension executes commands through the Chrome DevTools Protocol (CDP). Consumer applications own model execution, approvals, role-based access control, policies, and durable audit through breakwater and flowsafe. + +Read [`docs/technical-plan.md`](docs/technical-plan.md) for the architecture, safety contract, limits, and rollout gates. + +## Explore the repository + +| Path | Purpose | +|---|---| +| `packages/protocol` | Published Zod 4 command, event, control-frame, and status contracts | +| `packages/connector` | Published breakwater connectors for observe, act, and vaulted credential fill | +| `apps/backend` | Hono Worker, session and device Agents, tenant coordinator, quotas, and telemetry | +| `apps/extension` | WXT and React extension with attended and two-tab unattended hosting | +| `apps/cdp-spike` | Historical Manifest V3 CDP capability harness | + +`@understudy/protocol@0.7.0` and `@understudy/connector@0.5.0` are prepared in this repository. A local build does not publish them. + +## Understand the isolation boundary + +An unattended device is one tenant-dedicated Chrome profile with capacity for two extension-owned tabs. Those tabs have separate command, CDP, ref, and lifecycle state, but share cookies and browser storage. + +Understudy never: + +- Uses a Cloudflare-managed browser +- Automatically attaches an existing tab for unattended work +- Restores old URLs or tasks after restart +- Replays a granted write with an unproven result +- Records video, GIF, Document Object Model history, or session content +- Replaces consumer approval or durable audit + +Protocol 2 provides at-most-once write execution with explicit pending and unknown outcomes. + +## Develop the repository + +Requirements: + +- Node 22 or newer +- pnpm 11.5.2 +- Chrome 125 or newer for production extension verification + +Run: + +```bash pnpm install -pnpm build # first on a fresh clone: @understudy/* resolve via gitignored dist/ +pnpm build pnpm typecheck pnpm test ``` -Requires Node ≥22 and pnpm ≥10.16 (see `package.json`). Dependencies are quarantined for -7 days via `minimumReleaseAge` in `pnpm-workspace.yaml` (supply-chain guard against -freshly-published malicious versions; first-party `@proofoftech/*` packages are exempt). +Dependencies use a 7-day minimum release age through `pnpm-workspace.yaml`. First-party `@proofoftech/*` packages are exempt. + +For the production extension: + +```bash +pnpm --filter @understudy/extension build +``` + +Load `apps/extension/.output/chrome-mv3/` through `chrome://extensions`. Follow the [real-Chromium acceptance runbook](apps/extension/RUNBOOK.md). + +## Release published packages + +Changesets and GitHub Actions manage releases from `master`. A pull request that changes a published package adds a changeset with: + +```bash +pnpm changeset +``` + +The release workflow opens or updates the **Version Packages** pull request. Merging that pull request publishes approved versions with npm provenance. `NPM_TOKEN` needs publish access to the `@understudy` scope. -## Release (npm) +Backend deployment remains a separate Wrangler operation. Keep `UNATTENDED_ENABLED_TENANTS=[]` until the canary extension passes the production acceptance suite. -Changesets and GitHub Actions manage releases from `master`; see -`.changeset/README.md`. A pull request that changes a published package adds a -changeset with `pnpm changeset`. The release workflow opens or updates the -“Version Packages” pull request, then publishes the approved versions with npm -provenance. The repository secret `NPM_TOKEN` needs publish access to the -`@understudy` scope. +## Preserve attended proof history -The M0 harness needs no build — load `apps/cdp-spike` unpacked in a Chromium browser -(`apps/cdp-spike/README.md`). +The completed attended design and production proof remain in Git at Understudy `master@797d0e489df2772d0f5d597141982547861881bb` and Metamind `master@0814deb`. Attended mode remains compatible in the current extension and API. diff --git a/apps/backend/.dev.vars.example b/apps/backend/.dev.vars.example index 44e0f5c..76901f1 100644 --- a/apps/backend/.dev.vars.example +++ b/apps/backend/.dev.vars.example @@ -18,6 +18,13 @@ CALLER_TOKENS={"dev-caller-token":{"actor":"stub-consumer","tenantId":"dev-tenan # settings. EXTENSION_TOKENS={"dev-ext-token":"dev-tenant"} +# JSON map: SHA-256(device credential) -> tenant-bound device identity. +# The example digest is for the literal credential "dev-device-token". +DEVICE_TOKENS={"7053fe692ce151a1a4e066d93850420b420ce95d823a0c7e8609fddf5272438d":{"tenantId":"dev-tenant","deviceId":"00000000-0000-4000-8000-000000000001","credentialVersion":1}} + +# Independent HMAC key for 60-second, single-use WebSocket tickets. +WS_TICKET_SECRET=dev-ticket-secret-change-me + # base64url-encoded 32-byte AES-256-GCM key that envelope-encrypts every # vault value (src/vault.ts). This dev value decodes to the literal # "dev-vault-master-key-0123456789!". Generate a real one with: diff --git a/apps/backend/CHANGELOG.md b/apps/backend/CHANGELOG.md index 48d37a4..d93e82e 100644 --- a/apps/backend/CHANGELOG.md +++ b/apps/backend/CHANGELOG.md @@ -1,5 +1,13 @@ # @understudy/backend +## 0.1.0 + +### Minor Changes + +- Add tenant device coordination, device-control sockets, unattended lease APIs, exact quotas, expiry alarms, and content-free telemetry. +- Add protocol-2 command authority with single-flight admission, durable prepare and grant state, command schedules, status polling, and unknown tombstones. +- Preserve attended creation and attachment compatibility while fixing the legacy late-command race. + ## 0.0.3 ### Patch Changes diff --git a/apps/backend/README.md b/apps/backend/README.md index 7ab6841..3fec4cb 100644 --- a/apps/backend/README.md +++ b/apps/backend/README.md @@ -1,329 +1,180 @@ -# Backend (M3) - -## Overview - -Consumer apps (metamind, smart-compliance) drive a user's real, logged-in browser by -POSTing protocol Commands to this service and getting back the correlated Event. The -service holds no LLM and imports no agent framework (Topology 1) — the agent brain, -tool loop, and governance (breakwater/flowsafe) live in the consumer, not here. This -service's only job is: terminate the M2 extension's WebSocket, hold the live CDP -session per browser session, correlate each Command to its Event, enforce caller -auth + tenant isolation, and resolve `fill_secret` against a vault without ever -exposing plaintext outside this Worker. - -## Architecture - -A Hono HTTP front door and one Agents-SDK Durable Object per session (`SessionAgent`) -share the Worker's `fetch` handler: `routeAgentRequest` claims the -`/agents/session/:sessionId` WebSocket path (the M2 extension's connection target; -`session` is kebab-cased from the `SESSION` binding name in `wrangler.jsonc`, not -from the `SessionAgent` class name); the Hono app claims everything else, including -`/v1/sessions*` and `/health`. - -Command flow: `POST /v1/sessions/:sessionId/commands` → `authenticate` (bearer -caller token → `{actor, tenantId}`, 401 on failure) → `scopeSession` (verifies the -sessionId's embedded tenant matches the caller's, 404 on mismatch) → `safeParseCommand` -(400 on an unparseable body or schema failure) → `stub.dispatch()` or, for `fill_secret`, `stub.fillSecret()` -→ the correlated Event is returned as JSON. This order is load-bearing: an -unauthenticated or cross-tenant request never reaches parsing or dispatch. - -Expected dispatch failures cross the DO RPC boundary as a typed -`DispatchOutcome` (`src/types.ts`), never as a rejected RPC promise — workerd -logs every server-side RPC rejection as an uncaught exception even when the -caller handles it, and a typed reason beats message-prefix parsing at the -route (internally the coordinator still rejects with the `src/coordinator.ts` -prefix constants; `SessionAgent.dispatchFailure` maps them in-isolate). The -route maps reasons to statuses: -**503** `{error: "extension not connected"}` when the session has no live, -authoritative onConnect-authorized extension socket — the gate consults that delivery -predicate directly (not the persisted `status` scalar), fails fast instead of -burning the 30s timeout, and answers non-2xx deliberately: a 200 `ok:false` -Event would be cached by a consumer's idempotency store and replayed after -reconnect; **503** `{error: "session resynced mid-command"}` when a fresh -`hello` abandoned the in-flight command (the extension reconnected — same -retryable family, its own honest reason); **504** `{error: "command timed -out"}` when a connected extension never answered; **409** `{error: "command -already in flight"}` for a concurrent duplicate of a still-pending write -commandId; anything else is a genuine bug and remains a uniform JSON **500** -via `app.onError`. A real `fill_secret` checks the liveness predicate -*before* touching the vault, so no plaintext is ever resolved for a command -that cannot dispatch. Exception: a ref-less dryRun write (e.g. navigate) -still short-circuits to simulated `ok:true` without touching the wire — it -was never a liveness signal. - -Completed **writes** are additionally recorded per commandId -(`SessionState.completedWrites`, capped at 100): a retry under the same -commandId — the connector derives it from the breakwater idempotency key — -replays the recorded Event instead of executing twice, closing the -write-performed-but-response-lost retry gap. Reads never replay. - -Inside the DO, `SessionAgent` holds a `CfSessionCoordinator` (the Cloudflare -implementation of the portable `SessionCoordinator` interface). `send(cmd)` writes -the command to the extension WebSocket, parks a `{resolve, reject, timer}` in an -in-memory `Map` keyed by `commandId`, and persists the commandId into -`SessionState.awaitingCommandIds`. `onMessage` parses every inbound frame as a -protocol Event and routes `*_result`/`pong` to `coordinator.resolvePending`, which -matches by `commandId`, resolves the parked promise, clears the timer, and drops the -id from the awaiting-marker set. - -`SessionCoordinator` (`coordinator.ts`) is a Cloudflare-import-free interface; -`CfSessionCoordinator` (`coordinator-cf.ts`) is the only file that couples it to -Cloudflare, via a constructor-injected `CoordinatorHost` rather than a direct import -of `session.ts` (which would be circular) or the `agents` package. A raw-DO or -Node self-host swap only needs a new implementation of this one interface — the -command API (`index.ts`, `session.ts`) is unaffected. - -## WebSocket security model - -The first gate is at the **Worker edge**: `routeAgentRequest`'s -`onBeforeConnect`/`onBeforeRequest` hooks (`index.ts::gateAgentRequest`) -verify the extension token and tenant scope before the Durable Object ever -accepts the socket (or serves the SDK's HTTP surface). A bad token is a -plain 401 and a cross-tenant sessionId a 404 (no existence oracle, matching -the /v1 discipline) — the socket never enters the DO's connection set at -all. - -The in-DO gate remains as defense in depth for any path that reaches the DO -without that router: `onConnect` runs the same async checks -(`verifyExtensionToken` + `scopeSession`) before marking a connection -`connection.setState({ authorized: true })`, closing with 1008 otherwise. Once -authenticated, the newest socket becomes the session's sole authority through -persisted `SessionState.activeConnectionId`; prior authorized sockets are -demoted and closed with 4001 (`"replaced by newer extension connection"`). -The Agents SDK accepts a socket — and admits it to the connection set -`getConnections()` returns — before that async check resolves, so an -unauthenticated or wrong-tenant socket could sit in the connection set -during that window. Four things close this gap: - -- `sendToExtension` (the coordinator's outbound path) resolves exactly the - authorized socket named by `activeConnectionId`, so a command is never - broadcast, sent to a socket still pending auth, or duplicated during a - reconnect overlap. -- `onMessage` returns immediately unless its sender is that same authoritative, - authorized socket, so neither an unauthenticated nor a replaced socket can - inject events or resolve another socket's command. -- `shouldSendProtocolMessages` returns `false` unconditionally, suppressing the - SDK's own connect-time protocol frames — the extension speaks only the - `@understudy/protocol` wire shape and already discards anything else, so this - costs nothing for a legitimate connection. -- `validateStateChange` rejects any state write whose `source` isn't `"server"` — - the SDK's generic client→server `cf_agent_state` sync path reaches this hook for - any accepted connection, including one still awaiting auth, and this DO's state - is server-driven only. - -Persisted sessions created before `activeConnectionId` existed migrate lazily -only when exactly one authorized socket is live. Multiple legacy candidates are -ambiguous and fail closed until a newly authenticated socket claims authority; -the service never falls back to the former broadcast behavior. Closing a -replaced socket cannot detach its replacement because `onClose` clears status -only when the closing connection owns the persisted authority. - -## Design decisions - -- **Per-session DO, not per-user**: a user can have multiple concurrent - cases/sessions; a per-user DO would conflate them and cross tenant boundaries. - Keying by `sessionId` gives per-tenant/per-case isolation, with the tenant - recoverable from the id itself. -- **404 for cross-tenant sessions, never 403**: a 403 would confirm the session - exists for someone who doesn't own it — an existence oracle. Every - `scopeSession` failure path (bad shape, bad HMAC signature, wrong tenant, decode - error) collapses to the same `"not-found"` the route turns into 404, so no - response shape distinguishes "malformed id" from "someone else's session." -- **sessionIds are minted, not looked up**: `mintSessionId` HMAC-signs a payload - containing the tenant; `scopeSession` verifies the signature and payload rather - than querying a table. This makes tenant-ownership verification stateless. - `POST /v1/sessions` also accepts an optional `Idempotency-Key` UUID. The UUID is - hashed with the authenticated tenant before signing, so retries and concurrent - requests from one consumer converge on the same tenant-scoped session without - exposing the caller's key in the session id. Omitting the header preserves the - fresh-session behavior. -- **`fill_secret` resolves service-side, DO-scoped**: the agent (consumer-side) - only ever sees an opaque `secretRef`. `secrets.ts::resolveSecret` performs vault - lookup only — it imports neither `session.ts` nor the coordinator, so it cannot - itself dispatch anything. The actual resolve-then-type happens entirely inside - `SessionAgent.fillSecret`: the plaintext is fetched, immediately handed to - `coordinator.send({type: "type", ...})` (whose logging is metadata-only — - `{commandId, type}`, never the command body), and never written to `setState`, - never included in the Event response, and never appears in an error string. It - exists only transiently inside this one Durable Object, for the duration of the - one service→extension WS hop. Resolution is **tenant-scoped**: `fillSecret` - derives the session's authoritative tenant from its HMAC-signed `sessionId` - (`this.name`, via `auth.ts::tenantOf` — never a caller claim) and refuses any - `secretRef` outside that tenant's `vault:///…` namespace *before* any - vault read, so a consumer authenticated as tenant B, driving its own session, - can never resolve tenant A's secret. A cross-tenant *or* tenant-less ref returns - the same scrubbed `ok:false` an absent secret does (no existence oracle). Because - understudy owns one shared vault across every tenant, this check lives server-side - here — it is not delegated to a consumer-side breakwater, which can only govern - that consumer's own agent, never isolate one tenant from another. -- **`dryRun` is a service-API parameter (`{command, dryRun?}`), not a `Command` - union field**: adding it to every Command variant would churn the shared, - published protocol for a cross-cutting concern. On a dryRun WRITE command, - `dispatch` never dispatches the *mutating* command — instead it sends a - read-only `resolve_ref` probe (also via `coordinator.send`), which the - extension answers from its live ref map, returning a simulated - `action_result` (`simulated: true`) either way. The probe must NOT be a - `snapshot`: the extension re-mints every ref per snapshot (generation bump), - so a snapshot probe can never contain the consumer's ref — dryRun would - always refuse — and it invalidates every outstanding ref, breaking the - approved command that follows the simulation (the original M3 dry-run bug, - caught by the attended e2e). `fillSecret` does the same ref-only check on - dryRun and never calls `resolveSecret` or dispatches a `type` command; a - `secretRef` outside the session's own tenant short-circuits to a simulated - `ok:false` (the refusal the real call gives) before even the ref probe, so a - governance preview is honest on the tenant axis too and still reads no vault. - This is fail-safe by construction: a governance simulation (called *before* an - approval grant exists) can never actually mutate the page or resolve a - secret. A dry-run `ok` guarantees *resolvability* (the ref maps to a live - node in the current generation) — not *executability* of the eventual - dispatch (e.g. box-model availability), which only the real command proves. -- **The vault binding (`Env.VAULT`) is a KV namespace holding only AES-256-GCM - envelopes, not CF Secrets/Secrets Store**: `fill_secret`'s `secretRef` is - chosen per-call at runtime, and CF's Secrets/Secrets Store bindings are - static — one binding per fixed secret name — which cannot address an - arbitrary runtime-chosen key. KV's `get(key)` can. Because raw KV values are - readable back at rest, every value is envelope-encrypted (`src/vault.ts`, - format `v1..`, fresh IV per value) under `VAULT_MASTER_KEY` — a - Worker secret that never touches KV or wrangler.jsonc — and decrypted only - inside the DO via `createVault(env)`. Seed values with - `scripts/vault-put.mjs` (same envelope, plain Node), never a raw - `wrangler kv key put`; a legacy plaintext value fails closed at read time - ("not a recognized envelope"). A per-tenant external KMS (per-tenant - *encryption* at rest) remains a possible future swap behind the same - `VaultBinding.get` seam (`types.ts`); tenant *authorization* does not wait on - it — it is already enforced above the seam by the `vault:///…` - namespace check in the `fill_secret` bullet above. -- **Hibernation cannot lose an in-flight command; only shutdown/restart can**: - verified against the Cloudflare Durable Objects docs (2026-07-14) — hibernation - requires no pending timer, no in-progress awaited fetch, no active WS use, and no - request still being processed, all simultaneously, plus ~10s of subsequent - idle. A `send()` awaiting its Event violates two of those (a pending timer, a - request being processed) by itself, so the DO cannot hibernate mid-command; that - half of the awaiting-marker's job is a platform guarantee, not an assumption. - What *can* interrupt a command is shutdown/restart (deploys, runtime updates, - host rebalancing — non-deterministic, ~1-2x/day per the Agents SDK docs), which - kills the WS outright regardless of hibernation preconditions. The per-command - timeout is the caller-side bound for that case. The persisted - `awaitingCommandIds` marker's real job is reconciling an orphaned/late - `*_result` that arrives for an already-settled (resolved or timed-out) - commandId after the DO goes idle and wakes again — it's dropped, not - mis-resolved against unrelated bookkeeping. -- **A fresh `hello` abandons in-flight commands rather than waiting for them**: - a `hello` means the extension side just resynced (reconnect, SW restart), so - whatever it had in flight is known-gone; `abandonInFlight` rejects every pending - command and clears the marker set immediately instead of waiting out their - timeouts. -- **understudy builds no audit sink**: it may emit a structured non-secret log - `{ref, secretRef, ok}` for `fill_secret`, but the durable audit trail is - flowsafe's, consumer-side. This keeps the service framework-light and avoids a - second, redundant audit system. - -## Invariants - -- Every *successfully dispatched* `Command` produces exactly one `Event` - bearing its `commandId`, and the command route returns exactly that Event; - a command that cannot dispatch or never resolves maps to a non-2xx JSON - error instead (503/504/409/500 — see the failure mapping above). -- A **write** commandId executes at most once within a session's last 100 - writes: a repeat of a completed write replays its recorded Event - (`completedWrites`, cap 100), a repeat of a still-pending write is refused - 409, and the extension keeps a matching 100-entry replay + in-flight record - for the case where the service times out while the extension is still - executing (a duplicate is dropped, not re-run). A retry delayed beyond 100 - intervening writes degrades to re-execution. Reads never replay. -- `fill_secret` plaintext never enters `setState`, logs, the Event response, or an - error string; the coordinator logs only `{commandId, type}`. A replayed - `fill_secret` touches neither the vault nor the wire. -- A `secretRef` resolves only within its session's own tenant: `fillSecret` - requires `vault:///…` (tenant derived from the signed sessionId) and - refuses a cross-tenant or tenant-less ref with the same scrubbed `ok:false` an - absent secret gets, before any vault read — no cross-tenant plaintext, no - existence oracle. -- The vault at rest holds only `v1..` AES-256-GCM envelopes; a value - that does not decrypt under `VAULT_MASTER_KEY` is refused, never served. -- One Durable Object per `sessionId`; a sessionId whose embedded tenant disagrees - with the authenticated caller is refused with 404, never 403 — on the /v1 - API and on the agent WS/HTTP path alike. -- Exactly one authenticated extension socket is authoritative per session. - Commands and Events use only its persisted `activeConnectionId`; a newer - authenticated socket atomically replaces and closes prior sockets, and a - late predecessor close cannot detach the replacement. -- A mid-command DO hibernation cannot happen (see above); an interrupting - shutdown/restart is bounded by the per-command timeout, and the persisted - awaiting-marker reconciles any orphaned late result rather than mis-resolving it. -- The service runs no LLM and imports no agent framework. -- `SessionCoordinator` is the only Cloudflare-coupling seam; `coordinator.ts` - itself imports nothing Cloudflare-specific. -- `dryRun` never dispatches a mutating command and never resolves a secret; it - returns a simulated `action_result` from a read-only ref check - or, for a - `secretRef` outside the session's tenant, a simulated `ok:false` refusal that - matches what the real call would return, with no vault read. -- An unauthorized WS upgrade is refused at the Worker edge (401/404) before - the DO accepts it; any socket that still reaches the DO unauthorized can - neither receive a command, have its inbound messages processed, nor change - the session's status by closing (see WebSocket security model). -- Expected delivery failures never cross the DO RPC boundary as rejections - (no workerd "Uncaught (in promise)" noise); only genuine bugs throw. - -## Deploy - -First deployed 2026-07-17 to `https://understudy-backend.gcharang.workers.dev` -(account `056cbaa6f5c3d8ff5584f1aa84bbe050`). The account id is deliberately -NOT pinned in `wrangler.jsonc` (public repo, two local accounts): pass it per -command. Runbook, from `apps/backend`: - -```sh -export CLOUDFLARE_ACCOUNT_ID=056cbaa6f5c3d8ff5584f1aa84bbe050 - -# One-time: the ciphertext store (id goes into wrangler.jsonc kv_namespaces) -pnpm exec wrangler kv namespace create VAULT - -# Secrets - all four are required; `wrangler deploy` refuses to ship without -# them (wrangler.jsonc `secrets.required`). Mint strong values: -openssl rand -hex 32 | pnpm exec wrangler secret put AUTH_HMAC_SECRET -printf '%s' '{"":{"actor":"","tenantId":""}}' | pnpm exec wrangler secret put CALLER_TOKENS -printf '%s' '{"":""}' | pnpm exec wrangler secret put EXTENSION_TOKENS -openssl rand 32 | basenc --base64url | tr -d '=' | pnpm exec wrangler secret put VAULT_MASTER_KEY - -pnpm exec wrangler deploy -curl -s https://understudy-backend.gcharang.workers.dev/health # {"ok":true} - -# Seed a vault secret (encrypts locally; KV never sees plaintext). The key -# MUST be vault:/// where matches the tenant in -# CALLER_TOKENS/EXTENSION_TOKENS: fillSecret refuses any ref outside the -# requesting session's own tenant namespace, so a tenant-less key (vault://ref) -# is unreadable by design. -printf '%s' 'the-secret' | VAULT_MASTER_KEY= node scripts/vault-put.mjs 'vault:///ref' + + +# Operate the Understudy backend + +The backend is a Cloudflare Worker with Hono, Agents SDK Durable Objects, a raw SQLite coordinator, KV vault storage, Analytics Engine telemetry, and a rate-limit backstop. It coordinates attended and unattended sessions but never runs the browser. + +## Understand the object topology + +The Worker binds three Durable Object classes: + +| Binding | Class | Authority | +|---|---|---| +| `SESSION` | `SessionAgent` | Session WebSocket, command journal, schedules, results, dialogs, and vault resolution | +| `DEVICE` | `DeviceAgent` | One authoritative control WebSocket per enrolled profile | +| `TENANT_CONTROL` | `TenantDeviceCoordinator` | Tenant devices, allocations, leases, exact quotas, idempotency, and alarms | + +Migration `v1` created `SessionAgent`. Additive migration `v2` creates `DeviceAgent` and `TenantDeviceCoordinator`. Do not remove either migration during rollback. + +## Use the HTTP API + +All `/v1` caller endpoints require `Authorization: Bearer `. The service returns `404` for malformed, unknown, or cross-tenant session IDs. + +| Endpoint | Result | +|---|---| +| `POST /v1/sessions` with no body | Create an attended session | +| `POST /v1/sessions` with an unattended body | Allocate and provision a device lease | +| `GET /v1/devices` | Read device status and capacity | +| `GET /v1/sessions/:id` | Read active or terminal session status | +| `DELETE /v1/sessions/:id` | Retire attended authority or request unattended cleanup | +| `POST /v1/sessions/:id/commands` | Admit a strict command request | +| `GET /v1/sessions/:id/commands/:commandId` | Poll a protocol-2 command | +| `POST /v1/device/connect-ticket` | Mint a device control ticket | + +Unattended creation requires a UUID `Idempotency-Key` and this body: + +```json +{ + "mode": "unattended", + "allowedOrigins": ["https://portal.example"], + "profileStateKey": "portal_account_a" +} ``` -The extension connects to -`wss://understudy-backend.gcharang.workers.dev/agents/session/?token=`. - -### Secrets - -All four are **required** — `wrangler deploy` refuses to ship without them -(`wrangler.jsonc` `secrets.required`, which is also the `.dev.vars` allowlist -for local dev). Cloudflare stores them encrypted and **never shows a value -again** after `wrangler secret put`, so the deployed worker is the canonical -copy and the only readable backup is the operator-local, gitignored -`apps/backend/.secrets.production.env` (created out of band, never committed — -`.secrets*` in the root `.gitignore`). Lose that file and a secret can only be -*rotated*, not recovered. - -| secret | what it is | regenerate | rotation impact | -|---|---|---|---| -| `AUTH_HMAC_SECRET` | HMAC-SHA256 key signing minted sessionIds (stateless tenant scoping) | `openssl rand -hex 32` | invalidates every outstanding sessionId — consumers must re-mint | -| `CALLER_TOKENS` | JSON map `bearer token → {actor, tenantId}`; a consumer sends the raw token as `Authorization: Bearer …` | token: `printf 'uk_caller_%s\n' "$(openssl rand -hex 24)"` | the affected consumer swaps its `UNDERSTUDY_TOKEN` | -| `EXTENSION_TOKENS` | JSON map `WS token → tenantId`; the extension sends the raw token as `?token=…` | token: `printf 'uk_ext_%s\n' "$(openssl rand -hex 24)"` | that user pastes the new WS URL into the extension panel | -| `VAULT_MASTER_KEY` | base64url 32-byte AES-256-GCM key envelope-encrypting every vault value | `openssl rand 32 \| basenc --base64url \| tr -d '='` | **every stored vault value must be re-sealed** (`vault-put.mjs`) — old envelopes become undecryptable | - -**When to rotate:** on suspected exposure of that specific secret, when -offboarding a tenant/user (edit the relevant JSON map and re-put), or on a -periodic schedule for the two keys. `CALLER_TOKENS`/`EXTENSION_TOKENS` are -add/remove-an-entry edits — rotating one caller/extension does not disturb the -others. - -**Re-push after editing the backup file** (from `apps/backend`) — one at a -time with `wrangler secret put `, or all four via the bulk endpoint (the -`.secrets.production.env` header carries a ready-made env→JSON one-liner that -pipes into `wrangler secret bulk`). +The API canonicalizes origins and hashes the profile key with tenant domain separation. It persists neither raw value in coordinator state. + +Command requests are limited to 128 KiB and parsed against `CommandRequestSchema`. Unknown fields and malformed `dryRun` values return `400` before WebSocket traffic or durable command mutation. + +Protocol-2 connectors send `Understudy-Command-Contract: 2`. They can receive `202` and poll the returned status URL. Legacy connectors never receive `202`. + +Attended deletion persists terminal authority immediately, cancels active attempts, closes the extension socket with code `4003`, and returns `204`. Repeated attended deletion also returns `204`. Later commands return `410`, and reconnecting extensions receive code `4003`. + +Unattended deletion remains acknowledgement-driven. It returns `202` while the extension still owns the tab or the matching closure frame is pending, then returns `204` after cleanup confirmation. + +`GET /v1/sessions/:id` keeps unattended `closing` sessions pollable with `200`; only unattended `closed`, `expired`, and `lost` sessions return `410`. Attended sessions with a durable closed flag also return `410`, even though their response body retains `status: "detached"`. + +## Configure secrets + +Wrangler requires six secrets: + +| Secret | Format | Purpose | +|---|---|---| +| `AUTH_HMAC_SECRET` | Random HMAC key | Session IDs, profile hashes, request fingerprints, and telemetry pseudonyms | +| `CALLER_TOKENS` | JSON object | Caller token to actor and tenant | +| `EXTENSION_TOKENS` | JSON object | Legacy attended extension token to tenant | +| `DEVICE_TOKENS` | JSON object | SHA-256 device credential digest to tenant-bound device identity | +| `WS_TICKET_SECRET` | Independent random HMAC key | 60-second single-use WebSocket tickets | +| `VAULT_MASTER_KEY` | Base64url 32-byte key | AES-256-GCM vault envelope encryption | + +`CALLER_TOKENS` uses: + +```json +{ + "caller_token_here": { + "actor": "consumer_worker", + "tenantId": "tenant_a" + } +} +``` + +`DEVICE_TOKENS` uses: + +```json +{ + "sha256_device_credential_here": { + "tenantId": "tenant_a", + "deviceId": "00000000-0000-4000-8000-000000000001", + "credentialVersion": 1 + } +} +``` + +The raw device credential appears only in HTTPS authorization headers and the trusted extension’s local storage. Rotate a device by adding a higher `credentialVersion` entry and removing the old digest. A heartbeat detects revocation and fences the old socket. + +Copy `.dev.vars.example` to `.dev.vars` for local development. Never commit `.dev.vars`. + +## Configure rollout and quotas + +Non-secret Wrangler variables include: + +| Variable | Default | Purpose | +|---|---|---| +| `UNATTENDED_ENABLED_TENANTS` | `[]` | Tenant allowlist for new unattended leases | +| `SAFE_WRITE_REQUIRED_TENANTS` | `[]` | Tenant allowlist that rejects protocol-1 writes | +| `QUOTA_POLICY` | Built-in JSON | Exact SQLite quota configuration | + +Use `["*"]` only after the protocol-2 extension rollout completes. Keep unattended creation disabled during the initial backend deployment. + +The default exact quotas are: + +- 10 session creates/min per actor +- 120 commands/min per session +- 600 commands/min per tenant +- 30 credential fills/min per actor +- 30 device tickets/min per device +- 10,000 admitted commands per session + +The `RATE_LIMITER` binding allows 300 requests/min per authenticated caller or device identity pseudonym. It is an abuse backstop, not the authoritative quota mechanism. + +## Protect WebSocket authority + +Long-lived device credentials never enter WebSocket URLs. A device authenticates over HTTPS, receives a signed ticket, and uses it once on the control socket. + +The Worker verifies ticket signature, audience, expiry, and path-bound object name before object routing. The target object consumes the JTI hash atomically and validates current tenant, device, lease, and epoch authority. + +The extension persists a `closed` record and retries it until the Worker returns an exact `closed_ack`. The coordinator acknowledges the first durable closure, exact closed or expired replays, and exact lost-fence replays while preserving `lost`. It rejects missing leases and stale or mismatched fences. `DeviceAgent` updates the session lifecycle before sending the acknowledgement, and it emits release telemetry only for the first transition. + +Deploy this backend behavior before the acknowledging extension. Older extensions ignore `closed_ack`. Newer extensions fail closed against an older backend by retaining their closure records and staged profiles. + +Attended protocol-1 sockets retain their legacy `EXTENSION_TOKENS` query flow for compatibility. Unattended sockets require tickets. + +## Store vault values + +KV stores only `v1..` envelopes. Seed a tenant-scoped key through the encryption script: + +```bash +printf '%s' 'secret_value_here' | + VAULT_MASTER_KEY=base64url_key_here \ + node apps/backend/scripts/vault-put.mjs \ + 'vault://tenant_a/portal/password' +``` + +`fill_secret` rejects a ref outside the session tenant before a KV read. Plaintext exists only after write readiness and only in the in-memory grant frame. + +## Emit telemetry + +`src/telemetry.ts` writes content-free dimensions to Analytics Engine and structured logs. HMAC pseudonyms replace tenant, actor, device, and session identifiers. + +Never add URL, title, page content, dialog content, text, keys, refs, secret references, credentials, tickets, or full WebSocket URLs to telemetry. + +## Develop and verify + +Run from the repository root: + +```bash +pnpm --filter @understudy/backend typecheck +pnpm --filter @understudy/backend test +pnpm --filter @understudy/backend exec wrangler deploy --dry-run \ + --outdir /tmp/understudy-unattended-worker +``` + +The Miniflare test suite needs permission to bind a loopback port. + +## Deploy safely + +Deploy the dual-protocol backend with unattended creation disabled: + +```bash +pnpm --filter @understudy/backend exec wrangler deploy +``` + +After one canary extension reports protocol 2, enable only its tenant. Complete the production Chromium acceptance suite and 24-hour soak before broad enablement. + +A rollback must: + +1. Disable new unattended leases +2. Drain or terminalize active leases and granted commands +3. Roll back application code +4. Retain the additive Durable Object migrations + +Do not deploy protocol-1-only code while protocol-2 leases exist. diff --git a/apps/backend/package.json b/apps/backend/package.json index 46e9d4c..92b2f1d 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -1,6 +1,6 @@ { "name": "@understudy/backend", - "version": "0.0.3", + "version": "0.1.0", "private": true, "type": "module", "scripts": { diff --git a/apps/backend/src/auth.ts b/apps/backend/src/auth.ts index 848b557..609d9a9 100644 --- a/apps/backend/src/auth.ts +++ b/apps/backend/src/auth.ts @@ -19,6 +19,27 @@ export interface Actor { tenantId: string; } +export interface DeviceIdentity { + tenantId: string; + deviceId: string; + credentialVersion: number; + credentialDigest: string; +} + +export interface WsTicketClaims { + jti: string; + aud: "device-control" | "session"; + tenantId: string; + deviceId: string; + credentialVersion?: number; + sessionId?: string; + leaseId?: string; + leaseEpoch: number; + browserEpoch: string; + agentName: string; + exp: number; +} + export interface TokenVerifier { verify(token: string): Promise; } @@ -182,6 +203,244 @@ export async function verifyExtensionToken( return tenantId ? { tenantId } : null; } +export async function authenticateDevice( + req: Request, + env: Env, +): Promise { + const header = req.headers.get("Authorization"); + if (!header?.startsWith(BEARER_PREFIX)) return null; + const credential = header.slice(BEARER_PREFIX.length).trim(); + if (!credential || !env.DEVICE_TOKENS) return null; + const credentialDigest = await sha256Hex(credential); + + let entries: Record< + string, + { tenantId?: unknown; deviceId?: unknown; credentialVersion?: unknown } + >; + try { + entries = JSON.parse(env.DEVICE_TOKENS) as typeof entries; + } catch { + return null; + } + const entry = entries[credentialDigest]; + if ( + entry === undefined || + typeof entry.tenantId !== "string" || + !isValidTenantId(entry.tenantId) || + typeof entry.deviceId !== "string" || + !isUuid(entry.deviceId) || + typeof entry.credentialVersion !== "number" || + !Number.isInteger(entry.credentialVersion) || + entry.credentialVersion < 1 + ) { + return null; + } + return { + tenantId: entry.tenantId, + deviceId: entry.deviceId.toLowerCase(), + credentialVersion: entry.credentialVersion, + credentialDigest, + }; +} + +export async function deviceCredentialExists( + digest: string, + identity: Pick, + env: Env, +): Promise { + if (!env.DEVICE_TOKENS) return false; + try { + const entries = JSON.parse(env.DEVICE_TOKENS) as Record< + string, + { tenantId?: unknown; deviceId?: unknown; credentialVersion?: unknown } + >; + const entry = entries[digest]; + return ( + entry?.tenantId === identity.tenantId && + typeof entry.deviceId === "string" && + entry.deviceId.toLowerCase() === identity.deviceId.toLowerCase() && + entry.credentialVersion === identity.credentialVersion + ); + } catch { + return false; + } +} + +export async function mintWsTicket( + claims: Omit, + env: Env, + now = Date.now(), +): Promise { + const complete: WsTicketClaims = { + ...claims, + jti: crypto.randomUUID(), + exp: Math.floor(now / 1000) + 60, + }; + const payload = new TextEncoder().encode(JSON.stringify(complete)); + const signature = await crypto.subtle.sign( + "HMAC", + await importNamedHmacKey(env.WS_TICKET_SECRET), + payload, + ); + return `${base64urlEncode(payload)}.${base64urlEncode(new Uint8Array(signature))}`; +} + +export async function verifyWsTicket( + ticket: string, + expected: { aud: WsTicketClaims["aud"]; agentName: string }, + env: Env, + now = Date.now(), +): Promise { + try { + if (!ticket || ticket.length > 4 * 1024) return null; + const parts = ticket.split("."); + if (parts.length !== 2) return null; + const [payloadPart, signaturePart] = parts; + if (!payloadPart || !signaturePart) return null; + const payload = base64urlDecode(payloadPart); + const signature = base64urlDecode(signaturePart); + if ( + base64urlEncode(payload) !== payloadPart || + base64urlEncode(signature) !== signaturePart + ) { + return null; + } + const valid = await crypto.subtle.verify( + "HMAC", + await importNamedHmacKey(env.WS_TICKET_SECRET), + signature, + payload, + ); + if (!valid) return null; + const rawClaims = JSON.parse(new TextDecoder().decode(payload)) as unknown; + if ( + typeof rawClaims !== "object" || + rawClaims === null || + Array.isArray(rawClaims) + ) { + return null; + } + const claims = rawClaims as Partial; + const allowedClaims = new Set([ + "jti", + "aud", + "tenantId", + "deviceId", + "credentialVersion", + "sessionId", + "leaseId", + "leaseEpoch", + "browserEpoch", + "agentName", + "exp", + ]); + if ( + Object.keys(claims).some((key) => !allowedClaims.has(key)) || + typeof claims.jti !== "string" || + !isUuid(claims.jti) || + claims.aud !== expected.aud || + typeof claims.tenantId !== "string" || + !isValidTenantId(claims.tenantId) || + typeof claims.deviceId !== "string" || + !isUuid(claims.deviceId) || + typeof claims.leaseEpoch !== "number" || + !Number.isInteger(claims.leaseEpoch) || + claims.leaseEpoch < 0 || + typeof claims.browserEpoch !== "string" || + claims.browserEpoch.length < 1 || + claims.browserEpoch.length > 128 || + claims.agentName !== expected.agentName || + typeof claims.exp !== "number" || + !Number.isInteger(claims.exp) || + claims.exp <= Math.floor(now / 1000) || + claims.exp > Math.floor(now / 1000) + 60 + ) { + return null; + } + if ( + (claims.sessionId !== undefined && + (typeof claims.sessionId !== "string" || claims.sessionId.length > 128)) || + (claims.leaseId !== undefined && + (typeof claims.leaseId !== "string" || claims.leaseId.length > 128)) + ) { + return null; + } + if ( + claims.credentialVersion !== undefined && + (typeof claims.credentialVersion !== "number" || + !Number.isInteger(claims.credentialVersion) || + claims.credentialVersion < 1) + ) { + return null; + } + if ( + (claims.aud === "device-control" && + (claims.credentialVersion === undefined || + claims.sessionId !== undefined || + claims.leaseId !== undefined || + claims.leaseEpoch !== 0)) || + (claims.aud === "session" && + (claims.credentialVersion !== undefined || + typeof claims.sessionId !== "string" || + claims.sessionId.length < 1 || + typeof claims.leaseId !== "string" || + claims.leaseId.length < 1 || + claims.leaseEpoch < 1)) + ) { + return null; + } + return claims as WsTicketClaims; + } catch { + return null; + } +} + +export async function hashProfileStateKey( + tenantId: string, + profileStateKey: string, + env: Env, +): Promise { + const bytes = new TextEncoder().encode(`profile-state\0${tenantId}\0${profileStateKey}`); + const signature = await crypto.subtle.sign( + "HMAC", + await importHmacKey(env.AUTH_HMAC_SECRET), + bytes, + ); + return toHex(new Uint8Array(signature)); +} + +export async function telemetryPseudonym( + domain: string, + value: string, + env: Env, +): Promise { + const signature = await crypto.subtle.sign( + "HMAC", + await importHmacKey(env.AUTH_HMAC_SECRET), + new TextEncoder().encode(`telemetry\0${domain}\0${value}`), + ); + return toHex(new Uint8Array(signature)).slice(0, 32); +} + +export type RateLimitIdentity = + | { kind: "caller"; tenantId: string; actor: string } + | { kind: "device"; tenantId: string; deviceId: string }; + +export async function authenticatedRateAllowed( + identity: RateLimitIdentity, + env: Env, +): Promise { + if (env.RATE_LIMITER === undefined) return true; + const domain = + identity.kind === "caller" ? "rate-limit-caller" : "rate-limit-device"; + const value = + identity.kind === "caller" + ? JSON.stringify([identity.tenantId, identity.actor]) + : JSON.stringify([identity.tenantId, identity.deviceId]); + const key = await telemetryPseudonym(domain, value, env); + return (await env.RATE_LIMITER.limit({ key })).success; +} + async function importHmacKey(secret: string): Promise { return crypto.subtle.importKey( "raw", @@ -192,6 +451,20 @@ async function importHmacKey(secret: string): Promise { ); } +async function importNamedHmacKey(secret: string): Promise { + if (!secret) throw new Error("missing WebSocket ticket secret"); + return importHmacKey(secret); +} + +async function sha256Hex(value: string): Promise { + const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value)); + return toHex(new Uint8Array(digest)); +} + +function isUuid(value: string): boolean { + return SESSION_IDEMPOTENCY_KEY_PATTERN.test(value); +} + function toHex(bytes: Uint8Array): string { return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); } diff --git a/apps/backend/src/coordinator-cf.ts b/apps/backend/src/coordinator-cf.ts index a992420..38fa1fb 100644 --- a/apps/backend/src/coordinator-cf.ts +++ b/apps/backend/src/coordinator-cf.ts @@ -9,9 +9,18 @@ */ import type { Command, Event } from "@understudy/protocol"; -import { COMMAND_TIMED_OUT, DUPLICATE_COMMAND, SESSION_NOT_CONNECTED } from "./coordinator"; +import { + COMMAND_TIMED_OUT, + DUPLICATE_COMMAND, + SESSION_BUSY, + SESSION_NOT_CONNECTED, +} from "./coordinator"; import type { PendingCommand, PendingMap, SessionCoordinator } from "./coordinator"; -import type { SessionStatus } from "./types"; +import type { + LegacyCommandTombstone, + PersistedLegacyAwaiting, + SessionStatus, +} from "./types"; const DEFAULT_TIMEOUT_MS = 30_000; @@ -33,12 +42,16 @@ export interface CoordinatorHost { * truth). */ hasAuthorizedConnection(): boolean; - /** Reads the persisted awaiting-commandId marker (SessionState.awaitingCommandIds). */ - getAwaitingCommandIds(): string[]; - /** Persists the awaiting-commandId marker via the DO's setState. */ - persistAwaitingCommandIds(ids: string[]): void; + getAwaitingCommands(): PersistedLegacyAwaiting[]; + persistAwaitingCommands(commands: PersistedLegacyAwaiting[]): void; + persistCommandTombstone(tombstone: LegacyCommandTombstone): void; /** Persists the session status via the DO's setState. */ persistStatus(status: SessionStatus): void; + /** Persists a result that arrived after the caller's timeout tombstoned it. */ + persistLateResult( + tombstone: PersistedLegacyAwaiting, + event: Event, + ): void; } export class CfSessionCoordinator implements SessionCoordinator { @@ -74,7 +87,10 @@ export class CfSessionCoordinator implements SessionCoordinator { * timeout below is the caller-side guarantee for that case: `send()` * always settles even if the WebSocket is gone. */ - send(cmd: Command): Promise { + send( + cmd: Command, + tombstone: LegacyCommandTombstone, + ): Promise { // Fail fast instead of parking a promise that can only time out: with no // deliverable socket, sendToExtension writes to nobody, so the 30s // timeout (surfacing as an opaque 500) would be the guaranteed outcome - @@ -95,23 +111,30 @@ export class CfSessionCoordinator implements SessionCoordinator { new Error(`${DUPLICATE_COMMAND}: ${cmd.commandId} is already awaiting its event`), ); } + if (this.pending.size > 0 || this.host.getAwaitingCommands().length > 0) { + return Promise.reject( + new Error(`${SESSION_BUSY}: another command owns the session slot`), + ); + } return new Promise((resolve, reject) => { const timer = setTimeout(() => { reject( new Error(`${COMMAND_TIMED_OUT}: ${cmd.commandId} (${cmd.type}) after ${this.timeoutMs}ms`), ); - this.pending.delete(cmd.commandId); - this.dropAwaiting(cmd.commandId); + const pending = this.pending.get(cmd.commandId); + if (pending !== undefined) pending.timedOut = true; }, this.timeoutMs); - const pendingCommand: PendingCommand = { resolve, reject, timer }; + const pendingCommand: PendingCommand = { + resolve, + reject, + timer, + timedOut: false, + tombstone, + }; this.pending.set(cmd.commandId, pendingCommand); - this.addAwaiting(cmd.commandId); - - // DL-004: metadata only, never the full command - a fill_secret's - // eventual plaintext (type.text) and its secretRef must never reach a - // log, by construction. - console.log("coordinator.send", { commandId: cmd.commandId, type: cmd.type }); + this.host.persistCommandTombstone(tombstone); + this.addAwaiting(tombstone); try { this.host.sendToExtension(JSON.stringify(cmd)); @@ -134,10 +157,10 @@ export class CfSessionCoordinator implements SessionCoordinator { * hibernate, wiping the in-memory `pending` Map. If a stray or duplicate * `*_result` for an already-settled commandId arrives after that wake, * there is no resolver left to call for it - the persisted marker is the - * only remaining record that it was ever outstanding. Recognize that - * case, drop the event, and reconcile the marker, instead of leaking a - * phantom "awaiting" entry in SessionState forever or mis-resolving some - * unrelated later command that happens to reuse pending-map bookkeeping. + * only remaining record that it was ever outstanding. Recognize that case, + * retain only an eligible bounded write result, and reconcile the marker + * instead of leaking a phantom "awaiting" entry in SessionState forever or + * mis-resolving an unrelated later command. */ resolvePending(ev: Event): void { const commandId = "commandId" in ev ? ev.commandId : undefined; @@ -146,14 +169,21 @@ export class CfSessionCoordinator implements SessionCoordinator { const pendingCommand = this.pending.get(commandId); if (pendingCommand) { clearTimeout(pendingCommand.timer); + if (pendingCommand.timedOut) { + this.host.persistLateResult(pendingCommand.tombstone, ev); + } pendingCommand.resolve(ev); this.pending.delete(commandId); this.dropAwaiting(commandId); return; } - if (this.host.getAwaitingCommandIds().includes(commandId)) { + const awaiting = this.host + .getAwaitingCommands() + .find((entry) => entry.commandId === commandId); + if (awaiting !== undefined) { // Orphaned/late result: reconcile the marker, resolve nothing. + this.host.persistLateResult(awaiting, ev); this.dropAwaiting(commandId); return; } @@ -166,9 +196,9 @@ export class CfSessionCoordinator implements SessionCoordinator { } /** - * Rejects every outstanding command and clears all bookkeeping. M-004 - * calls this on a fresh `hello` resync, when the extension side is known - * to have dropped whatever was in flight. + * Rejects every in-memory command and clears typed awaiting metadata. + * Pre-migration ID-only markers remain fenced until their late event is + * reconciled because their write/read classification is unknown. */ abandonInFlight(reason: string): void { for (const pendingCommand of this.pending.values()) { @@ -176,17 +206,25 @@ export class CfSessionCoordinator implements SessionCoordinator { pendingCommand.reject(new Error(reason)); } this.pending.clear(); - this.host.persistAwaitingCommandIds([]); + this.host.persistAwaitingCommands( + this.host + .getAwaitingCommands() + .filter((entry) => !("commandType" in entry)), + ); } - private addAwaiting(commandId: string): void { - const current = this.host.getAwaitingCommandIds(); - const next = current.includes(commandId) ? current : [...current, commandId]; - this.host.persistAwaitingCommandIds(next); + private addAwaiting(tombstone: LegacyCommandTombstone): void { + const current = this.host.getAwaitingCommands(); + const next = current.some((entry) => entry.commandId === tombstone.commandId) + ? current + : [...current, tombstone]; + this.host.persistAwaitingCommands(next); } private dropAwaiting(commandId: string): void { - const current = this.host.getAwaitingCommandIds(); - this.host.persistAwaitingCommandIds(current.filter((id) => id !== commandId)); + const current = this.host.getAwaitingCommands(); + this.host.persistAwaitingCommands( + current.filter((entry) => entry.commandId !== commandId), + ); } } diff --git a/apps/backend/src/coordinator.ts b/apps/backend/src/coordinator.ts index b561c93..3da388d 100644 --- a/apps/backend/src/coordinator.ts +++ b/apps/backend/src/coordinator.ts @@ -10,7 +10,7 @@ */ import type { Command, Event } from "@understudy/protocol"; -import type { SessionStatus } from "./types"; +import type { LegacyCommandTombstone, SessionStatus } from "./types"; /** * send()'s delivery-failure vocabulary. These prefixes never cross the RPC @@ -26,12 +26,16 @@ export const SESSION_NOT_CONNECTED = "session not connected"; export const COMMAND_TIMED_OUT = "command timed out"; export const SESSION_RESYNCED = "session resynced"; export const DUPLICATE_COMMAND = "duplicate command in flight"; +export const SESSION_BUSY = "session busy"; +export const SESSION_TERMINAL = "session terminal"; /** One outstanding `send(cmd)` call, awaiting its correlated Event. */ export interface PendingCommand { resolve: (ev: Event) => void; reject: (err: Error) => void; timer: ReturnType; + timedOut: boolean; + tombstone: LegacyCommandTombstone; } /** Outstanding commands awaiting their correlated Event, keyed by commandId. */ @@ -44,6 +48,6 @@ export type PendingMap = Map; * per-command timeout. */ export interface SessionCoordinator { - send(cmd: Command): Promise; + send(cmd: Command, tombstone: LegacyCommandTombstone): Promise; setStatus(s: SessionStatus): void; } diff --git a/apps/backend/src/device.ts b/apps/backend/src/device.ts new file mode 100644 index 0000000..585c2a6 --- /dev/null +++ b/apps/backend/src/device.ts @@ -0,0 +1,678 @@ +import { Agent, getAgentByName } from "agents"; +import type { AgentContext, Connection, ConnectionContext, WSMessage } from "agents"; +import { + DEVICE_CONTROL_FRAME_MAX_BYTES, + PROTOCOL_VERSION, + safeParseDeviceControlClientFrame, + type DeviceControlServerFrame, + type ProtocolCapability, +} from "@understudy/protocol"; +import { + deviceCredentialExists, + mintWsTicket, + verifyWsTicket, + type DeviceIdentity, + type WsTicketClaims, +} from "./auth"; +import type { LeaseResource, TenantDeviceCoordinator } from "./tenant-coordinator"; +import { canonicalizeOrigins } from "./validation"; +import type { Env } from "./types"; +import { emitTelemetry } from "./telemetry"; + +interface DeviceState { + activeConnectionId: string | null; + tenantId: string | null; + browserEpoch: string | null; + browser: string | null; + extVersion: string | null; + capabilities: ProtocolCapability[]; +} + +interface AuthorizedConnectionState { + authorized: true; + claims: WsTicketClaims; +} + +interface DeviceAuthRow { + tenant_id: string; + device_id: string; + credential_digest: string; + credential_version: number; +} + +interface DeviceAuthorityFence { + connectionId: string; + tenantId: string; + browserEpoch: string; + credentialDigest: string; + credentialVersion: number; +} + +export class DeviceAgent extends Agent { + initialState: DeviceState = { + activeConnectionId: null, + tenantId: null, + browserEpoch: null, + browser: null, + extVersion: null, + capabilities: [], + }; + + constructor(ctx: AgentContext, env: Env) { + super(ctx, env); + this.sql` + CREATE TABLE IF NOT EXISTS device_authority ( + singleton INTEGER PRIMARY KEY CHECK(singleton = 1), + tenant_id TEXT NOT NULL, + device_id TEXT NOT NULL, + credential_digest TEXT NOT NULL, + credential_version INTEGER NOT NULL + ) + `; + this.sql` + CREATE TABLE IF NOT EXISTS consumed_ticket ( + jti_hash TEXT PRIMARY KEY, + expires_at INTEGER NOT NULL + ) + `; + } + + shouldSendProtocolMessages(): boolean { + return false; + } + + validateStateChange(_nextState: DeviceState, source: Connection | "server"): void { + if (source !== "server") { + throw new Error("device state is server-driven"); + } + } + + async authorizeCredential(identity: DeviceIdentity): Promise { + if (identity.deviceId !== this.name) return false; + const existing = this.authority(); + if ( + existing !== undefined && + (existing.tenant_id !== identity.tenantId || + existing.device_id !== identity.deviceId || + identity.credentialVersion < existing.credential_version || + (identity.credentialVersion === existing.credential_version && + identity.credentialDigest !== existing.credential_digest)) + ) { + return false; + } + const advanced = await this.coordinator(identity.tenantId).advanceDeviceCredential({ + deviceId: identity.deviceId, + credentialDigest: identity.credentialDigest, + credentialVersion: identity.credentialVersion, + }); + if (!advanced.accepted) return false; + const latest = this.authority(); + if ( + latest !== undefined && + (latest.tenant_id !== identity.tenantId || + latest.device_id !== identity.deviceId || + identity.credentialVersion < latest.credential_version || + (identity.credentialVersion === latest.credential_version && + identity.credentialDigest !== latest.credential_digest)) + ) { + return false; + } + this.sql` + INSERT INTO device_authority ( + singleton, tenant_id, device_id, credential_digest, credential_version + ) VALUES ( + 1, ${identity.tenantId}, ${identity.deviceId}, + ${identity.credentialDigest}, ${identity.credentialVersion} + ) + ON CONFLICT(singleton) DO UPDATE SET + tenant_id = excluded.tenant_id, + device_id = excluded.device_id, + credential_digest = excluded.credential_digest, + credential_version = excluded.credential_version + `; + if ( + existing !== undefined && + (existing.credential_version !== identity.credentialVersion || + existing.credential_digest !== identity.credentialDigest) + ) { + this.setState({ ...this.state, activeConnectionId: null }); + for (const connection of this.getConnections()) { + connection.setState(null); + try { + connection.close(1008, "device credential rotated"); + } catch { + // The persisted credential version already fences the predecessor. + } + } + } + return true; + } + + async onConnect(connection: Connection, ctx: ConnectionContext): Promise { + const ticket = new URL(ctx.request.url).searchParams.get("ticket") ?? ""; + const claims = await verifyWsTicket( + ticket, + { aud: "device-control", agentName: this.name }, + this.env, + ); + let authority = this.authority(); + if ( + claims === null || + authority === undefined || + claims.deviceId !== this.name || + claims.tenantId !== authority.tenant_id || + claims.credentialVersion !== authority.credential_version || + !(await this.consumeTicket(claims)) + ) { + connection.close(1008, "invalid or replayed device ticket"); + return; + } + authority = this.authority(); + if ( + authority === undefined || + claims.tenantId !== authority.tenant_id || + claims.deviceId !== authority.device_id || + claims.credentialVersion !== authority.credential_version + ) { + connection.close(1008, "stale device ticket"); + return; + } + + connection.setState({ authorized: true, claims } satisfies AuthorizedConnectionState); + this.setState({ + ...this.state, + activeConnectionId: connection.id, + tenantId: claims.tenantId, + browserEpoch: claims.browserEpoch, + }); + for (const previous of this.getConnections()) { + if (previous.id === connection.id || !this.isAuthorized(previous)) continue; + previous.setState(null); + try { + previous.close(4001, "replaced by newer authorized device connection"); + } catch { + // The persisted active id already fences a raced close. + } + } + await emitTelemetry(this.env, { + event: "device_connect", + outcome: "authorized", + tenantId: claims.tenantId, + deviceId: claims.deviceId, + }); + } + + async onMessage(connection: Connection, message: WSMessage): Promise { + if (!this.isAuthoritative(connection)) return; + if (typeof message !== "string") { + connection.close(1009, "binary device frames are not supported"); + return; + } + if (new TextEncoder().encode(message).byteLength > DEVICE_CONTROL_FRAME_MAX_BYTES) { + connection.close(1009, "device frame too large"); + return; + } + let raw: unknown; + try { + raw = JSON.parse(message) as unknown; + } catch { + connection.close(1008, "invalid device frame"); + return; + } + const parsed = safeParseDeviceControlClientFrame(raw); + if (!parsed.success) { + connection.close(1008, "invalid device frame"); + return; + } + const frame = parsed.data; + const fence = this.captureAuthority(connection); + if (fence === null) { + connection.close(1008, "device authority missing"); + return; + } + + switch (frame.type) { + case "device_hello": { + if ( + frame.deviceId !== this.name || + frame.browserEpoch !== fence.browserEpoch || + frame.protocolVersion !== PROTOCOL_VERSION + ) { + connection.close(1008, "device hello fence mismatch"); + return; + } + let allowedOrigins: string[]; + try { + allowedOrigins = canonicalizeOrigins(frame.allowedOrigins); + } catch { + connection.close(1008, "invalid local origin policy"); + return; + } + this.setState({ + ...this.state, + browserEpoch: frame.browserEpoch, + browser: frame.browser, + extVersion: frame.extVersion, + capabilities: frame.capabilities, + }); + const coordinator = this.coordinator(fence.tenantId); + const registration = await coordinator.registerDevice({ + deviceId: this.name, + browser: frame.browser, + extVersion: frame.extVersion, + browserEpoch: frame.browserEpoch, + credentialDigest: fence.credentialDigest, + credentialVersion: fence.credentialVersion, + allowedOrigins, + capabilities: frame.capabilities, + }); + if (!this.matchesAuthority(connection, fence)) return; + if (!registration.accepted) { + if (this.state.activeConnectionId === connection.id) { + this.setState({ ...this.state, activeConnectionId: null }); + } + connection.setState(null); + connection.close(1008, "stale device registration"); + return; + } + if (registration.epochChanged) { + await emitTelemetry(this.env, { + event: "device_epoch_change", + outcome: "recovering", + tenantId: fence.tenantId, + deviceId: this.name, + }); + } + return; + } + case "heartbeat": { + if ( + frame.deviceId !== this.name || + frame.browserEpoch !== fence.browserEpoch || + !(await deviceCredentialExists( + fence.credentialDigest, + { + tenantId: fence.tenantId, + deviceId: this.name, + credentialVersion: fence.credentialVersion, + }, + this.env, + )) + ) { + if (!this.matchesAuthority(connection, fence)) return; + const revoked = await this.coordinator(fence.tenantId).revokeDevice( + this.name, + { + credentialDigest: fence.credentialDigest, + credentialVersion: fence.credentialVersion, + }, + ); + if (!revoked || !this.matchesAuthority(connection, fence)) return; + await emitTelemetry(this.env, { + event: "device_offline", + outcome: "credential_revoked", + tenantId: fence.tenantId, + deviceId: this.name, + }); + if (!this.matchesAuthority(connection, fence)) return; + this.send(connection, { type: "credential_revoked" }); + if (this.state.activeConnectionId === connection.id) { + this.setState({ ...this.state, activeConnectionId: null }); + } + connection.setState(null); + connection.close(1008, "device credential revoked"); + return; + } + if (!this.matchesAuthority(connection, fence)) return; + const heartbeat = await this.coordinator(fence.tenantId).heartbeat( + this.name, + frame.browserEpoch, + frame.leaseIds, + ); + if (!this.matchesAuthority(connection, fence)) return; + if (!heartbeat.ok) { + connection.close(1008, "device heartbeat rejected"); + return; + } + for (const lease of heartbeat.recoveries) { + const session = await getAgentByName(this.env.SESSION, lease.sessionId); + if (!this.matchesAuthority(connection, fence)) return; + await session.beginRecovery(lease); + if (!this.matchesAuthority(connection, fence)) return; + await this.sendProvision(lease, fence); + if (!this.matchesAuthority(connection, fence)) return; + await emitTelemetry(this.env, { + event: "recovery", + outcome: "provision_sent", + tenantId: fence.tenantId, + deviceId: this.name, + sessionId: lease.sessionId, + }); + } + for (const lease of heartbeat.assignments) { + const session = await getAgentByName(this.env.SESSION, lease.sessionId); + if (!this.matchesAuthority(connection, fence)) return; + if (await session.needsSessionTicket()) { + if (!this.matchesAuthority(connection, fence)) return; + await this.sendSessionTicket(lease, fence); + } + if (!this.matchesAuthority(connection, fence)) return; + } + for (const lease of heartbeat.closures) { + if (!this.matchesAuthority(connection, fence)) return; + await this.requestClose(lease); + } + return; + } + case "provisioned": { + const result = await this.coordinator(fence.tenantId).markProvisioned({ + ...frame, + deviceId: this.name, + }); + if (!result.accepted) { + if (!this.matchesAuthority(connection, fence)) return; + await emitTelemetry(this.env, { + event: "provisioning", + outcome: "fenced", + tenantId: fence.tenantId, + deviceId: this.name, + sessionId: frame.sessionId, + }); + if (!this.matchesAuthority(connection, fence)) return; + this.send(connection, { + type: "close_lease", + sessionId: frame.sessionId, + leaseId: frame.leaseId, + leaseEpoch: frame.leaseEpoch, + browserEpoch: frame.browserEpoch, + }); + return; + } + const session = await getAgentByName(this.env.SESSION, frame.sessionId); + await session.markProvisioned(frame.tab, frame.browserEpoch); + await emitTelemetry(this.env, { + event: "provisioning", + outcome: "connected", + tenantId: fence.tenantId, + deviceId: this.name, + sessionId: frame.sessionId, + }); + return; + } + case "provision_failed": + await this.coordinator(fence.tenantId).markProvisionFailed({ + ...frame, + deviceId: this.name, + }); + if (!this.matchesAuthority(connection, fence)) return; + await emitTelemetry(this.env, { + event: "provisioning", + outcome: "failed", + tenantId: fence.tenantId, + deviceId: this.name, + sessionId: frame.sessionId, + }); + return; + case "closed": { + const confirmation = await this.coordinator(fence.tenantId).confirmClosed({ + ...frame, + deviceId: this.name, + }); + if (confirmation !== null) { + const session = await getAgentByName(this.env.SESSION, frame.sessionId); + await session.markLifecycle(confirmation.status, false); + if (confirmation.newlyClosed) { + await emitTelemetry(this.env, { + event: "release", + outcome: confirmation.status, + tenantId: fence.tenantId, + deviceId: this.name, + sessionId: frame.sessionId, + }); + } + if (!this.matchesAuthority(connection, fence)) return; + this.send(connection, { + type: "closed_ack", + sessionId: frame.sessionId, + leaseId: frame.leaseId, + leaseEpoch: frame.leaseEpoch, + browserEpoch: frame.browserEpoch, + }); + } + return; + } + } + } + + async onClose(connection: Connection): Promise { + if (this.state.activeConnectionId !== connection.id) return; + this.setState({ ...this.state, activeConnectionId: null }); + } + + async requestProvision(lease: LeaseResource): Promise { + if ( + this.state.tenantId === null || + lease.deviceId !== this.name || + lease.browserEpoch !== this.state.browserEpoch + ) { + return false; + } + return this.sendProvision(lease); + } + + async requestClose(lease: LeaseResource): Promise { + const connection = this.authoritativeConnection(); + if ( + connection === undefined || + lease.deviceId !== this.name || + lease.browserEpoch !== this.state.browserEpoch + ) { + return false; + } + this.send(connection, { + type: "close_lease", + sessionId: lease.sessionId, + leaseId: lease.leaseId, + leaseEpoch: lease.leaseEpoch, + browserEpoch: lease.browserEpoch, + }); + return true; + } + + private async sendProvision( + lease: LeaseResource, + expectedFence?: DeviceAuthorityFence, + ): Promise { + const connection = this.authoritativeConnection(); + const fence = + connection === undefined ? null : this.captureAuthority(connection); + if ( + connection === undefined || + fence === null || + (expectedFence !== undefined && !sameAuthorityFence(fence, expectedFence)) || + lease.deviceId !== this.name || + lease.browserEpoch !== fence.browserEpoch + ) { + return false; + } + const sessionTicket = await mintWsTicket( + { + aud: "session", + tenantId: fence.tenantId, + deviceId: this.name, + sessionId: lease.sessionId, + leaseId: lease.leaseId, + leaseEpoch: lease.leaseEpoch, + browserEpoch: lease.browserEpoch, + agentName: lease.sessionId, + }, + this.env, + ); + if ( + !this.matchesAuthority(connection, fence) || + lease.deviceId !== this.name || + lease.browserEpoch !== this.state.browserEpoch + ) { + return false; + } + this.send(connection, { + type: "provision", + sessionId: lease.sessionId, + leaseId: lease.leaseId, + leaseEpoch: lease.leaseEpoch, + browserEpoch: lease.browserEpoch, + allowedOrigins: lease.allowedOrigins, + sessionTicket, + }); + return true; + } + + private async sendSessionTicket( + lease: LeaseResource, + expectedFence?: DeviceAuthorityFence, + ): Promise { + const connection = this.authoritativeConnection(); + const fence = + connection === undefined ? null : this.captureAuthority(connection); + if ( + connection === undefined || + fence === null || + (expectedFence !== undefined && !sameAuthorityFence(fence, expectedFence)) || + lease.deviceId !== this.name || + lease.browserEpoch !== fence.browserEpoch + ) { + return false; + } + const sessionTicket = await mintWsTicket( + { + aud: "session", + tenantId: fence.tenantId, + deviceId: this.name, + sessionId: lease.sessionId, + leaseId: lease.leaseId, + leaseEpoch: lease.leaseEpoch, + browserEpoch: lease.browserEpoch, + agentName: lease.sessionId, + }, + this.env, + ); + if ( + !this.matchesAuthority(connection, fence) || + lease.deviceId !== this.name || + lease.browserEpoch !== this.state.browserEpoch + ) { + return false; + } + this.send(connection, { + type: "session_ticket", + sessionId: lease.sessionId, + leaseId: lease.leaseId, + leaseEpoch: lease.leaseEpoch, + browserEpoch: lease.browserEpoch, + sessionTicket, + }); + return true; + } + + private authority(): DeviceAuthRow | undefined { + return this.sql`SELECT * FROM device_authority WHERE singleton = 1`[0]; + } + + private coordinator(tenantId: string): DurableObjectStub { + return this.env.TENANT_CONTROL.getByName(tenantId); + } + + private async consumeTicket(claims: WsTicketClaims): Promise { + const jtiHash = await sha256Hex(claims.jti); + this.sql`DELETE FROM consumed_ticket WHERE expires_at <= ${Math.floor(Date.now() / 1000)}`; + this.sql` + INSERT OR IGNORE INTO consumed_ticket (jti_hash, expires_at) + VALUES (${jtiHash}, ${claims.exp}) + `; + const changes = this.sql<{ count: number }>`SELECT changes() AS count`[0]?.count ?? 0; + return changes === 1; + } + + private isAuthorized(connection: Connection): boolean { + return (connection.state as Partial | null)?.authorized === true; + } + + private isAuthoritative(connection: Connection): boolean { + return this.captureAuthority(connection) !== null; + } + + private authoritativeConnection(): Connection | undefined { + if (this.state.activeConnectionId === null) return undefined; + return [...this.getConnections()].find( + (connection) => + connection.id === this.state.activeConnectionId && + this.captureAuthority(connection) !== null, + ); + } + + private captureAuthority( + connection: Connection, + ): DeviceAuthorityFence | null { + if ( + !this.isAuthorized(connection) || + this.state.activeConnectionId !== connection.id + ) { + return null; + } + const connectionState = connection.state as + | Partial + | null; + const claims = connectionState?.claims; + const authority = this.authority(); + if ( + claims === undefined || + authority === undefined || + claims.tenantId !== authority.tenant_id || + claims.deviceId !== authority.device_id || + claims.credentialVersion !== authority.credential_version || + this.state.tenantId !== claims.tenantId || + this.state.browserEpoch !== claims.browserEpoch + ) { + return null; + } + return { + connectionId: connection.id, + tenantId: claims.tenantId, + browserEpoch: claims.browserEpoch, + credentialDigest: authority.credential_digest, + credentialVersion: authority.credential_version, + }; + } + + private matchesAuthority( + connection: Connection, + expected: DeviceAuthorityFence, + ): boolean { + const current = this.captureAuthority(connection); + return current !== null && sameAuthorityFence(current, expected); + } + + private send(connection: Connection, frame: DeviceControlServerFrame): void { + connection.send(JSON.stringify(frame)); + } +} + +function sameAuthorityFence( + left: DeviceAuthorityFence, + right: DeviceAuthorityFence, +): boolean { + return ( + left.connectionId === right.connectionId && + left.tenantId === right.tenantId && + left.browserEpoch === right.browserEpoch && + left.credentialDigest === right.credentialDigest && + left.credentialVersion === right.credentialVersion + ); +} + +async function sha256Hex(value: string): Promise { + const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value)); + return Array.from(new Uint8Array(digest), (byte) => + byte.toString(16).padStart(2, "0"), + ).join(""); +} diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts index d005d72..22dffd7 100644 --- a/apps/backend/src/index.ts +++ b/apps/backend/src/index.ts @@ -1,169 +1,706 @@ -/** - * Worker entry: the consumer-facing command API (M-005). - * - * Two routers share the fetch handler: routeAgentRequest owns the - * `/agents/session/:sessionId` WebSocket path the M2 extension connects to - * (the route segment is `session`, kebab-cased from the SESSION binding - * name in wrangler.jsonc - not from the SessionAgent class name); the Hono - * app below owns everything else, including the /v1 command API consumers - * (metamind/smart-compliance) drive. SessionAgent is re-exported because - * wrangler resolves durable_objects.bindings[].class_name against this - * module's exports. - */ -import { Hono } from "hono"; +import { Hono, type Context } from "hono"; import { getAgentByName, routeAgentRequest } from "agents"; -import { safeParseCommand } from "@understudy/protocol"; +import { z } from "zod"; +import { + CommandRequestSchema, + SESSION_RESULT_FRAME_MAX_BYTES, + UnattendedSessionRequestSchema, + isWriteCommand, +} from "@understudy/protocol"; import { authenticate, + authenticatedRateAllowed, + authenticateDevice, mintSessionId, + mintWsTicket, scopeSession, SESSION_IDEMPOTENCY_KEY_PATTERN, + telemetryPseudonym, verifyExtensionToken, + verifyWsTicket, } from "./auth"; -import type { DispatchOutcome, Env } from "./types"; +import type { DeviceAgent } from "./device"; import type { SessionAgent } from "./session"; +import type { TenantDeviceCoordinator } from "./tenant-coordinator"; +import type { DispatchOutcome, Env, V2DispatchOutcome } from "./types"; +import { + canonicalizeUnattendedRequest, + parseBoundedStrictJson, + RequestBodyError, +} from "./validation"; +import { emitTelemetry } from "./telemetry"; +import type { Actor, DeviceIdentity } from "./auth"; +export { DeviceAgent } from "./device"; export { SessionAgent } from "./session"; +export { TenantDeviceCoordinator } from "./tenant-coordinator"; + +const app = new Hono<{ Bindings: Env }>(); +const DeviceTicketRequestSchema = z + .object({ browserEpoch: z.string().min(1).max(128) }) + .strict(); function getSessionStub(env: Env, sessionId: string): Promise> { return getAgentByName(env.SESSION, sessionId); } -const app = new Hono<{ Bindings: Env }>(); +function getTenantStub( + env: Env, + tenantId: string, +): DurableObjectStub { + return env.TENANT_CONTROL.getByName(tenantId); +} app.get("/health", (c) => c.json({ ok: true })); app.post("/v1/sessions", async (c) => { - const actor = await authenticate(c.req.raw, c.env); - if (!actor) return c.json({ error: "unauthorized" }, 401); + const authentication = await authenticateCaller(c.req.raw, c.env); + if (authentication.kind === "unauthorized") return c.json({ error: "unauthorized" }, 401); + if (authentication.kind === "rate_limited") return c.json({ error: "rate limited" }, 429); + const actor = authentication.actor; - const idempotencyKey = c.req.header("idempotency-key")?.trim(); + const idempotencyKey = c.req.header("idempotency-key")?.trim().toLowerCase(); if ( idempotencyKey !== undefined && !SESSION_IDEMPOTENCY_KEY_PATTERN.test(idempotencyKey) ) { return c.json({ error: "idempotency-key must be a UUID" }, 400); } + + if (c.req.raw.body === null) { + const actorPseudonym = await telemetryPseudonym("actor", actor.actor, c.env); + if ( + !(await getTenantStub(c.env, actor.tenantId).consumeSessionCreateQuota( + actorPseudonym, + )) + ) { + return c.json({ error: "session creation quota exceeded" }, 429); + } + const sessionId = await mintSessionId(actor.tenantId, c.env, idempotencyKey); + await emitTelemetry(c.env, { + event: "session_create", + outcome: "attended", + tenantId: actor.tenantId, + actor: actor.actor, + sessionId, + }); + return c.json({ sessionId }); + } + + let request: z.infer; + try { + request = await parseBoundedStrictJson(c.req.raw, UnattendedSessionRequestSchema); + } catch (error) { + return bodyError(c, error); + } + if (idempotencyKey === undefined) { + return c.json({ error: "idempotency-key is required for unattended sessions" }, 400); + } + if (!enabledForTenant(c.env.UNATTENDED_ENABLED_TENANTS, actor.tenantId)) { + return c.json({ error: "unattended sessions are disabled" }, 503); + } + + let canonical; + try { + canonical = await canonicalizeUnattendedRequest(request, actor.tenantId, c.env); + } catch (error) { + return bodyError(c, error); + } const sessionId = await mintSessionId(actor.tenantId, c.env, idempotencyKey); - return c.json({ sessionId }); + const actorPseudonym = await telemetryPseudonym("actor", actor.actor, c.env); + const coordinator = getTenantStub(c.env, actor.tenantId); + const allocation = await coordinator.createLease({ + idempotencyKey, + fingerprint: canonical.fingerprint, + sessionId, + ...(canonical.deviceId === undefined ? {} : { deviceId: canonical.deviceId }), + allowedOrigins: canonical.allowedOrigins, + profileStateHash: canonical.profileStateHash, + actorPseudonym, + }); + await emitTelemetry(c.env, { + event: allocation.kind === "replay" ? "session_replay" : "session_create", + outcome: allocation.kind, + tenantId: actor.tenantId, + actor: actor.actor, + sessionId, + ...("lease" in allocation ? { deviceId: allocation.lease.deviceId } : {}), + }); + + switch (allocation.kind) { + case "conflict": + return c.json({ error: "idempotency key conflicts with its original request" }, 409); + case "terminal": + return c.json({ sessionId, mode: "unattended", status: allocation.status }, 410); + case "device_not_found": + return c.json({ error: "device not found" }, 404); + case "no_device": + return c.json({ error: "no online compatible device" }, 503); + case "capacity": + return c.json({ error: "device capacity exhausted" }, 429); + case "collision": + return c.json({ error: "origin or profile-state collision" }, 409); + case "created": + case "replay": { + const session = await getSessionStub(c.env, sessionId); + if (allocation.created) { + try { + await session.initializeUnattended(actor.tenantId, allocation.lease); + const device = c.env.DEVICE.getByName( + allocation.lease.deviceId, + ) as DurableObjectStub; + if (!(await device.requestProvision(allocation.lease))) { + throw new Error("device connection unavailable"); + } + } catch { + await coordinator.markProvisionFailed({ + sessionId, + leaseId: allocation.lease.leaseId, + deviceId: allocation.lease.deviceId, + leaseEpoch: allocation.lease.leaseEpoch, + browserEpoch: allocation.lease.browserEpoch, + }); + await session.markLifecycle("closing", true); + return c.json({ error: "device connection unavailable" }, 503); + } + } + const connected = await session.waitForProtocolV2Connection(5_000); + const location = sessionLocation(c.req.raw, sessionId); + if (!connected) { + c.header("Location", location); + c.header("Retry-After", "2"); + return c.json( + { sessionId, mode: "unattended", status: allocation.lease.status }, + 202, + ); + } + return c.json( + { sessionId, mode: "unattended", status: "connected" as const }, + allocation.created ? 201 : 200, + ); + } + } }); -app.get("/v1/sessions/:sessionId", async (c) => { - const actor = await authenticate(c.req.raw, c.env); - if (!actor) return c.json({ error: "unauthorized" }, 401); +app.get("/v1/devices", async (c) => { + const authentication = await authenticateCaller(c.req.raw, c.env); + if (authentication.kind === "unauthorized") return c.json({ error: "unauthorized" }, 401); + if (authentication.kind === "rate_limited") return c.json({ error: "rate limited" }, 429); + const actor = authentication.actor; + return c.json({ devices: await getTenantStub(c.env, actor.tenantId).listDevices() }); +}); +app.get("/v1/sessions/:sessionId", async (c) => { + const authentication = await authenticateCaller(c.req.raw, c.env); + if (authentication.kind === "unauthorized") return c.json({ error: "unauthorized" }, 401); + if (authentication.kind === "rate_limited") return c.json({ error: "rate limited" }, 429); + const actor = authentication.actor; const sessionId = c.req.param("sessionId"); - const scope = await scopeSession(sessionId, actor.tenantId, c.env); - if (scope === "not-found") return c.json({ error: "not found" }, 404); - - const stub = await getSessionStub(c.env, sessionId); - const status = await stub.getStatus(); + if ((await scopeSession(sessionId, actor.tenantId, c.env)) === "not-found") { + return c.json({ error: "not found" }, 404); + } + const session = await getSessionStub(c.env, sessionId); + const status = await session.getStatus(); + if ("mode" in status && status.mode === "unattended") { + return status.status === "closed" || + status.status === "expired" || + status.status === "lost" + ? c.json(status, 410) + : c.json(status); + } + if (await session.isTerminal()) return c.json(status, 410); return c.json(status); }); -// Auth order is load-bearing: authenticate (401) -> scopeSession (404) -> -// parse (400), so an unauthenticated or cross-tenant request never reaches -// command parsing/dispatch, and a cross-tenant sessionId is indistinguishable -// from one that never existed (DL-008). -app.post("/v1/sessions/:sessionId/commands", async (c) => { - const actor = await authenticate(c.req.raw, c.env); - if (!actor) return c.json({ error: "unauthorized" }, 401); +app.delete("/v1/sessions/:sessionId", async (c) => { + const authentication = await authenticateCaller(c.req.raw, c.env); + if (authentication.kind === "unauthorized") return c.json({ error: "unauthorized" }, 401); + if (authentication.kind === "rate_limited") return c.json({ error: "rate limited" }, 429); + const actor = authentication.actor; + const sessionId = c.req.param("sessionId"); + if ((await scopeSession(sessionId, actor.tenantId, c.env)) === "not-found") { + return c.json({ error: "not found" }, 404); + } + const session = await getSessionStub(c.env, sessionId); + const status = await session.getStatus(); + if (!("mode" in status) || status.mode !== "unattended") { + const confirmed = await session.requestCloseAttended(); + await emitTelemetry(c.env, { + event: "session_close", + outcome: confirmed ? "confirmed" : "pending", + tenantId: actor.tenantId, + actor: actor.actor, + sessionId, + }); + return confirmed + ? c.body(null, 204) + : c.body(null, 202, { Location: sessionLocation(c.req.raw, sessionId) }); + } + const coordinator = getTenantStub(c.env, actor.tenantId); + const closing = await coordinator.closeLease(sessionId); + if (!closing.found) return c.json({ error: "not found" }, 404); + if (closing.cleanupConfirmed) { + await emitTelemetry(c.env, { + event: "session_close", + outcome: "confirmed", + tenantId: actor.tenantId, + actor: actor.actor, + sessionId, + }); + return c.body(null, 204); + } + if (closing.lease !== undefined) { + await session.markLifecycle("closing", closing.lease.needsReconciliation); + const device = c.env.DEVICE.getByName( + closing.lease.deviceId, + ) as DurableObjectStub; + await device.requestClose(closing.lease); + } + await emitTelemetry(c.env, { + event: "session_close", + outcome: "pending", + tenantId: actor.tenantId, + actor: actor.actor, + sessionId, + deviceId: closing.lease?.deviceId, + }); + return c.body(null, 202, { Location: sessionLocation(c.req.raw, sessionId) }); +}); +app.post("/v1/sessions/:sessionId/commands", async (c) => { + const authentication = await authenticateCaller(c.req.raw, c.env); + if (authentication.kind === "unauthorized") return c.json({ error: "unauthorized" }, 401); + if (authentication.kind === "rate_limited") return c.json({ error: "rate limited" }, 429); + const actor = authentication.actor; const sessionId = c.req.param("sessionId"); - const scope = await scopeSession(sessionId, actor.tenantId, c.env); - if (scope === "not-found") return c.json({ error: "not found" }, 404); + if ((await scopeSession(sessionId, actor.tenantId, c.env)) === "not-found") { + return c.json({ error: "not found" }, 404); + } - // Unparseable JSON is the client's fault: 400, not a rethrow into the - // uniform 500 (which would dress a client error as a server error). - let body: { command?: unknown; dryRun?: unknown }; + let body: z.infer; try { - body = await c.req.json(); - } catch { - return c.json({ error: "invalid body" }, 400); + body = await parseBoundedStrictJson(c.req.raw, CommandRequestSchema); + } catch (error) { + if (error instanceof RequestBodyError && error.category === "schema") { + return c.json({ error: "invalid command" }, 400); + } + return bodyError(c, error); } - const parsed = safeParseCommand(body?.command); - if (!parsed.success) return c.json({ error: "invalid command" }, 400); - - // fill_secret is routed to the DO's dedicated fillSecret RPC rather than a - // generic dispatch, so vault resolution stays behind that one method's - // no-plaintext-leak contract instead of a raw type{text} command ever - // carrying a secret through this route (DL-004). - const dryRun = body?.dryRun === true; + const dryRun = body.dryRun ?? false; const stub = await getSessionStub(c.env, sessionId); + if (await stub.isTerminal()) { + return c.json({ error: "session is terminal" }, 410); + } + const contractV2 = c.req.header("understudy-command-contract") === "2"; + + if (contractV2 || (await stub.usesV2CommandProtocol())) { + const statusUrl = commandLocation(c.req.raw, sessionId, body.command.commandId); + const actorPseudonym = await telemetryPseudonym("actor", actor.actor, c.env); + const outcome = await stub.dispatchV2(body.command, dryRun, actorPseudonym, statusUrl); + await emitTelemetry(c.env, { + event: "command", + outcome: outcome.kind, + tenantId: actor.tenantId, + actor: actor.actor, + sessionId, + commandType: body.command.type, + }); + return contractV2 + ? v2Outcome(c, outcome, sessionId) + : compatibilityV2Outcome(c, outcome, sessionId); + } + + if ( + isWriteCommand(body.command) && + !dryRun && + enabledForTenant(c.env.SAFE_WRITE_REQUIRED_TENANTS, actor.tenantId) + ) { + return c.json({ error: "extension lacks safe-write-v2" }, 426); + } + + const actorPseudonym = await telemetryPseudonym("actor", actor.actor, c.env); + const admitted = await getTenantStub(c.env, actor.tenantId).authorizeAttendedCommand({ + sessionId, + actorPseudonym, + credentialFill: body.command.type === "fill_secret" && !dryRun, + }); + if (!admitted) return c.json({ code: "command_quota_exceeded" }, 429); + const outcome: DispatchOutcome = - parsed.data.type === "fill_secret" - ? await stub.fillSecret(parsed.data, dryRun) - : await stub.dispatch(parsed.data, dryRun); + body.command.type === "fill_secret" + ? await stub.fillSecret(body.command, dryRun) + : await stub.dispatch(body.command, dryRun); + await emitTelemetry(c.env, { + event: "command", + outcome: outcome.ok ? "legacy_terminal" : `legacy_${outcome.reason}`, + tenantId: actor.tenantId, + actor: actor.actor, + sessionId, + commandType: body.command.type, + }); if (outcome.ok) return c.json(outcome.event); - - // Expected delivery failures arrive as typed outcomes, never as RPC - // rejections (types.ts::DispatchOutcome). All of these are deliberately - // non-2xx rather than a 200 ok:false Event, which a consumer's - // idempotency store would cache and replay even after the extension - // reconnects. Only a genuine bug still throws (-> the uniform 500). - // - // The honest reason is logged for observability; by DL-004 construction it - // carries only {commandId, type}-level detail, never a command payload. - console.warn("command dispatch failed", outcome.message); switch (outcome.reason) { case "not_connected": - // Retryable infrastructure state: no live, authorized extension socket. return c.json({ error: "extension not connected" }, 503); case "timed_out": return c.json({ error: "command timed out" }, 504); case "resynced": - // The extension reconnected mid-command, abandoning whatever it had in - // flight. Retryable, like not_connected - the session itself is healthy. return c.json({ error: "session resynced mid-command" }, 503); case "duplicate_in_flight": return c.json({ error: "command already in flight" }, 409); - default: - // A new DispatchOutcome.reason without a route mapping fails the build - // here (assertNever) instead of silently returning undefined. - return assertNever(outcome.reason); + case "session_busy": + return c.json({ code: "session_busy" }, 429); + case "terminal_session": + return c.json({ error: "session is terminal" }, 410); + case "id_conflict": + return c.json({ code: "command_id_conflict" }, 409); } }); -// Compile-time exhaustiveness backstop for the DispatchOutcome.reason switch. -function assertNever(value: never): never { - throw new Error(`unhandled dispatch outcome reason: ${String(value)}`); -} +app.get("/v1/sessions/:sessionId/commands/:commandId", async (c) => { + const authentication = await authenticateCaller(c.req.raw, c.env); + if (authentication.kind === "unauthorized") return c.json({ error: "unauthorized" }, 401); + if (authentication.kind === "rate_limited") return c.json({ error: "rate limited" }, 429); + const actor = authentication.actor; + const sessionId = c.req.param("sessionId"); + if ((await scopeSession(sessionId, actor.tenantId, c.env)) === "not-found") { + return c.json({ error: "not found" }, 404); + } + const commandId = c.req.param("commandId"); + if (commandId.length < 1 || commandId.length > 128) { + return c.json({ error: "invalid command id" }, 400); + } + const status = await (await getSessionStub(c.env, sessionId)).getCommandStatus( + commandId, + ); + if (status === null) return c.json({ error: "not found" }, 404); + return c.json(status); +}); -// Anything a route throws (or rethrows above) becomes a uniform JSON 500 -// instead of workerd's opaque non-JSON error page. Command payloads never -// enter error messages by construction (DL-004), so logging the message -// leaks nothing; the response body stays generic regardless. -app.onError((err, c) => { - console.error("unhandled route error", err.message); - return c.json({ error: "internal error" }, 500); +app.post("/v1/device/connect-ticket", async (c) => { + const authentication = await authenticateDeviceCaller(c.req.raw, c.env); + if (authentication.kind === "unauthorized") return c.json({ error: "unauthorized" }, 401); + if (authentication.kind === "rate_limited") return c.json({ error: "rate limited" }, 429); + const device = authentication.device; + let body: z.infer; + try { + body = await parseBoundedStrictJson(c.req.raw, DeviceTicketRequestSchema, 4 * 1024); + } catch (error) { + return bodyError(c, error); + } + const coordinator = getTenantStub(c.env, device.tenantId); + if (!(await coordinator.consumeDeviceTicketQuota(device.deviceId))) { + return c.json({ error: "device ticket quota exceeded" }, 429); + } + const agent = c.env.DEVICE.getByName(device.deviceId) as DurableObjectStub; + if (!(await agent.authorizeCredential(device))) { + return c.json({ error: "device not found" }, 404); + } + const ticket = await mintWsTicket( + { + aud: "device-control", + tenantId: device.tenantId, + deviceId: device.deviceId, + credentialVersion: device.credentialVersion, + leaseEpoch: 0, + browserEpoch: body.browserEpoch, + agentName: device.deviceId, + }, + c.env, + ); + return c.json({ + ticket, + expiresIn: 60, + websocketPath: `/agents/device/${encodeURIComponent(device.deviceId)}`, + }); }); -/** - * Worker-level gate on every request routeAgentRequest matches, BEFORE the - * Durable Object ever accepts the socket (or serves an SDK HTTP surface). - * Defense-in-depth with SessionAgent.onConnect: the in-DO gate stays (it - * covers any path that reaches the DO without this router), but an - * unauthorized upgrade is now refused at the edge instead of being - * accepted-but-inert. Failure statuses mirror the /v1 discipline: bad token - * 401; a sessionId whose tenant disagrees with the token collapses to 404, - * never 403 (DL-008: no existence oracle). `lobby.name` is the raw - * `:sessionId` path segment routeAgentRequest extracted. - */ +type CallerAuthentication = + | { kind: "ok"; actor: Actor } + | { kind: "unauthorized" } + | { kind: "rate_limited" }; + +async function authenticateCaller( + request: Request, + env: Env, +): Promise { + const actor = await authenticate(request, env); + if (actor === null) { + await emitTelemetry(env, { event: "authentication", outcome: "unauthorized" }); + return { kind: "unauthorized" }; + } + if ( + !(await authenticatedRateAllowed( + { kind: "caller", tenantId: actor.tenantId, actor: actor.actor }, + env, + )) + ) { + await emitTelemetry(env, { + event: "authentication", + outcome: "rate_limited", + tenantId: actor.tenantId, + actor: actor.actor, + }); + return { kind: "rate_limited" }; + } + await emitTelemetry(env, { + event: "authentication", + outcome: "ok", + tenantId: actor.tenantId, + actor: actor.actor, + }); + return { kind: "ok", actor }; +} + +type DeviceAuthentication = + | { kind: "ok"; device: DeviceIdentity } + | { kind: "unauthorized" } + | { kind: "rate_limited" }; + +async function authenticateDeviceCaller( + request: Request, + env: Env, +): Promise { + const device = await authenticateDevice(request, env); + if (device === null) { + await emitTelemetry(env, { event: "authentication", outcome: "device_unauthorized" }); + return { kind: "unauthorized" }; + } + if ( + !(await authenticatedRateAllowed( + { kind: "device", tenantId: device.tenantId, deviceId: device.deviceId }, + env, + )) + ) { + await emitTelemetry(env, { + event: "authentication", + outcome: "device_rate_limited", + tenantId: device.tenantId, + deviceId: device.deviceId, + }); + return { kind: "rate_limited" }; + } + await emitTelemetry(env, { + event: "authentication", + outcome: "device_ok", + tenantId: device.tenantId, + deviceId: device.deviceId, + }); + return { kind: "ok", device }; +} + +function v2Outcome( + c: Parameters[0], + outcome: V2DispatchOutcome, + sessionId: string, +) { + switch (outcome.kind) { + case "terminal": + return c.json(outcome.event); + case "pending": + c.header("Location", outcome.pending.statusUrl); + c.header("Retry-After", "2"); + return c.json(outcome.pending, 202); + case "not_started": + return c.json( + { + code: "command_not_started", + commandId: outcome.commandId, + safeToRetry: true, + }, + 504, + ); + case "timed_out": + return c.json( + { + code: "command_timed_out", + commandId: outcome.commandId, + safeToRetry: true, + }, + 504, + ); + case "unknown": + return c.json( + { + code: "command_outcome_unknown", + commandId: outcome.commandId, + safeToRetry: false, + }, + 409, + ); + case "id_conflict": + return c.json({ code: "command_id_conflict", commandId: outcome.commandId }, 409); + case "busy": + return c.json({ code: "session_busy", commandId: outcome.commandId }, 429); + case "not_connected": + return c.json({ error: "session connection unavailable", sessionId }, 503); + case "unsupported": + return c.json({ error: "extension lacks safe-write-v2" }, 426); + case "terminal_session": + return c.json({ error: "session is terminal" }, 410); + } +} + +function compatibilityV2Outcome( + c: Parameters[0], + outcome: V2DispatchOutcome, + sessionId: string, +) { + if (outcome.kind === "pending") { + return c.json( + { + code: "command_pending_connector_upgrade", + commandId: outcome.pending.commandId, + safeToRetry: false, + }, + 503, + ); + } + return v2Outcome(c, outcome, sessionId); +} + async function gateAgentRequest( req: Request, lobby: { name: string }, env: Env, ): Promise { - const token = new URL(req.url).searchParams.get("token") ?? ""; + const url = new URL(req.url); + const path = url.pathname.split("/").filter(Boolean); + const agentType = path.at(-2); + const ticket = url.searchParams.get("ticket"); + if (agentType === "device") { + if ( + ticket === null || + (await verifyWsTicket( + ticket, + { aud: "device-control", agentName: lobby.name }, + env, + )) === null + ) { + return new Response("invalid device ticket", { status: 401 }); + } + return undefined; + } + if (ticket !== null) { + const claims = await verifyWsTicket( + ticket, + { aud: "session", agentName: lobby.name }, + env, + ); + if (claims === null || claims.sessionId !== lobby.name) { + return new Response("invalid session ticket", { status: 401 }); + } + if ((await scopeSession(lobby.name, claims.tenantId, env)) !== "ok") { + return new Response("not found", { status: 404 }); + } + return undefined; + } + const token = url.searchParams.get("token") ?? ""; const verified = await verifyExtensionToken(token, env); if (verified === null) return new Response("invalid extension token", { status: 401 }); - const scope = await scopeSession(lobby.name, verified.tenantId, env); - if (scope !== "ok") return new Response("not found", { status: 404 }); + if ((await scopeSession(lobby.name, verified.tenantId, env)) !== "ok") { + return new Response("not found", { status: 404 }); + } return undefined; } +async function gateAgentPathBeforeResolution( + req: Request, + env: Env, +): Promise { + const url = new URL(req.url); + const path = url.pathname.split("/").filter(Boolean); + if (path.length !== 3 || path[0] !== "agents") return undefined; + const agentType = path[1]; + let agentName: string; + try { + agentName = decodeURIComponent(path[2] ?? ""); + } catch { + return new Response("invalid agent path", { status: 400 }); + } + if (agentName.length < 1 || agentName.length > 128) { + return new Response("invalid agent path", { status: 400 }); + } + + const ticket = url.searchParams.get("ticket"); + if (agentType === "device") { + const claims = + ticket === null + ? null + : await verifyWsTicket( + ticket, + { aud: "device-control", agentName }, + env, + ); + return claims?.deviceId === agentName + ? null + : new Response("invalid device ticket", { status: 401 }); + } + if (agentType !== "session") return undefined; + if (ticket !== null) { + const claims = await verifyWsTicket( + ticket, + { aud: "session", agentName }, + env, + ); + if (claims === null || claims.sessionId !== agentName) { + return new Response("invalid session ticket", { status: 401 }); + } + return (await scopeSession(agentName, claims.tenantId, env)) === "ok" + ? null + : new Response("not found", { status: 404 }); + } + const verified = await verifyExtensionToken(url.searchParams.get("token") ?? "", env); + if (verified === null) return new Response("invalid extension token", { status: 401 }); + return (await scopeSession(agentName, verified.tenantId, env)) === "ok" + ? null + : new Response("not found", { status: 404 }); +} + +function bodyError(c: HonoContext, error: unknown) { + if (error instanceof RequestBodyError) { + return c.json({ error: error.message }, error.status); + } + return c.json({ error: "invalid body" }, 400); +} + +type HonoContext = Context<{ Bindings: Env }>; + +function enabledForTenant(raw: string, tenantId: string): boolean { + try { + const parsed = JSON.parse(raw || "[]") as unknown; + return Array.isArray(parsed) && (parsed.includes("*") || parsed.includes(tenantId)); + } catch { + return false; + } +} + +function sessionLocation(request: Request, sessionId: string): string { + return new URL(`/v1/sessions/${encodeURIComponent(sessionId)}`, request.url).toString(); +} + +function commandLocation(request: Request, sessionId: string, commandId: string): string { + return new URL( + `/v1/sessions/${encodeURIComponent(sessionId)}/commands/${encodeURIComponent(commandId)}`, + request.url, + ).toString(); +} + +app.onError((_error, c) => { + console.error("unhandled route error"); + return c.json({ error: "internal error" }, 500); +}); + export default { async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise { + if ( + request.headers.get("content-length") !== null && + Number(request.headers.get("content-length")) > SESSION_RESULT_FRAME_MAX_BYTES + ) { + return Response.json({ error: "request body too large" }, { status: 413 }); + } + const agentGate = await gateAgentPathBeforeResolution(request, env); + if (agentGate instanceof Response) return agentGate; const agentResponse = await routeAgentRequest(request, env, { onBeforeConnect: (req, lobby) => gateAgentRequest(req, lobby, env), onBeforeRequest: (req, lobby) => gateAgentRequest(req, lobby, env), diff --git a/apps/backend/src/quota.ts b/apps/backend/src/quota.ts new file mode 100644 index 0000000..b6d4df7 --- /dev/null +++ b/apps/backend/src/quota.ts @@ -0,0 +1,45 @@ +export interface QuotaPolicy { + sessionCreatesPerActorMinute: number; + commandsPerSessionMinute: number; + commandsPerTenantMinute: number; + credentialFillsPerActorMinute: number; + deviceTicketsPerDeviceMinute: number; + sessionCommandCap: number; +} + +export const DEFAULT_QUOTA_POLICY: QuotaPolicy = { + sessionCreatesPerActorMinute: 10, + commandsPerSessionMinute: 120, + commandsPerTenantMinute: 600, + credentialFillsPerActorMinute: 30, + deviceTicketsPerDeviceMinute: 30, + sessionCommandCap: 10_000, +}; + +export function parseQuotaPolicy(raw: string): QuotaPolicy { + if (!raw) return DEFAULT_QUOTA_POLICY; + let value: unknown; + try { + value = JSON.parse(raw) as unknown; + } catch { + throw new Error("invalid QUOTA_POLICY"); + } + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new Error("invalid QUOTA_POLICY"); + } + const input = value as Record; + const expected = Object.keys(DEFAULT_QUOTA_POLICY); + if (Object.keys(input).some((key) => !expected.includes(key))) { + throw new Error("invalid QUOTA_POLICY"); + } + const output = { ...DEFAULT_QUOTA_POLICY }; + for (const key of expected as Array) { + const candidate = input[key]; + if (candidate === undefined) continue; + if (typeof candidate !== "number" || !Number.isInteger(candidate) || candidate < 1) { + throw new Error("invalid QUOTA_POLICY"); + } + output[key] = candidate; + } + return output; +} diff --git a/apps/backend/src/session.ts b/apps/backend/src/session.ts index 6747643..1972599 100644 --- a/apps/backend/src/session.ts +++ b/apps/backend/src/session.ts @@ -1,29 +1,108 @@ import { Agent } from "agents"; import type { AgentContext, Connection, ConnectionContext, WSMessage } from "agents"; -import { isWriteCommand, safeParseEvent } from "@understudy/protocol"; -import type { Command, Event } from "@understudy/protocol"; -import { scopeSession, tenantOf, verifyExtensionToken } from "./auth"; +import { z } from "zod"; +import { + PROTOCOL_CAPABILITIES, + PROTOCOL_VERSION, + SESSION_RESULT_FRAME_MAX_BYTES, + WRITE_COMMAND_TYPES, + WS_CLOSE_REPLACED, + WS_CLOSE_SESSION_TERMINAL, + isWriteCommand, + safeParseEvent, + safeParseSessionClientFrame, + utf8ByteLength, +} from "@understudy/protocol"; +import type { + Command, + CommandState, + Event, + ProtocolCapability, + SessionClientFrame, + SessionServerFrame, + TabInfo, + UnattendedSessionLifecycle, +} from "@understudy/protocol"; +import { + scopeSession, + tenantOf, + verifyExtensionToken, + verifyWsTicket, + type WsTicketClaims, +} from "./auth"; import { COMMAND_TIMED_OUT, DUPLICATE_COMMAND, SESSION_NOT_CONNECTED, SESSION_RESYNCED, + SESSION_BUSY, + SESSION_TERMINAL, } from "./coordinator"; import { CfSessionCoordinator } from "./coordinator-cf"; import { resolveSecret } from "./secrets"; import { createVault } from "./vault"; -import type { DispatchOutcome, Env, SessionState, SessionStatus } from "./types"; +import type { + CommandStatusRecord, + CompletedLegacyWrite, + DispatchOutcome, + Env, + LegacyCommandTombstone, + PersistedLegacyCommandTombstone, + PersistedLegacyAwaiting, + SessionState, + SessionStatus, + V2DispatchOutcome, +} from "./types"; +import type { LeaseResource, TenantDeviceCoordinator } from "./tenant-coordinator"; +import { parseQuotaPolicy } from "./quota"; +import { requestFingerprint } from "./validation"; +import { emitTelemetry, type TelemetryEvent } from "./telemetry"; type FillSecretCommand = Extract; -// Bounds SessionState.completedWrites (the idempotent-retry replay record). -// 100 write results at ~100 bytes each is well under any DO state budget -// while covering far more retries than a consumer's per-case write count. +// Bounds SessionState.completedWrites by both count and serialized event size. +// The FIFO cap covers retries without allowing late results to grow state +// without a fixed ceiling. const COMPLETED_WRITES_CAP = 100; +const COMPLETED_WRITE_EVENT_MAX_BYTES = 16 * 1024; // Bounds SessionState.dialogs (the recent-dialogs surface). Dialogs are far // rarer than writes; 50 recent covers any realistic burst a consumer polls for. const RECENT_DIALOGS_CAP = 50; +const PREPARE_DEADLINE_MS = 5_000; +const EXECUTION_DEADLINE_MS = 25_000; +const SYNCHRONOUS_WAIT_MS = 20_000; +const LegacyDialogEventSchema = z + .object({ + type: z.literal("dialog"), + tabId: z.number().int().nonnegative(), + dialogType: z.enum(["alert", "confirm", "prompt", "beforeunload"]), + message: z.string().max(4 * 1024), + url: z.string().min(1).max(8 * 1024), + defaultPrompt: z.string().max(1024).optional(), + disposition: z.enum(["accept", "dismiss"]), + }) + .strict(); + +interface CommandRow { + command_id: string; + fingerprint: string; + command_type: Command["type"]; + dry_run: number; + state: CommandState; + attempt_id: string; + ready_deadline_at: number; + execution_deadline_at: number | null; + result_json: string | null; + created_at: number; + updated_at: number; + is_write: number; +} + +interface AuthorizedConnectionState { + authorized: true; + ticket?: WsTicketClaims; +} export class SessionAgent extends Agent { initialState: SessionState = { @@ -32,13 +111,19 @@ export class SessionAgent extends Agent { currentUrl: null, generation: 0, awaitingCommandIds: [], + awaitingCommands: [], status: "pending", activeConnectionId: null, completedWrites: [], dialogs: [], + protocolVersion: 1, + capabilities: [], + mode: "attended", }; private readonly coordinator: CfSessionCoordinator; + private readonly stateWaiters = new Map void>>(); + private readonly connectionWaiters = new Set<(connected: boolean) => void>(); constructor(ctx: AgentContext, env: Env) { super(ctx, env); @@ -51,20 +136,102 @@ export class SessionAgent extends Agent { connection.send(payload); }, hasAuthorizedConnection: () => this.hasAuthorizedConnection(), - getAwaitingCommandIds: () => this.state.awaitingCommandIds, - persistAwaitingCommandIds: (ids) => this.setState({ ...this.state, awaitingCommandIds: ids }), + getAwaitingCommands: () => this.awaitingLegacyCommands(), + persistAwaitingCommands: (commands) => + this.setState({ + ...this.state, + awaitingCommands: commands, + awaitingCommandIds: commands.map((entry) => entry.commandId), + }), + persistCommandTombstone: (tombstone) => + this.rememberLegacyCommandTombstone(tombstone), persistStatus: (status) => this.setState({ ...this.state, status }), + persistLateResult: (tombstone, event) => + this.rememberLegacyLateResult(tombstone, event), }); + this.sql` + CREATE TABLE IF NOT EXISTS command_journal ( + command_id TEXT PRIMARY KEY, + fingerprint TEXT NOT NULL, + command_type TEXT NOT NULL, + dry_run INTEGER NOT NULL, + state TEXT NOT NULL, + attempt_id TEXT NOT NULL, + ready_deadline_at INTEGER NOT NULL, + execution_deadline_at INTEGER, + result_json TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + is_write INTEGER NOT NULL + ) + `; + this.sql` + CREATE UNIQUE INDEX IF NOT EXISTS command_attempt_id + ON command_journal(attempt_id) + `; + this.sql` + CREATE TABLE IF NOT EXISTS consumed_session_ticket ( + jti_hash TEXT PRIMARY KEY, + expires_at INTEGER NOT NULL + ) + `; + this.sql` + CREATE TABLE IF NOT EXISTS dialog_seen ( + dialog_id TEXT PRIMARY KEY, + occurred_at TEXT NOT NULL + ) + `; + this.sql` + CREATE TABLE IF NOT EXISTS session_flag ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ) + `; } async onConnect(connection: Connection, ctx: ConnectionContext): Promise { - const token = new URL(ctx.request.url).searchParams.get("token") ?? ""; - const res = await verifyExtensionToken(token, this.env); - if (res === null) { + if (this.rejectTerminalConnection(connection)) return; + const url = new URL(ctx.request.url); + if (this.state.mode === "unattended" && this.state.unattended !== undefined) { + const ticket = url.searchParams.get("ticket") ?? ""; + const claims = await verifyWsTicket( + ticket, + { aud: "session", agentName: this.name }, + this.env, + ); + if (this.rejectTerminalConnection(connection)) return; + const unattended = this.state.unattended; + if ( + claims === null || + claims.sessionId !== this.name || + claims.tenantId !== unattended.tenantId || + claims.deviceId !== unattended.deviceId || + claims.leaseId !== unattended.leaseId || + claims.leaseEpoch !== unattended.leaseEpoch || + claims.browserEpoch !== unattended.browserEpoch + ) { + connection.close(1008, "invalid or replayed session ticket"); + return; + } + const consumed = await this.consumeSessionTicket(claims); + if (this.rejectTerminalConnection(connection)) return; + if (!consumed) { + connection.close(1008, "invalid or replayed session ticket"); + return; + } + this.makeConnectionAuthoritative(connection, claims); + return; + } + + const token = url.searchParams.get("token") ?? ""; + const verified = await verifyExtensionToken(token, this.env); + if (this.rejectTerminalConnection(connection)) return; + if (verified === null) { connection.close(1008, "invalid extension token"); return; } - const scope = await scopeSession(this.name, res.tenantId, this.env); + const scope = await scopeSession(this.name, verified.tenantId, this.env); + if (this.rejectTerminalConnection(connection)) return; if (scope !== "ok") { connection.close(1008, "tenant mismatch"); return; @@ -72,8 +239,14 @@ export class SessionAgent extends Agent { this.makeConnectionAuthoritative(connection); } - private makeConnectionAuthoritative(connection: Connection): void { - connection.setState({ authorized: true }); + private makeConnectionAuthoritative( + connection: Connection, + ticket?: WsTicketClaims, + ): void { + connection.setState({ + authorized: true, + ...(ticket === undefined ? {} : { ticket }), + } satisfies AuthorizedConnectionState); this.setState({ ...this.state, activeConnectionId: connection.id, @@ -84,7 +257,7 @@ export class SessionAgent extends Agent { if (previous.id === connection.id || !this.isAuthorizedConnection(previous)) continue; previous.setState({ authorized: false }); try { - previous.close(4001, "replaced by newer extension connection"); + previous.close(WS_CLOSE_REPLACED, "replaced by newer extension connection"); } catch { // Authority already moved and the predecessor is demoted. A socket // that raced to CLOSED must not make the successful replacement's @@ -122,18 +295,50 @@ export class SessionAgent extends Agent { } async onMessage(connection: Connection, message: WSMessage): Promise { + if (this.rejectTerminalConnection(connection)) return; if (!this.isAuthoritativeConnection(connection)) return; - if (typeof message !== "string") return; + if (typeof message !== "string") { + connection.close(1009, "binary session frames are not supported"); + return; + } + if (new TextEncoder().encode(message).byteLength > SESSION_RESULT_FRAME_MAX_BYTES) { + connection.close(1009, "session frame too large"); + return; + } let parsed: unknown; try { - parsed = JSON.parse(message); + parsed = JSON.parse(message) as unknown; } catch { + if (this.state.protocolVersion === PROTOCOL_VERSION) { + connection.close(1008, "invalid session frame"); + } + return; + } + + const v2 = safeParseSessionClientFrame(parsed); + if (v2.success) { + await this.handleV2Frame(connection, v2.data); return; } const result = safeParseEvent(parsed); - if (!result.success) return; + if (!result.success) { + const legacyDialog = LegacyDialogEventSchema.safeParse(parsed); + if (this.state.protocolVersion === PROTOCOL_VERSION) { + connection.close(1008, "invalid session frame"); + return; + } + if (!legacyDialog.success) return; + if (this.rememberDialog({ + ...legacyDialog.data, + dialogId: crypto.randomUUID(), + occurredAt: new Date().toISOString(), + })) { + await this.emitSessionTelemetry("dialog", "legacy_recorded"); + } + return; + } const ev = result.data; switch (ev.type) { @@ -145,6 +350,19 @@ export class SessionAgent extends Agent { this.coordinator.resolvePending(ev); return; case "hello": + if (ev.protocolVersion === PROTOCOL_VERSION) { + if ( + ev.tabs.length !== 1 || + ev.capabilities === undefined || + (this.state.mode === "unattended" && + (ev.browserEpoch !== this.state.unattended?.browserEpoch || + ev.leaseId !== this.state.unattended?.leaseId || + ev.leaseEpoch !== this.state.unattended?.leaseEpoch)) + ) { + connection.close(1008, "protocol-v2 hello fence mismatch"); + return; + } + } this.coordinator.abandonInFlight(`${SESSION_RESYNCED}: hello`); this.setState({ ...this.state, @@ -152,13 +370,140 @@ export class SessionAgent extends Agent { tabs: ev.tabs, generation: this.state.generation + 1, status: "connected", + protocolVersion: ev.protocolVersion ?? 1, + capabilities: ev.capabilities ?? [], + ...(this.state.unattended === undefined + ? {} + : { + unattended: { + ...this.state.unattended, + status: "connected" as const, + }, + }), }); + const safeV2 = + ev.protocolVersion === PROTOCOL_VERSION && + (ev.capabilities ?? []).includes("safe-write-v2"); + if (safeV2 && this.writesBlocked()) { + this.trySendSessionFrame({ + type: "writes_blocked", + reason: "session write authority requires reconciliation", + }); + } + for (const resolve of [...this.connectionWaiters]) resolve(safeV2); + this.connectionWaiters.clear(); return; case "page_event": this.setState({ ...this.state, currentUrl: ev.url }); return; case "dialog": - this.rememberDialog(ev); + if (this.rememberDialog(ev)) { + await this.emitSessionTelemetry("dialog", "recorded"); + } + if ("dialogId" in ev) { + this.trySendSessionFrame({ type: "dialog_ack", dialogId: ev.dialogId }); + } + return; + } + } + + private async handleV2Frame( + connection: Connection, + frame: SessionClientFrame, + ): Promise { + switch (frame.type) { + case "write_ready": { + const row = this.commandByAttempt(frame.attemptId); + if ( + row === undefined || + row.command_id !== frame.commandId || + row.fingerprint !== frame.requestFingerprint || + row.state !== "preparing" || + row.ready_deadline_at <= Date.now() || + !this.frameMatchesCurrentLease(frame) + ) { + connection.send( + JSON.stringify({ + type: "attempt_cancel", + attemptId: frame.attemptId, + commandId: frame.commandId, + } satisfies SessionServerFrame), + ); + return; + } + this.sql` + UPDATE command_journal SET state = 'ready', updated_at = ${Date.now()} + WHERE attempt_id = ${frame.attemptId} AND state = 'preparing' + AND ready_deadline_at > ${Date.now()} + `; + this.notifyAttempt(frame.attemptId); + return; + } + case "command_result": { + const row = this.commandByAttempt(frame.attemptId); + const now = Date.now(); + if ( + row !== undefined && + row.command_id === frame.commandId && + row.state === "granted" && + row.execution_deadline_at !== null && + row.execution_deadline_at > now && + this.frameMatchesCurrentLease(frame) + ) { + const event = + row.dry_run === 1 && row.is_write === 1 && frame.event.type === "action_result" + ? { ...frame.event, simulated: true } + : frame.event; + this.sql` + UPDATE command_journal + SET state = 'completed', result_json = ${JSON.stringify(event)}, + updated_at = ${now} + WHERE attempt_id = ${frame.attemptId} AND state = 'granted' + AND execution_deadline_at > ${now} + `; + this.notifyAttempt(frame.attemptId); + } else if ( + row?.state === "granted" && + row.execution_deadline_at !== null && + row.execution_deadline_at <= now + ) { + await this.expireAttempt({ attemptId: frame.attemptId }); + } + connection.send( + JSON.stringify({ + type: "result_ack", + attemptId: frame.attemptId, + commandId: frame.commandId, + } satisfies SessionServerFrame), + ); + return; + } + case "dialog": + if (this.rememberDialog(frame)) { + await this.emitSessionTelemetry("dialog", "recorded"); + } else { + await this.emitSessionTelemetry("dialog", "deduplicated"); + } + connection.send( + JSON.stringify({ type: "dialog_ack", dialogId: frame.dialogId } satisfies SessionServerFrame), + ); + return; + case "health": + if (this.state.unattended !== undefined) { + this.setState({ + ...this.state, + unattended: { + ...this.state.unattended, + dialogDelivery: + this.state.unattended.dialogDelivery === "overflow" + ? "overflow" + : frame.dialogDelivery, + }, + }); + await this.tenantCoordinator().setDialogDelivery(this.name, frame.dialogDelivery); + } + return; + case "pong": return; } } @@ -177,31 +522,52 @@ export class SessionAgent extends Agent { return; } + const unattendedBeforeClose = this.state.unattended; this.setState({ ...this.state, activeConnectionId: null, status: "detached", + ...(unattendedBeforeClose === undefined || + unattendedBeforeClose.status !== "connected" + ? {} + : { + unattended: { + ...unattendedBeforeClose, + status: "recovering" as const, + needsReconciliation: true, + }, + }), }); + if (unattendedBeforeClose?.status === "connected") { + await this.tenantCoordinator().markRecovering({ + sessionId: this.name, + leaseId: unattendedBeforeClose.leaseId, + leaseEpoch: unattendedBeforeClose.leaseEpoch, + browserEpoch: unattendedBeforeClose.browserEpoch, + }); + } } async dispatch(command: Command, dryRun?: boolean): Promise { try { + if (this.isTerminalSession()) return this.terminalDispatchOutcome(); + const fingerprint = await requestFingerprint(command, dryRun === true); + if (this.isTerminalSession()) return this.terminalDispatchOutcome(); + const tombstone = legacyTombstone(command, fingerprint); + + const replay = this.legacyReplay(tombstone); + if (replay.kind === "conflict") return this.idConflictDispatchOutcome(); + if (replay.kind === "replay") return { ok: true, event: replay.event }; + if (dryRun === true && isWriteCommand(command)) { const probe = await this.checkRefResolves(this.commandRef(command)); + if (this.isTerminalSession()) return this.terminalDispatchOutcome(); return { ok: true, event: this.simulatedResult(command.commandId, probe) }; } - // Real dispatch (a dry-run READ also lands here: it executes for real). - // A write whose Event was already recorded replays it instead of - // executing twice - the consumer retries under the same commandId when - // its previous attempt's response was lost or unparseable. The - // completedWrite helpers no-op for reads (incl. a dry-run read), so no - // dryRun guard is needed here: a dry-run write already returned above. - const replayed = this.completedWriteEvent(command); - if (replayed !== undefined) return { ok: true, event: replayed }; - - const event = await this.coordinator.send(command); - this.rememberCompletedWrite(command, event); + const event = await this.coordinator.send(command, tombstone); + if (this.isTerminalSession()) return this.terminalDispatchOutcome(); + this.rememberCompletedWrite(tombstone, event); return { ok: true, event }; } catch (err) { return this.dispatchFailure(err); @@ -210,6 +576,14 @@ export class SessionAgent extends Agent { async fillSecret(cmd: FillSecretCommand, dryRun?: boolean): Promise { try { + if (this.isTerminalSession()) return this.terminalDispatchOutcome(); + const fingerprint = await requestFingerprint(cmd, dryRun === true); + if (this.isTerminalSession()) return this.terminalDispatchOutcome(); + const tombstone = legacyTombstone(cmd, fingerprint); + const replay = this.legacyReplay(tombstone); + if (replay.kind === "conflict") return this.idConflictDispatchOutcome(); + if (replay.kind === "replay") return { ok: true, event: replay.event }; + if (dryRun === true) { // A dry-run the real call would refuse for tenant scoping simulates // that refusal (before the DOM ref probe), so a governance pre-approval @@ -217,6 +591,7 @@ export class SessionAgent extends Agent { // never dispatch. Still zero vault access and no wire traffic: // secretRefInTenant only reads the signed sessionId (this.name). if (!(await this.secretRefInTenant(cmd.secretRef))) { + if (this.isTerminalSession()) return this.terminalDispatchOutcome(); return { ok: true, event: this.simulatedResult(cmd.commandId, { @@ -225,14 +600,19 @@ export class SessionAgent extends Agent { }), }; } + if (this.isTerminalSession()) return this.terminalDispatchOutcome(); + const probe = await this.checkRefResolves(cmd.ref); + if (this.isTerminalSession()) return this.terminalDispatchOutcome(); return { ok: true, - event: this.simulatedResult(cmd.commandId, await this.checkRefResolves(cmd.ref)), + event: this.simulatedResult(cmd.commandId, probe), }; } - // Tenant scoping FIRST, before replay/gate/vault: a secretRef resolves - // only within this session's OWN tenant, derived from the HMAC-signed + // Exact replay/conflict binding runs before external work. For a new + // request, tenant scoping precedes the connection gate and vault: a + // secretRef resolves only within this session's OWN tenant, derived from + // the HMAC-signed // sessionId (this.name) - never a caller claim - so tenantB driving its // own session can never read vault://tenantA/... understudy owns one // shared vault across tenants, so this check lives here, not in a @@ -241,13 +621,10 @@ export class SessionAgent extends Agent { // dispatch, and no oracle telling "not yours" from "does not exist" // (DL-008). if (!(await this.secretRefInTenant(cmd.secretRef))) { + if (this.isTerminalSession()) return this.terminalDispatchOutcome(); return { ok: true, event: this.unresolvableSecretResult(cmd.commandId) }; } - - // Replay BEFORE the connection gate and the vault: a retry of an - // already-performed fill needs neither liveness nor plaintext. - const replayed = this.completedWriteEvent(cmd); - if (replayed !== undefined) return { ok: true, event: replayed }; + if (this.isTerminalSession()) return this.terminalDispatchOutcome(); // Gate BEFORE the vault: resolving a secret for a command that cannot // dispatch would materialize plaintext (and emit a vault access) for @@ -264,23 +641,1024 @@ export class SessionAgent extends Agent { try { secret = await resolveSecret(createVault(this.env), cmd.secretRef); } catch { + if (this.isTerminalSession()) return this.terminalDispatchOutcome(); return { ok: true, event: this.unresolvableSecretResult(cmd.commandId) }; } + if (this.isTerminalSession()) return this.terminalDispatchOutcome(); - const event = await this.coordinator.send({ - type: "type", - commandId: cmd.commandId, - ref: cmd.ref, - text: secret, - submit: cmd.submit, - }); - this.rememberCompletedWrite(cmd, event); + const event = await this.coordinator.send( + { + type: "type", + commandId: cmd.commandId, + ref: cmd.ref, + text: secret, + submit: cmd.submit, + }, + tombstone, + ); + if (this.isTerminalSession()) return this.terminalDispatchOutcome(); + this.rememberCompletedWrite(tombstone, event); return { ok: true, event }; } catch (err) { return this.dispatchFailure(err); } } + async dispatchV2( + command: Command, + dryRun: boolean, + actorPseudonym: string, + statusUrl: string, + ): Promise { + const startedAt = Date.now(); + if (this.isTerminalSession()) { + return { kind: "terminal_session", commandId: command.commandId }; + } + const fingerprint = await requestFingerprint(command, dryRun); + if (this.isTerminalSession()) { + return { kind: "terminal_session", commandId: command.commandId }; + } + const existing = this.command(command.commandId); + if (existing !== undefined) { + if (existing.fingerprint !== fingerprint) { + return { kind: "id_conflict", commandId: command.commandId }; + } + if (existing.state !== "not_started" && existing.state !== "timed_out") { + return this.outcomeForRow(existing, statusUrl); + } + } + + if (!this.hasAuthorizedConnection()) { + return { kind: "not_connected", commandId: command.commandId }; + } + if ( + isWriteCommand(command) && + !dryRun && + (this.state.protocolVersion !== PROTOCOL_VERSION || + !(this.state.capabilities ?? []).includes("safe-write-v2")) + ) { + return { kind: "unsupported", commandId: command.commandId }; + } + if (isWriteCommand(command) && !dryRun && this.writesBlocked()) { + return { kind: "unknown", commandId: command.commandId, safeToRetry: false }; + } + + const active = this.sql<{ command_id: string }>` + SELECT command_id FROM command_journal + WHERE state IN ('preparing','ready','granted') + AND command_id <> ${command.commandId} + LIMIT 1 + `[0]; + if (active !== undefined) { + return { kind: "busy", commandId: command.commandId }; + } + + const policy = parseQuotaPolicy(this.env.QUOTA_POLICY); + if ( + existing === undefined && + (this.sql<{ count: number }>`SELECT COUNT(*) AS count FROM command_journal`[0]?.count ?? 0) >= + policy.sessionCommandCap + ) { + return { kind: "busy", commandId: command.commandId }; + } + + const attemptId = crypto.randomUUID(); + const readyDeadlineAt = Date.now() + PREPARE_DEADLINE_MS; + if (existing === undefined) { + this.sql` + INSERT INTO command_journal ( + command_id, fingerprint, command_type, dry_run, state, attempt_id, + ready_deadline_at, execution_deadline_at, result_json, created_at, + updated_at, is_write + ) VALUES ( + ${command.commandId}, ${fingerprint}, ${command.type}, ${dryRun ? 1 : 0}, + 'preparing', ${attemptId}, ${readyDeadlineAt}, NULL, NULL, + ${Date.now()}, ${Date.now()}, ${isWriteCommand(command) ? 1 : 0} + ) + `; + } else { + this.sql` + UPDATE command_journal SET + state = 'preparing', attempt_id = ${attemptId}, + ready_deadline_at = ${readyDeadlineAt}, execution_deadline_at = NULL, + result_json = NULL, updated_at = ${Date.now()} + WHERE command_id = ${command.commandId} + AND state IN ('not_started','timed_out') + `; + const claimed = + (this.sql<{ count: number }>`SELECT changes() AS count`[0]?.count ?? 0) === 1; + if (!claimed) { + const raced = this.command(command.commandId); + if (raced === undefined) throw new Error("command retry disappeared"); + return this.outcomeForRow(raced, statusUrl); + } + } + + if (this.state.mode === "unattended") { + const admission = await this.tenantCoordinator().authorizeCommand({ + sessionId: this.name, + actorPseudonym, + credentialFill: command.type === "fill_secret" && !dryRun, + }); + const continuation = this.continuationOutcome( + attemptId, + "preparing", + command.commandId, + statusUrl, + ); + if (continuation !== null) return continuation; + if (!admission.ok) { + this.sql` + UPDATE command_journal SET state = 'not_started', updated_at = ${Date.now()} + WHERE attempt_id = ${attemptId} AND state = 'preparing' + `; + return admission.reason === "terminal" + ? { kind: "terminal_session", commandId: command.commandId } + : { kind: "busy", commandId: command.commandId }; + } + if (this.state.unattended !== undefined) { + this.setState({ + ...this.state, + unattended: { + ...this.state.unattended, + lastActivityAt: new Date().toISOString(), + idleExpiresAt: new Date(admission.idleExpiresAt).toISOString(), + }, + }); + } + } else { + const tenantId = await tenantOf(this.name, this.env); + const scopedContinuation = this.continuationOutcome( + attemptId, + "preparing", + command.commandId, + statusUrl, + ); + if (scopedContinuation !== null) return scopedContinuation; + if (tenantId === null) { + this.markAttempt(attemptId, "not_started"); + return { kind: "terminal_session", commandId: command.commandId }; + } + const admitted = await this.env.TENANT_CONTROL.getByName(tenantId).authorizeAttendedCommand({ + sessionId: this.name, + actorPseudonym, + credentialFill: command.type === "fill_secret" && !dryRun, + }); + const admittedContinuation = this.continuationOutcome( + attemptId, + "preparing", + command.commandId, + statusUrl, + ); + if (admittedContinuation !== null) return admittedContinuation; + if (!admitted) { + this.markAttempt(attemptId, "not_started"); + return { kind: "busy", commandId: command.commandId }; + } + } + + if (dryRun && isWriteCommand(command)) { + if (command.type === "fill_secret") { + const scoped = await this.secretRefInTenant(command.secretRef); + const continuation = this.continuationOutcome( + attemptId, + "preparing", + command.commandId, + statusUrl, + ); + if (continuation !== null) return continuation; + if (!scoped) { + const event = this.simulatedResult(command.commandId, { + ok: false, + reason: "secret could not be resolved", + }); + this.completeAttempt(attemptId, event); + return { kind: "terminal", event }; + } + } + const ref = this.commandRef(command); + if (ref === undefined) { + const event = this.simulatedResult(command.commandId, { ok: true }); + this.completeAttempt(attemptId, event); + return { kind: "terminal", event }; + } + return this.executeReadV2( + { type: "resolve_ref", commandId: command.commandId, ref }, + attemptId, + startedAt, + statusUrl, + ); + } + + if (!isWriteCommand(command)) { + return this.executeReadV2(command, attemptId, startedAt, statusUrl); + } + + await this.schedule( + new Date(readyDeadlineAt), + "expireAttempt", + { attemptId }, + { idempotent: true }, + ); + const prepareContinuation = this.continuationOutcome( + attemptId, + "preparing", + command.commandId, + statusUrl, + ); + if (prepareContinuation !== null) return prepareContinuation; + try { + this.sendSessionFrame({ + type: "write_prepare", + ...this.currentFence(attemptId, readyDeadlineAt), + commandId: command.commandId, + commandType: command.type, + requestFingerprint: fingerprint, + }); + await this.emitSessionTelemetry("command_prepare", "sent", command.type); + const telemetryContinuation = this.continuationOutcome( + attemptId, + "preparing", + command.commandId, + statusUrl, + ); + if (telemetryContinuation !== null) return telemetryContinuation; + } catch { + const won = this.markAttempt(attemptId, "not_started", "preparing"); + if (won) return { kind: "not_started", commandId: command.commandId, safeToRetry: true }; + return this.outcomeForRow(this.commandByAttempt(attemptId)!, statusUrl); + } + + let row = await this.waitForAttempt( + attemptId, + (candidate) => candidate.state !== "preparing", + Math.max(0, readyDeadlineAt - Date.now()), + ); + if (this.isTerminalSession()) { + return { kind: "terminal_session", commandId: command.commandId }; + } + row = this.commandByAttempt(attemptId) ?? row; + if (row.state === "preparing") { + const won = this.markAttempt(attemptId, "not_started", "preparing"); + if (won) { + this.trySendSessionFrame({ + type: "attempt_cancel", + attemptId, + commandId: command.commandId, + }); + return { kind: "not_started", commandId: command.commandId, safeToRetry: true }; + } + row = this.commandByAttempt(attemptId) ?? row; + } + if (row.state !== "ready") return this.outcomeForRow(row, statusUrl); + + let grantedCommand: Command = command; + if (command.type === "fill_secret") { + const scoped = await this.secretRefInTenant(command.secretRef); + const scopedContinuation = this.continuationOutcome( + attemptId, + "ready", + command.commandId, + statusUrl, + ); + if (scopedContinuation !== null) return scopedContinuation; + if (!scoped) { + const event = this.unresolvableSecretResult(command.commandId); + const completed = this.completeAttempt(attemptId, event, "ready"); + this.trySendSessionFrame({ type: "attempt_cancel", attemptId, commandId: command.commandId }); + return completed + ? { kind: "terminal", event } + : this.outcomeForRow(this.commandByAttempt(attemptId)!, statusUrl); + } + const resolution = await resolveBeforeDeadline( + resolveSecret(createVault(this.env), command.secretRef), + readyDeadlineAt, + ); + const vaultContinuation = this.continuationOutcome( + attemptId, + "ready", + command.commandId, + statusUrl, + ); + if (vaultContinuation !== null) return vaultContinuation; + if (resolution.kind === "timeout") { + const won = this.markAttempt(attemptId, "not_started", "ready"); + this.trySendSessionFrame({ type: "attempt_cancel", attemptId, commandId: command.commandId }); + return won + ? { kind: "not_started", commandId: command.commandId, safeToRetry: true } + : this.outcomeForRow(this.commandByAttempt(attemptId)!, statusUrl); + } + if (resolution.kind === "error") { + const event = this.unresolvableSecretResult(command.commandId); + const completed = this.completeAttempt(attemptId, event, "ready"); + this.trySendSessionFrame({ type: "attempt_cancel", attemptId, commandId: command.commandId }); + return completed + ? { kind: "terminal", event } + : this.outcomeForRow(this.commandByAttempt(attemptId)!, statusUrl); + } + const secret = resolution.value; + grantedCommand = { + type: "type", + commandId: command.commandId, + ref: command.ref, + text: secret, + submit: command.submit, + }; + } + + const executionDeadlineAt = Date.now() + EXECUTION_DEADLINE_MS; + this.sql` + UPDATE command_journal + SET state = 'granted', execution_deadline_at = ${executionDeadlineAt}, + updated_at = ${Date.now()} + WHERE attempt_id = ${attemptId} AND state = 'ready' + AND ready_deadline_at > ${Date.now()} + `; + row = this.commandByAttempt(attemptId) ?? row; + if (row.state !== "granted") { + const won = + row.state === "ready" && + this.markAttempt(attemptId, "not_started", "ready"); + if (won) { + return { kind: "not_started", commandId: command.commandId, safeToRetry: true }; + } + return this.outcomeForRow(this.commandByAttempt(attemptId) ?? row, statusUrl); + } + await this.emitSessionTelemetry("command_grant", "persisted", command.type); + const grantTelemetryContinuation = this.continuationOutcome( + attemptId, + "granted", + command.commandId, + statusUrl, + ); + if (grantTelemetryContinuation !== null) { + return grantTelemetryContinuation; + } + await this.schedule( + new Date(executionDeadlineAt), + "expireAttempt", + { attemptId }, + { idempotent: true }, + ); + const grantContinuation = this.continuationOutcome( + attemptId, + "granted", + command.commandId, + statusUrl, + ); + if (grantContinuation !== null) return grantContinuation; + try { + this.sendSessionFrame({ + type: "write_grant", + ...this.currentFence(attemptId, executionDeadlineAt), + command: grantedCommand, + }); + } catch { + return this.pendingOutcome(command.commandId, statusUrl); + } + return this.awaitSynchronousOutcome( + attemptId, + command.commandId, + startedAt, + statusUrl, + ); + } + + async getCommandStatus(commandId: string): Promise { + const row = this.command(commandId); + if (row === undefined) return null; + return { + commandId, + status: row.state, + ...(row.result_json === null ? {} : { event: JSON.parse(row.result_json) as Event }), + safeToRetry: row.state === "not_started" || row.state === "timed_out", + }; + } + + async expireAttempt(payload: { attemptId: string }): Promise { + const row = this.commandByAttempt(payload.attemptId); + if (row === undefined) return; + const now = Date.now(); + if ( + (row.state === "preparing" || row.state === "ready") && + row.ready_deadline_at <= now + ) { + this.markAttempt(payload.attemptId, "not_started", row.state); + this.notifyAttempt(payload.attemptId); + return; + } + if ( + row.state === "granted" && + row.execution_deadline_at !== null && + row.execution_deadline_at <= now + ) { + const terminal: CommandState = + row.is_write === 1 && row.dry_run === 0 ? "unknown" : "timed_out"; + this.markAttempt(payload.attemptId, terminal, "granted"); + if (terminal === "unknown") { + this.sql` + INSERT INTO session_flag (key, value) VALUES ('writes_blocked', '1') + ON CONFLICT(key) DO UPDATE SET value = '1' + `; + try { + this.sendSessionFrame({ + type: "writes_blocked", + reason: "a granted write reached its execution deadline without a result", + }); + } catch { + // The durable unknown tombstone is authoritative while disconnected. + } + await this.emitSessionTelemetry("command_unknown", "deadline", row.command_type); + } + this.notifyAttempt(payload.attemptId); + } + } + + async initializeUnattended( + tenantId: string, + lease: LeaseResource, + ): Promise { + this.setState({ + ...this.state, + mode: "unattended", + status: "pending", + browser: null, + tabs: [], + currentUrl: null, + activeConnectionId: null, + protocolVersion: 2, + capabilities: [], + unattended: { + tenantId, + deviceId: lease.deviceId, + leaseId: lease.leaseId, + leaseEpoch: lease.leaseEpoch, + browserEpoch: lease.browserEpoch, + status: lease.status, + createdAt: new Date(lease.createdAt).toISOString(), + lastActivityAt: new Date(lease.lastActivityAt).toISOString(), + idleExpiresAt: new Date(lease.idleExpiresAt).toISOString(), + hardExpiresAt: new Date(lease.hardExpiresAt).toISOString(), + needsReconciliation: lease.needsReconciliation, + dialogDelivery: lease.dialogDelivery, + allowedOrigins: lease.allowedOrigins, + }, + }); + } + + async beginRecovery(lease: LeaseResource): Promise { + const unattended = this.state.unattended; + if ( + unattended === undefined || + lease.sessionId !== this.name || + lease.leaseId !== unattended.leaseId || + lease.leaseEpoch !== unattended.leaseEpoch || + lease.deviceId !== unattended.deviceId + ) { + return; + } + const epochChanged = lease.browserEpoch !== unattended.browserEpoch; + this.terminalizeGrantedAttempts(); + if (epochChanged) { + this.sql` + INSERT INTO session_flag (key, value) VALUES ('writes_blocked', '1') + ON CONFLICT(key) DO UPDATE SET value = '1' + `; + } + this.fenceConnections(epochChanged ? "browser epoch changed" : "session reconnecting"); + this.setState({ + ...this.state, + activeConnectionId: null, + status: "detached", + browser: epochChanged ? null : this.state.browser, + tabs: epochChanged ? [] : this.state.tabs, + currentUrl: epochChanged ? null : this.state.currentUrl, + unattended: { + ...unattended, + browserEpoch: lease.browserEpoch, + status: "recovering", + needsReconciliation: true, + dialogDelivery: + epochChanged && unattended.dialogDelivery !== "overflow" + ? "interrupted" + : unattended.dialogDelivery, + }, + }); + } + + async needsSessionTicket(): Promise { + return ( + this.state.mode === "unattended" && + !this.hasAuthorizedConnection() && + !this.isTerminalSession() + ); + } + + async revokeDevice(): Promise { + if (this.state.unattended === undefined) return; + this.terminalizeActiveAttempts(); + this.fenceConnections("device credential revoked"); + this.setState({ + ...this.state, + activeConnectionId: null, + status: "detached", + unattended: { + ...this.state.unattended, + status: "lost", + needsReconciliation: true, + }, + }); + } + + async markProvisioned(tab: TabInfo, browserEpoch: string): Promise { + const unattended = this.state.unattended; + if (unattended === undefined || unattended.browserEpoch !== browserEpoch) return; + this.setState({ + ...this.state, + tabs: [tab], + currentUrl: tab.url === "about:blank" ? null : tab.url, + unattended: { + ...unattended, + status: "provisioning", + }, + }); + } + + async markLifecycle( + status: UnattendedSessionLifecycle, + needsReconciliation: boolean, + ): Promise { + if (this.state.unattended === undefined) return; + if (status === "closing" || isTerminalLifecycle(status)) { + this.terminalizeActiveAttempts(); + } + this.setState({ + ...this.state, + activeConnectionId: isTerminalLifecycle(status) ? null : this.state.activeConnectionId, + status: isTerminalLifecycle(status) ? "detached" : this.state.status, + unattended: { + ...this.state.unattended, + status, + needsReconciliation, + }, + }); + } + + async requestCloseAttended(): Promise { + if (this.state.mode === "unattended") return false; + this.sql` + INSERT INTO session_flag (key, value) VALUES ('closed', '1') + ON CONFLICT(key) DO UPDATE SET value = '1' + `; + this.terminalizeActiveAttempts(); + this.coordinator.abandonInFlight(`${SESSION_TERMINAL}: session deleted`); + const connection = this.authoritativeConnection(); + if (connection !== undefined) { + try { + connection.send( + JSON.stringify({ + type: "close_session", + closeTab: false, + } satisfies SessionServerFrame), + ); + } catch { + // The terminal flag remains authoritative. + } + connection.setState(null); + } + this.setState({ + ...this.state, + status: "detached", + activeConnectionId: null, + }); + if (connection !== undefined) { + try { + connection.close( + WS_CLOSE_SESSION_TERMINAL, + "session deleted", + ); + } catch { + // The connection was already closed after authority was retired. + } + } + for (const resolve of [...this.connectionWaiters]) resolve(false); + this.connectionWaiters.clear(); + return true; + } + + async isTerminal(): Promise { + return this.isTerminalSession(); + } + + async waitForProtocolV2Connection(timeoutMs: number): Promise { + if (this.isTerminalSession()) return false; + if ( + this.state.status === "connected" && + this.state.protocolVersion === PROTOCOL_VERSION && + (this.state.capabilities ?? []).includes("safe-write-v2") + ) { + return true; + } + return new Promise((resolve) => { + const finish = (connected: boolean) => { + clearTimeout(timer); + this.connectionWaiters.delete(finish); + resolve(connected); + }; + const timer = setTimeout(() => finish(false), timeoutMs); + this.connectionWaiters.add(finish); + }); + } + + async usesV2CommandProtocol(): Promise { + return ( + this.state.mode === "unattended" || + this.state.protocolVersion === PROTOCOL_VERSION + ); + } + + private async executeReadV2( + command: Command, + attemptId: string, + startedAt: number, + statusUrl: string, + ): Promise { + const preparing = this.continuationOutcome( + attemptId, + "preparing", + command.commandId, + statusUrl, + ); + if (preparing !== null) return preparing; + const executionDeadlineAt = Date.now() + EXECUTION_DEADLINE_MS; + this.sql` + UPDATE command_journal + SET state = 'granted', execution_deadline_at = ${executionDeadlineAt}, + updated_at = ${Date.now()} + WHERE attempt_id = ${attemptId} AND state = 'preparing' + `; + await this.schedule( + new Date(executionDeadlineAt), + "expireAttempt", + { attemptId }, + { idempotent: true }, + ); + const continuation = this.continuationOutcome( + attemptId, + "granted", + command.commandId, + statusUrl, + ); + if (continuation !== null) return continuation; + try { + this.sendSessionFrame({ + type: "command", + ...this.currentFence(attemptId, executionDeadlineAt), + command, + }); + } catch { + this.markAttempt(attemptId, "timed_out", "granted"); + return { kind: "not_connected", commandId: command.commandId }; + } + return this.awaitSynchronousOutcome(attemptId, command.commandId, startedAt, statusUrl); + } + + private async awaitSynchronousOutcome( + attemptId: string, + commandId: string, + startedAt: number, + statusUrl: string, + ): Promise { + const remaining = Math.max(0, SYNCHRONOUS_WAIT_MS - (Date.now() - startedAt)); + const row = await this.waitForAttempt( + attemptId, + (candidate) => + candidate.state === "completed" || + candidate.state === "not_started" || + candidate.state === "timed_out" || + candidate.state === "unknown", + remaining, + ); + if (this.isTerminalSession()) { + return { kind: "terminal_session", commandId }; + } + const current = this.commandByAttempt(attemptId) ?? row; + if ( + current.state === "granted" || + current.state === "ready" || + current.state === "preparing" + ) { + return this.pendingOutcome(commandId, statusUrl); + } + return this.outcomeForRow(current, statusUrl); + } + + private outcomeForRow(row: CommandRow, statusUrl: string): V2DispatchOutcome { + switch (row.state) { + case "completed": { + if (row.result_json === null) throw new Error("completed command is missing its result"); + return { kind: "terminal", event: JSON.parse(row.result_json) as Event }; + } + case "not_started": + return { kind: "not_started", commandId: row.command_id, safeToRetry: true }; + case "timed_out": + return { kind: "timed_out", commandId: row.command_id, safeToRetry: true }; + case "unknown": + return { kind: "unknown", commandId: row.command_id, safeToRetry: false }; + case "preparing": + case "ready": + case "granted": + return this.pendingOutcome(row.command_id, statusUrl); + } + } + + private continuationOutcome( + attemptId: string, + expected: CommandState, + commandId: string, + statusUrl: string, + ): V2DispatchOutcome | null { + if (this.isTerminalSession()) { + return { kind: "terminal_session", commandId }; + } + const row = this.commandByAttempt(attemptId); + if (row === undefined) throw new Error("command attempt disappeared"); + return row.state === expected ? null : this.outcomeForRow(row, statusUrl); + } + + private pendingOutcome(commandId: string, statusUrl: string): V2DispatchOutcome { + return { + kind: "pending", + pending: { + commandId, + status: "pending", + statusUrl, + retryPolicy: "poll_same_command", + }, + }; + } + + private command(commandId: string): CommandRow | undefined { + return this.sql` + SELECT * FROM command_journal WHERE command_id = ${commandId} + `[0]; + } + + private commandByAttempt(attemptId: string): CommandRow | undefined { + return this.sql` + SELECT * FROM command_journal WHERE attempt_id = ${attemptId} + `[0]; + } + + private markAttempt( + attemptId: string, + next: CommandState, + expected?: CommandState, + ): boolean { + const before = this.commandByAttempt(attemptId); + if (before === undefined || (expected !== undefined && before.state !== expected)) return false; + this.sql` + UPDATE command_journal SET state = ${next}, updated_at = ${Date.now()} + WHERE attempt_id = ${attemptId} AND state = ${before.state} + `; + const changed = this.sql<{ count: number }>`SELECT changes() AS count`[0]?.count ?? 0; + if (changed === 1) this.notifyAttempt(attemptId); + return changed === 1; + } + + private completeAttempt( + attemptId: string, + event: Event, + expected: CommandState = "preparing", + ): boolean { + this.sql` + UPDATE command_journal + SET state = 'completed', result_json = ${JSON.stringify(event)}, + updated_at = ${Date.now()} + WHERE attempt_id = ${attemptId} AND state = ${expected} + `; + const changed = this.sql<{ count: number }>`SELECT changes() AS count`[0]?.count ?? 0; + if (changed === 1) this.notifyAttempt(attemptId); + return changed === 1; + } + + private waitForAttempt( + attemptId: string, + predicate: (row: CommandRow) => boolean, + timeoutMs: number, + ): Promise { + const current = this.commandByAttempt(attemptId); + if (current === undefined) return Promise.reject(new Error("command attempt disappeared")); + if (predicate(current) || timeoutMs <= 0) return Promise.resolve(current); + return new Promise((resolve, reject) => { + let timer: ReturnType; + const wake = () => { + const row = this.commandByAttempt(attemptId); + if (row === undefined) { + clearTimeout(timer); + this.removeWaiter(attemptId, wake); + reject(new Error("command attempt disappeared")); + return; + } + if (!predicate(row)) return; + clearTimeout(timer); + this.removeWaiter(attemptId, wake); + resolve(row); + }; + const waiters = this.stateWaiters.get(attemptId) ?? new Set(); + waiters.add(wake); + this.stateWaiters.set(attemptId, waiters); + timer = setTimeout(() => { + this.removeWaiter(attemptId, wake); + const row = this.commandByAttempt(attemptId); + if (row === undefined) reject(new Error("command attempt disappeared")); + else resolve(row); + }, timeoutMs); + }); + } + + private notifyAttempt(attemptId: string): void { + for (const wake of [...(this.stateWaiters.get(attemptId) ?? [])]) wake(); + } + + private removeWaiter(attemptId: string, wake: () => void): void { + const waiters = this.stateWaiters.get(attemptId); + if (waiters === undefined) return; + waiters.delete(wake); + if (waiters.size === 0) this.stateWaiters.delete(attemptId); + } + + private currentFence( + attemptId: string, + deadlineAt: number, + ): Omit, "type" | "command"> { + const unattended = this.state.unattended; + return { + attemptId, + deadlineAt: new Date(deadlineAt).toISOString(), + ...(unattended === undefined + ? {} + : { + leaseId: unattended.leaseId, + leaseEpoch: unattended.leaseEpoch, + browserEpoch: unattended.browserEpoch, + }), + }; + } + + private frameMatchesCurrentLease(frame: { + leaseId?: string; + leaseEpoch?: number; + browserEpoch?: string; + }): boolean { + const unattended = this.state.unattended; + if (unattended === undefined) { + return ( + frame.leaseId === undefined && + frame.leaseEpoch === undefined && + frame.browserEpoch === undefined + ); + } + return ( + frame.leaseId === unattended.leaseId && + frame.leaseEpoch === unattended.leaseEpoch && + frame.browserEpoch === unattended.browserEpoch + ); + } + + private sendSessionFrame(frame: SessionServerFrame): void { + if (this.isTerminalSession()) { + throw new Error(`${SESSION_TERMINAL}: session deleted`); + } + const connection = this.authoritativeConnection(); + if (connection === undefined) throw new Error("session connection unavailable"); + connection.send(JSON.stringify(frame)); + } + + private trySendSessionFrame(frame: SessionServerFrame): boolean { + try { + this.sendSessionFrame(frame); + return true; + } catch { + return false; + } + } + + private writesBlocked(): boolean { + return ( + this.sql<{ value: string }>` + SELECT value FROM session_flag WHERE key = 'writes_blocked' + `[0]?.value === "1" + ); + } + + private isTerminalSession(): boolean { + if ( + this.sql<{ value: string }>` + SELECT value FROM session_flag WHERE key = 'closed' + `[0]?.value === "1" + ) { + return true; + } + const status = this.state.unattended?.status; + return ( + status === "closing" || + status === "closed" || + status === "expired" || + status === "lost" + ); + } + + private rejectTerminalConnection(connection: Connection): boolean { + if (!this.isTerminalSession()) return false; + connection.setState(null); + try { + connection.close(WS_CLOSE_SESSION_TERMINAL, "session deleted"); + } catch { + // The durable terminal flag remains authoritative. + } + return true; + } + + private terminalizeActiveAttempts(): void { + const active = this.sql<{ + attempt_id: string; + state: "preparing" | "ready" | "granted"; + is_write: number; + dry_run: number; + }>` + SELECT attempt_id, state, is_write, dry_run + FROM command_journal + WHERE state IN ('preparing','ready','granted') + `; + let blocked = false; + for (const row of active) { + const terminal: CommandState = + row.state === "granted" + ? row.is_write === 1 && row.dry_run === 0 + ? "unknown" + : "timed_out" + : "not_started"; + if (terminal === "unknown") blocked = true; + this.markAttempt(row.attempt_id, terminal, row.state); + } + if (blocked) { + this.sql` + INSERT INTO session_flag (key, value) VALUES ('writes_blocked', '1') + ON CONFLICT(key) DO UPDATE SET value = '1' + `; + } + } + + private terminalizeGrantedAttempts(): void { + const granted = this.sql<{ attempt_id: string; is_write: number; dry_run: number }>` + SELECT attempt_id, is_write, dry_run FROM command_journal WHERE state = 'granted' + `; + let blocked = false; + for (const row of granted) { + const terminal = + row.is_write === 1 && row.dry_run === 0 ? "unknown" : "timed_out"; + if (terminal === "unknown") blocked = true; + this.markAttempt(row.attempt_id, terminal, "granted"); + } + if (blocked) { + this.sql` + INSERT INTO session_flag (key, value) VALUES ('writes_blocked', '1') + ON CONFLICT(key) DO UPDATE SET value = '1' + `; + } + } + + private fenceConnections(reason: string): void { + for (const connection of this.getConnections()) { + connection.setState(null); + try { + connection.close(4002, reason); + } catch { + // Persisted lifecycle and epochs are already authoritative. + } + } + } + + private tenantCoordinator(): DurableObjectStub { + const tenantId = this.state.unattended?.tenantId; + if (tenantId === undefined) throw new Error("unattended tenant is missing"); + return this.env.TENANT_CONTROL.getByName(tenantId); + } + + private async consumeSessionTicket(claims: WsTicketClaims): Promise { + const jtiHash = await sha256Hex(claims.jti); + this.sql` + DELETE FROM consumed_session_ticket + WHERE expires_at <= ${Math.floor(Date.now() / 1000)} + `; + this.sql` + INSERT OR IGNORE INTO consumed_session_ticket (jti_hash, expires_at) + VALUES (${jtiHash}, ${claims.exp}) + `; + return (this.sql<{ count: number }>`SELECT changes() AS count`[0]?.count ?? 0) === 1; + } + /** * Whether `secretRef` lives in this session's own tenant namespace. The * tenant is the one HMAC-signed into the sessionId (this.name) - the same @@ -333,40 +1711,258 @@ export class SessionAgent extends Agent { if (message.startsWith(DUPLICATE_COMMAND)) { return { ok: false, reason: "duplicate_in_flight", message }; } + if (message.startsWith(SESSION_BUSY)) { + return { ok: false, reason: "session_busy", message }; + } + if (message.startsWith(SESSION_TERMINAL)) { + return this.terminalDispatchOutcome(message); + } throw err; } - /** The recorded Event for an already-completed write commandId, if any. */ - private completedWriteEvent(command: Command): Event | undefined { - if (!isWriteCommand(command)) return undefined; - return this.completedWrites().find((entry) => entry.commandId === command.commandId)?.event; + private terminalDispatchOutcome( + message = `${SESSION_TERMINAL}: session deleted`, + ): DispatchOutcome { + return { ok: false, reason: "terminal_session", message }; + } + + private idConflictDispatchOutcome(): DispatchOutcome { + return { + ok: false, + reason: "id_conflict", + message: "command id was already used for a different request", + }; + } + + private legacyReplay(tombstone: LegacyCommandTombstone): + | { kind: "none" } + | { kind: "conflict" } + | { + kind: "replay"; + event: Extract; + } { + const awaiting = this.awaitingLegacyCommands().find( + (entry) => entry.commandId === tombstone.commandId, + ); + if (awaiting !== undefined) { + if (!isLegacyCommandTombstone(awaiting)) return { kind: "conflict" }; + return sameLegacyCommand(awaiting, tombstone) + ? { kind: "none" } + : { kind: "conflict" }; + } + + const completed = this.completedWrites().find( + (entry) => entry.commandId === tombstone.commandId, + ); + if (completed !== undefined) { + if (!isCompletedLegacyWrite(completed)) return { kind: "conflict" }; + return sameLegacyCommand(completed, tombstone) + ? { kind: "replay", event: completed.event } + : { kind: "conflict" }; + } + + const sent = this.legacyCommandTombstones().find( + (entry) => entry.commandId === tombstone.commandId, + ); + return sent === undefined ? { kind: "none" } : { kind: "conflict" }; } - private rememberCompletedWrite(command: Command, event: Event): void { - if (!isWriteCommand(command)) return; + private rememberCompletedWrite( + tombstone: LegacyCommandTombstone, + event: Event, + ): void { + if ( + !isWriteCommandType(tombstone.commandType) || + event.type !== "action_result" || + event.commandId !== tombstone.commandId || + utf8ByteLength(JSON.stringify(event)) > COMPLETED_WRITE_EVENT_MAX_BYTES + ) { + return; + } const next = [ - ...this.completedWrites().filter((entry) => entry.commandId !== command.commandId), - { commandId: command.commandId, event }, + ...this.completedWrites().filter( + (entry) => entry.commandId !== tombstone.commandId, + ), + { ...tombstone, event }, ]; while (next.length > COMPLETED_WRITES_CAP) next.shift(); this.setState({ ...this.state, completedWrites: next }); } + private rememberLegacyLateResult( + tombstone: PersistedLegacyAwaiting, + event: Event, + ): void { + this.rememberLegacyCommandTombstone( + isLegacyCommandTombstone(tombstone) + ? tombstone + : { commandId: tombstone.commandId }, + ); + if ( + !isLegacyCommandTombstone(tombstone) || + !isWriteCommandType(tombstone.commandType) || + event.type !== "action_result" || + event.commandId !== tombstone.commandId || + utf8ByteLength(JSON.stringify(event)) > COMPLETED_WRITE_EVENT_MAX_BYTES + ) { + return; + } + this.rememberCompletedWrite(tombstone, event); + } + + private rememberLegacyCommandTombstone( + tombstone: PersistedLegacyCommandTombstone, + ): void { + if ( + isLegacyCommandTombstone(tombstone) && + !isWriteCommandType(tombstone.commandType) + ) { + return; + } + const next = [ + ...this.legacyCommandTombstones().filter( + (entry) => entry.commandId !== tombstone.commandId, + ), + tombstone, + ]; + while (next.length > COMPLETED_WRITES_CAP) next.shift(); + this.setState({ ...this.state, legacyCommandTombstones: next }); + } + + private awaitingLegacyCommands(): PersistedLegacyAwaiting[] { + const typed = this.state.awaitingCommands; + if (Array.isArray(typed) && typed.length > 0) { + return typed.filter(isPersistedLegacyAwaiting); + } + return (this.state.awaitingCommandIds ?? []).map((commandId) => ({ + commandId, + })); + } + + private legacyCommandTombstones(): PersistedLegacyCommandTombstone[] { + this.sanitizeLegacyReplayState(); + return (this.state.legacyCommandTombstones ?? []).filter( + isPersistedLegacyAwaiting, + ); + } + // Persisted before this field existed, a session's state can lack it; // initialState only seeds brand-new DOs. - private completedWrites(): SessionState["completedWrites"] { - return this.state.completedWrites ?? []; + private completedWrites(): CompletedLegacyWrite[] { + this.sanitizeLegacyReplayState(); + return (this.state.completedWrites ?? []).filter(isCompletedLegacyWrite); + } + + private sanitizeLegacyReplayState(): void { + const persistedCompleted = Array.isArray(this.state.completedWrites) + ? this.state.completedWrites + : []; + const persistedTombstones = Array.isArray( + this.state.legacyCommandTombstones, + ) + ? this.state.legacyCommandTombstones + : []; + const completed: CompletedLegacyWrite[] = []; + const tombstones: PersistedLegacyCommandTombstone[] = []; + let changed = + persistedCompleted !== this.state.completedWrites || + persistedTombstones !== this.state.legacyCommandTombstones; + + for (const entry of persistedTombstones) { + if (!isPersistedLegacyAwaiting(entry)) { + changed = true; + continue; + } + const existingIndex = tombstones.findIndex( + (candidate) => candidate.commandId === entry.commandId, + ); + if (existingIndex >= 0) { + tombstones.splice(existingIndex, 1); + changed = true; + } + tombstones.push(entry); + } + + for (const entry of persistedCompleted) { + if (isCompletedLegacyWrite(entry)) { + const existingIndex = completed.findIndex( + (candidate) => candidate.commandId === entry.commandId, + ); + if (existingIndex >= 0) { + completed.splice(existingIndex, 1); + changed = true; + } + completed.push(entry); + continue; + } + changed = true; + const commandId = persistedCommandId(entry); + if (commandId === null) continue; + const existingIndex = tombstones.findIndex( + (candidate) => candidate.commandId === commandId, + ); + if (existingIndex >= 0) tombstones.splice(existingIndex, 1); + tombstones.push({ commandId }); + } + + if (completed.length > COMPLETED_WRITES_CAP) { + completed.splice(0, completed.length - COMPLETED_WRITES_CAP); + changed = true; + } + if (tombstones.length > COMPLETED_WRITES_CAP) { + tombstones.splice(0, tombstones.length - COMPLETED_WRITES_CAP); + changed = true; + } + if ( + !changed && + (completed.length !== persistedCompleted.length || + tombstones.length !== persistedTombstones.length) + ) { + changed = true; + } + if (changed) { + this.setState({ + ...this.state, + completedWrites: completed, + legacyCommandTombstones: tombstones, + }); + } } /** Records a handled page dialog (capped) for the GET /v1/sessions/:id surface. */ - private rememberDialog(ev: Extract): void { + private rememberDialog(ev: Extract): boolean { // Strip only the wire discriminator: object-rest yields exactly DialogRecord // (preserving defaultPrompt's presence/absence), so a new protocol dialog // field persists automatically - no hand-copied field list to drift. const { type: _type, ...record } = ev; + const existing = this.sql<{ dialog_id: string }>` + SELECT dialog_id FROM dialog_seen WHERE dialog_id = ${record.dialogId} + `[0]; + if (existing !== undefined) return false; + this.sql` + INSERT INTO dialog_seen (dialog_id, occurred_at) + VALUES (${record.dialogId}, ${record.occurredAt}) + `; const next = [...this.dialogs(), record]; while (next.length > RECENT_DIALOGS_CAP) next.shift(); this.setState({ ...this.state, dialogs: next }); + return true; + } + + private async emitSessionTelemetry( + event: TelemetryEvent, + outcome: string, + commandType?: string, + ): Promise { + const tenantId = + this.state.unattended?.tenantId ?? await tenantOf(this.name, this.env) ?? undefined; + await emitTelemetry(this.env, { + event, + outcome, + ...(tenantId === undefined ? {} : { tenantId }), + sessionId: this.name, + ...(commandType === undefined ? {} : { commandType }), + }); } // Persisted before this field existed, a session's state can lack it. @@ -374,13 +1970,61 @@ export class SessionAgent extends Agent { return this.state.dialogs ?? []; } - async getStatus(): Promise<{ - status: SessionStatus; - browser: SessionState["browser"]; - tabs: SessionState["tabs"]; - currentUrl: string | null; - dialogs: SessionState["dialogs"]; - }> { + async getStatus(): Promise< + | { + status: SessionStatus; + browser: SessionState["browser"]; + tabs: SessionState["tabs"]; + currentUrl: string | null; + dialogs: SessionState["dialogs"]; + } + | { + mode: "unattended"; + status: UnattendedSessionLifecycle; + deviceId: string; + createdAt: string; + lastActivityAt: string; + idleExpiresAt: string; + hardExpiresAt: string; + needsReconciliation: boolean; + dialogDelivery: "ok" | "interrupted" | "overflow"; + browser: SessionState["browser"]; + tabs: SessionState["tabs"]; + currentUrl: string | null; + dialogs: SessionState["dialogs"]; + } + > { + if (this.state.unattended !== undefined) { + const lease = await this.tenantCoordinator().getLease(this.name); + const unattended = + lease === null + ? { ...this.state.unattended, status: "lost" as const, needsReconciliation: true } + : { + ...this.state.unattended, + status: lease.status, + lastActivityAt: new Date(lease.lastActivityAt).toISOString(), + idleExpiresAt: new Date(lease.idleExpiresAt).toISOString(), + hardExpiresAt: new Date(lease.hardExpiresAt).toISOString(), + needsReconciliation: lease.needsReconciliation, + dialogDelivery: lease.dialogDelivery, + }; + this.setState({ ...this.state, unattended }); + return { + mode: "unattended", + status: unattended.status, + deviceId: unattended.deviceId, + createdAt: unattended.createdAt, + lastActivityAt: unattended.lastActivityAt, + idleExpiresAt: unattended.idleExpiresAt, + hardExpiresAt: unattended.hardExpiresAt, + needsReconciliation: unattended.needsReconciliation, + dialogDelivery: unattended.dialogDelivery, + browser: this.state.browser, + tabs: this.state.tabs.slice(0, 1), + currentUrl: this.state.currentUrl, + dialogs: this.dialogs(), + }; + } return { status: this.state.status, browser: this.state.browser, @@ -398,13 +2042,27 @@ export class SessionAgent extends Agent { private async checkRefResolves( ref: string | undefined, ): Promise<{ ok: true } | { ok: false; reason: string }> { + if (this.isTerminalSession()) { + throw new Error(`${SESSION_TERMINAL}: session deleted`); + } if (ref === undefined) return { ok: true }; - const ev = await this.coordinator.send({ + const probe: Command = { type: "resolve_ref", commandId: crypto.randomUUID(), ref, - }); + }; + const fingerprint = await requestFingerprint(probe, false); + if (this.isTerminalSession()) { + throw new Error(`${SESSION_TERMINAL}: session deleted`); + } + const ev = await this.coordinator.send( + probe, + legacyTombstone(probe, fingerprint), + ); + if (this.isTerminalSession()) { + throw new Error(`${SESSION_TERMINAL}: session deleted`); + } if (ev.type !== "action_result") { return { ok: false, reason: `unexpected probe response '${ev.type}'` }; } @@ -449,7 +2107,7 @@ export class SessionAgent extends Agent { } private hasAuthorizedConnection(): boolean { - return this.authoritativeConnection() !== undefined; + return !this.isTerminalSession() && this.authoritativeConnection() !== undefined; } private isAuthoritativeConnection(connection: Connection): boolean { @@ -501,3 +2159,159 @@ export class SessionAgent extends Agent { ).activeConnectionId; } } + +const WRITE_COMMAND_TYPE_SET = new Set( + WRITE_COMMAND_TYPES, +); + +function legacyTombstone( + command: Command, + requestFingerprint: string, +): LegacyCommandTombstone { + return { + commandId: command.commandId, + commandType: command.type, + requestFingerprint, + }; +} + +function isWriteCommandType( + commandType: Command["type"], +): boolean { + return WRITE_COMMAND_TYPE_SET.has(commandType); +} + +function isPersistedLegacyAwaiting( + value: unknown, +): value is PersistedLegacyAwaiting { + if (typeof value !== "object" || value === null) return false; + const candidate = value as { + commandId?: unknown; + commandType?: unknown; + requestFingerprint?: unknown; + }; + if ( + typeof candidate.commandId !== "string" || + candidate.commandId.length < 1 || + candidate.commandId.length > 128 + ) { + return false; + } + if ( + candidate.commandType === undefined && + candidate.requestFingerprint === undefined + ) { + return true; + } + return isLegacyCommandTombstone(value); +} + +function isLegacyCommandTombstone( + value: unknown, +): value is LegacyCommandTombstone { + if (typeof value !== "object" || value === null) return false; + const candidate = value as { + commandId?: unknown; + commandType?: unknown; + requestFingerprint?: unknown; + }; + return ( + typeof candidate.commandId === "string" && + candidate.commandId.length >= 1 && + candidate.commandId.length <= 128 && + typeof candidate.commandType === "string" && + isCommandType(candidate.commandType) && + typeof candidate.requestFingerprint === "string" && + candidate.requestFingerprint.length === 64 + ); +} + +function isCompletedLegacyWrite( + value: unknown, +): value is CompletedLegacyWrite { + if (!isLegacyCommandTombstone(value)) return false; + const event = (value as { event?: unknown }).event; + const parsed = safeParseEvent(event); + return ( + isWriteCommandType(value.commandType) && + parsed.success && + parsed.data.type === "action_result" && + parsed.data.commandId === value.commandId && + utf8ByteLength(JSON.stringify(parsed.data)) <= + COMPLETED_WRITE_EVENT_MAX_BYTES + ); +} + +function persistedCommandId(value: unknown): string | null { + if (typeof value !== "object" || value === null) return null; + const commandId = (value as { commandId?: unknown }).commandId; + return typeof commandId === "string" && + commandId.length > 0 && + commandId.length <= 128 + ? commandId + : null; +} + +function sameLegacyCommand( + left: LegacyCommandTombstone, + right: LegacyCommandTombstone, +): boolean { + return ( + left.commandId === right.commandId && + left.commandType === right.commandType && + left.requestFingerprint === right.requestFingerprint + ); +} + +function isCommandType(value: string): value is Command["type"] { + return ( + value === "snapshot" || + value === "navigate" || + value === "click" || + value === "type" || + value === "fill_secret" || + value === "key" || + value === "scroll" || + value === "wait" || + value === "resolve_ref" || + value === "get_tabs" || + value === "switch_tab" + ); +} + +function isTerminalLifecycle(status: UnattendedSessionLifecycle): boolean { + return status === "closed" || status === "expired" || status === "lost"; +} + +async function sha256Hex(value: string): Promise { + const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value)); + return Array.from(new Uint8Array(digest), (byte) => + byte.toString(16).padStart(2, "0"), + ).join(""); +} + +async function resolveBeforeDeadline( + promise: Promise, + deadlineAt: number, +): Promise< + | { kind: "value"; value: T } + | { kind: "error" } + | { kind: "timeout" } +> { + const remaining = deadlineAt - Date.now(); + if (remaining <= 0) return { kind: "timeout" }; + let timer: ReturnType | undefined; + try { + return await Promise.race([ + promise.then( + (value) => ({ kind: "value" as const, value }), + () => ({ kind: "error" as const }), + ), + new Promise<{ kind: "timeout" }>((resolve) => { + timer = setTimeout(() => resolve({ kind: "timeout" }), remaining); + }), + ]); + } finally { + if (timer !== undefined) clearTimeout(timer); + } +} diff --git a/apps/backend/src/telemetry.ts b/apps/backend/src/telemetry.ts new file mode 100644 index 0000000..ac47460 --- /dev/null +++ b/apps/backend/src/telemetry.ts @@ -0,0 +1,70 @@ +import { telemetryPseudonym } from "./auth"; +import type { Env } from "./types"; + +export type TelemetryEvent = + | "authentication" + | "session_create" + | "session_replay" + | "session_close" + | "session_expiry" + | "device_connect" + | "device_offline" + | "device_epoch_change" + | "reservation" + | "release" + | "capacity" + | "provisioning" + | "recovery" + | "command" + | "command_prepare" + | "command_grant" + | "command_pending" + | "command_unknown" + | "dialog" + | "quota"; + +export interface TelemetryInput { + event: TelemetryEvent; + outcome: string; + tenantId?: string; + actor?: string; + deviceId?: string; + sessionId?: string; + commandType?: string; + durationMs?: number; +} + +export async function emitTelemetry(env: Env, input: TelemetryInput): Promise { + const dimensions: Record = { + event: input.event, + outcome: input.outcome.slice(0, 128), + }; + const identifiers = [ + ["tenant", input.tenantId], + ["actor", input.actor], + ["device", input.deviceId], + ["session", input.sessionId], + ] as const; + for (const [domain, value] of identifiers) { + if (value !== undefined) { + dimensions[domain] = await telemetryPseudonym(domain, value, env); + } + } + if (input.commandType !== undefined) dimensions.commandType = input.commandType.slice(0, 64); + if (input.durationMs !== undefined) dimensions.durationMs = Math.max(0, input.durationMs); + + console.log(JSON.stringify({ telemetry: dimensions })); + env.ANALYTICS?.writeDataPoint({ + blobs: [ + String(dimensions.event), + String(dimensions.outcome), + String(dimensions.tenant ?? ""), + String(dimensions.actor ?? ""), + String(dimensions.device ?? ""), + String(dimensions.session ?? ""), + String(dimensions.commandType ?? ""), + ], + doubles: [typeof dimensions.durationMs === "number" ? dimensions.durationMs : 0], + indexes: [String(dimensions.tenant ?? "anonymous")], + }); +} diff --git a/apps/backend/src/tenant-coordinator.ts b/apps/backend/src/tenant-coordinator.ts new file mode 100644 index 0000000..e802611 --- /dev/null +++ b/apps/backend/src/tenant-coordinator.ts @@ -0,0 +1,1152 @@ +import { DurableObject } from "cloudflare:workers"; +import { getAgentByName } from "agents"; +import type { DeviceStatus, ProtocolCapability, UnattendedSessionLifecycle } from "@understudy/protocol"; +import { parseQuotaPolicy } from "./quota"; +import type { Env } from "./types"; +import type { DeviceAgent } from "./device"; +import { emitTelemetry } from "./telemetry"; + +const DEVICE_CAPACITY = 2; +const DEVICE_OFFLINE_MS = 75_000; +const DEVICE_LOST_MS = 90_000; +const PROVISIONING_DEADLINE_MS = 30_000; +const IDLE_EXPIRY_MS = 2 * 60 * 60 * 1000; +const HARD_EXPIRY_MS = 24 * 60 * 60 * 1000; +interface DeviceRow { + [key: string]: string | number | null; + device_id: string; + enabled: number; + last_seen_at: number; + last_assigned_at: number; + browser: string; + ext_version: string; + browser_epoch: string; + credential_digest: string; + credential_version: number; + origin_policy_json: string; + capabilities_json: string; +} + +interface DeviceCredentialRow { + [key: string]: string | number; + device_id: string; + credential_digest: string; + credential_version: number; +} + +interface LeaseRow { + [key: string]: string | number | null; + session_id: string; + lease_id: string; + device_id: string; + status: UnattendedSessionLifecycle; + allowed_origins_json: string; + profile_state_hash: string; + lease_epoch: number; + browser_epoch: string; + created_at: number; + last_activity_at: number; + idle_expires_at: number; + hard_expires_at: number; + provisioning_deadline_at: number; + release_at: number | null; + needs_reconciliation: number; + dialog_delivery: "ok" | "interrupted" | "overflow"; +} + +export interface LeaseResource { + sessionId: string; + leaseId: string; + deviceId: string; + status: UnattendedSessionLifecycle; + allowedOrigins: string[]; + leaseEpoch: number; + browserEpoch: string; + createdAt: number; + lastActivityAt: number; + idleExpiresAt: number; + hardExpiresAt: number; + needsReconciliation: boolean; + dialogDelivery: "ok" | "interrupted" | "overflow"; +} + +export interface ClosureConfirmation { + status: "closed" | "expired" | "lost"; + newlyClosed: boolean; +} + +export type CreateLeaseResult = + | { kind: "created"; created: true; lease: LeaseResource } + | { kind: "replay"; created: false; lease: LeaseResource } + | { kind: "conflict" } + | { kind: "terminal"; status: UnattendedSessionLifecycle } + | { kind: "device_not_found" } + | { kind: "no_device" } + | { kind: "capacity" } + | { kind: "collision" }; + +export interface CreateLeaseInput { + idempotencyKey: string; + fingerprint: string; + sessionId: string; + deviceId?: string; + allowedOrigins: string[]; + profileStateHash: string; + actorPseudonym: string; + now?: number; +} + +export interface RegisterDeviceInput { + deviceId: string; + browser: string; + extVersion: string; + browserEpoch: string; + credentialDigest: string; + credentialVersion: number; + allowedOrigins: string[]; + capabilities: ProtocolCapability[]; + now?: number; +} + +export class TenantDeviceCoordinator extends DurableObject { + private readonly tenantId: string; + + constructor(ctx: DurableObjectState, env: Env) { + super(ctx, env); + this.tenantId = ctx.id.name ?? ctx.id.toString(); + ctx.blockConcurrencyWhile(async () => { + this.initializeSchema(); + }); + } + + private initializeSchema(): void { + this.ctx.storage.sql.exec(` + CREATE TABLE IF NOT EXISTS device ( + device_id TEXT PRIMARY KEY, + enabled INTEGER NOT NULL, + last_seen_at INTEGER NOT NULL, + last_assigned_at INTEGER NOT NULL, + browser TEXT NOT NULL, + ext_version TEXT NOT NULL, + browser_epoch TEXT NOT NULL, + credential_digest TEXT NOT NULL, + credential_version INTEGER NOT NULL, + origin_policy_json TEXT NOT NULL, + capabilities_json TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS device_credential_fence ( + device_id TEXT PRIMARY KEY, + credential_digest TEXT NOT NULL, + credential_version INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS lease ( + session_id TEXT PRIMARY KEY, + lease_id TEXT NOT NULL UNIQUE, + device_id TEXT NOT NULL, + status TEXT NOT NULL, + allowed_origins_json TEXT NOT NULL, + profile_state_hash TEXT NOT NULL, + lease_epoch INTEGER NOT NULL, + browser_epoch TEXT NOT NULL, + created_at INTEGER NOT NULL, + last_activity_at INTEGER NOT NULL, + idle_expires_at INTEGER NOT NULL, + hard_expires_at INTEGER NOT NULL, + provisioning_deadline_at INTEGER NOT NULL, + release_at INTEGER, + needs_reconciliation INTEGER NOT NULL, + dialog_delivery TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS lease_device_active + ON lease(device_id, status, release_at); + CREATE TABLE IF NOT EXISTS create_idempotency ( + idempotency_key TEXT PRIMARY KEY, + fingerprint TEXT NOT NULL, + session_id TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS quota_counter ( + scope TEXT NOT NULL, + subject TEXT NOT NULL, + bucket INTEGER NOT NULL, + count INTEGER NOT NULL, + PRIMARY KEY(scope, subject, bucket) + ); + `); + } + + async advanceDeviceCredential(input: { + deviceId: string; + credentialDigest: string; + credentialVersion: number; + }): Promise<{ accepted: boolean }> { + return { accepted: this.advanceCredentialFence(input) }; + } + + async registerDevice( + input: RegisterDeviceInput, + ): Promise<{ accepted: boolean; epochChanged: boolean }> { + if (!this.advanceCredentialFence(input)) { + return { accepted: false, epochChanged: false }; + } + const now = input.now ?? Date.now(); + const previous = this.device(input.deviceId); + const epochChanged = + previous !== undefined && + previous.browser_epoch !== input.browserEpoch; + this.ctx.storage.sql.exec( + `INSERT INTO device ( + device_id, enabled, last_seen_at, last_assigned_at, browser, ext_version, + browser_epoch, credential_digest, credential_version, origin_policy_json, + capabilities_json + ) VALUES (?, 1, ?, 0, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(device_id) DO UPDATE SET + enabled = 1, + last_seen_at = excluded.last_seen_at, + browser = excluded.browser, + ext_version = excluded.ext_version, + browser_epoch = excluded.browser_epoch, + credential_digest = excluded.credential_digest, + credential_version = excluded.credential_version, + origin_policy_json = excluded.origin_policy_json, + capabilities_json = excluded.capabilities_json`, + input.deviceId, + now, + input.browser, + input.extVersion, + input.browserEpoch, + input.credentialDigest, + input.credentialVersion, + JSON.stringify(input.allowedOrigins), + JSON.stringify(input.capabilities), + ); + + if (epochChanged) { + this.ctx.storage.sql.exec( + `UPDATE lease + SET status = CASE WHEN status = 'expired' THEN 'expired' ELSE 'lost' END, + release_at = ?, needs_reconciliation = 1 + WHERE device_id = ? AND status IN ('closing','expired') + AND release_at IS NULL`, + now, + input.deviceId, + ); + this.ctx.storage.sql.exec( + `UPDATE lease + SET status = 'recovering', + browser_epoch = ?, + provisioning_deadline_at = ?, + needs_reconciliation = 1, + dialog_delivery = CASE WHEN dialog_delivery = 'overflow' THEN 'overflow' ELSE 'interrupted' END + WHERE device_id = ? + AND status IN ('allocating','provisioning','connected','recovering') + AND release_at IS NULL`, + input.browserEpoch, + now + DEVICE_LOST_MS, + input.deviceId, + ); + } + await this.scheduleNextAlarm(); + return { accepted: true, epochChanged }; + } + + async heartbeat( + deviceId: string, + browserEpoch: string, + reportedLeaseIds: string[] = [], + now = Date.now(), + ): Promise<{ + ok: boolean; + recoveries: LeaseResource[]; + assignments: LeaseResource[]; + closures: LeaseResource[]; + }> { + const device = this.device(deviceId); + if (device === undefined || device.browser_epoch !== browserEpoch || device.enabled !== 1) { + return { ok: false, recoveries: [], assignments: [], closures: [] }; + } + this.ctx.storage.sql.exec( + "UPDATE device SET last_seen_at = ? WHERE device_id = ? AND browser_epoch = ?", + now, + deviceId, + browserEpoch, + ); + const reported = new Set(reportedLeaseIds); + for (const lease of this.leaseRows( + `SELECT * FROM lease + WHERE device_id = ? AND status = 'connected' AND release_at IS NULL`, + deviceId, + )) { + if (reported.has(lease.lease_id)) continue; + this.ctx.storage.sql.exec( + `UPDATE lease SET status = 'recovering', needs_reconciliation = 1, + provisioning_deadline_at = ? + WHERE session_id = ? AND status = 'connected' AND release_at IS NULL`, + now + DEVICE_LOST_MS, + lease.session_id, + ); + } + const recoveries = this.leaseRows( + `SELECT * FROM lease + WHERE device_id = ? AND status = 'recovering' AND release_at IS NULL + ORDER BY created_at`, + deviceId, + ).map(toLeaseResource); + const assignments = this.leaseRows( + `SELECT * FROM lease + WHERE device_id = ? AND status = 'connected' AND release_at IS NULL + ORDER BY created_at`, + deviceId, + ).map(toLeaseResource); + const closures = this.leaseRows( + `SELECT * FROM lease + WHERE device_id = ? AND status IN ('closing','expired') AND release_at IS NULL + ORDER BY created_at`, + deviceId, + ).map(toLeaseResource); + await this.scheduleNextAlarm(); + return { ok: true, recoveries, assignments, closures }; + } + + async createLease(input: CreateLeaseInput): Promise { + const now = input.now ?? Date.now(); + const existingKey = this.ctx.storage.sql + .exec<{ fingerprint: string; session_id: string }>( + "SELECT fingerprint, session_id FROM create_idempotency WHERE idempotency_key = ?", + input.idempotencyKey, + ) + .toArray()[0]; + if (existingKey !== undefined) { + if (existingKey.fingerprint !== input.fingerprint) return { kind: "conflict" }; + const lease = this.lease(existingKey.session_id); + if (lease === undefined) return { kind: "terminal", status: "lost" }; + if (isTerminal(lease.status)) return { kind: "terminal", status: lease.status }; + return { kind: "replay", created: false, lease: toLeaseResource(lease) }; + } + const policy = parseQuotaPolicy(this.env.QUOTA_POLICY); + if ( + !this.consumeQuota( + "session_create_actor", + input.actorPseudonym, + now, + policy.sessionCreatesPerActorMinute, + ) + ) { + await emitTelemetry(this.env, { + event: "quota", + outcome: "session_create_denied", + tenantId: this.tenantId, + actor: input.actorPseudonym, + }); + return { kind: "capacity" }; + } + + const online = this.onlineCompatibleDevices(now); + if (input.deviceId !== undefined && this.device(input.deviceId) === undefined) { + return { kind: "device_not_found" }; + } + const candidates = + input.deviceId === undefined + ? online + : online.filter((device) => device.device_id === input.deviceId); + if (candidates.length === 0) return { kind: "no_device" }; + + let sawCapacity = false; + let sawCollision = false; + let selected: DeviceRow | undefined; + for (const device of candidates) { + const leases = this.activeLeasesForDevice(device.device_id); + if (leases.length >= DEVICE_CAPACITY) { + sawCapacity = true; + continue; + } + if (!isSubset(input.allowedOrigins, parseStringArray(device.origin_policy_json))) { + sawCollision = true; + continue; + } + if ( + leases.some( + (lease) => + lease.profile_state_hash === input.profileStateHash || + intersects(input.allowedOrigins, parseStringArray(lease.allowed_origins_json)), + ) + ) { + sawCollision = true; + continue; + } + selected = device; + break; + } + if (selected === undefined) { + if (sawCollision) return { kind: "collision" }; + if (sawCapacity) return { kind: "capacity" }; + return { kind: "no_device" }; + } + + const leaseId = crypto.randomUUID(); + const hardExpiresAt = now + HARD_EXPIRY_MS; + const idleExpiresAt = Math.min(now + IDLE_EXPIRY_MS, hardExpiresAt); + this.ctx.storage.transactionSync(() => { + this.ctx.storage.sql.exec( + `INSERT INTO create_idempotency (idempotency_key, fingerprint, session_id) + VALUES (?, ?, ?)`, + input.idempotencyKey, + input.fingerprint, + input.sessionId, + ); + this.ctx.storage.sql.exec( + `INSERT INTO lease ( + session_id, lease_id, device_id, status, allowed_origins_json, + profile_state_hash, lease_epoch, browser_epoch, created_at, + last_activity_at, idle_expires_at, hard_expires_at, + provisioning_deadline_at, release_at, needs_reconciliation, + dialog_delivery + ) VALUES (?, ?, ?, 'provisioning', ?, ?, 1, ?, ?, ?, ?, ?, ?, NULL, 0, 'ok')`, + input.sessionId, + leaseId, + selected.device_id, + JSON.stringify(input.allowedOrigins), + input.profileStateHash, + selected.browser_epoch, + now, + now, + idleExpiresAt, + hardExpiresAt, + now + PROVISIONING_DEADLINE_MS, + ); + this.ctx.storage.sql.exec( + "UPDATE device SET last_assigned_at = ? WHERE device_id = ?", + now, + selected.device_id, + ); + }); + await this.scheduleNextAlarm(); + const lease = this.lease(input.sessionId); + if (lease === undefined) throw new Error("lease reservation disappeared"); + return { kind: "created", created: true, lease: toLeaseResource(lease) }; + } + + async markProvisioned(input: { + sessionId: string; + leaseId: string; + leaseEpoch: number; + browserEpoch: string; + deviceId: string; + now?: number; + }): Promise<{ accepted: boolean; close: boolean }> { + const now = input.now ?? Date.now(); + const changed = this.ctx.storage.sql.exec<{ status: string }>( + `UPDATE lease + SET status = 'connected', needs_reconciliation = 0 + WHERE session_id = ? AND lease_id = ? AND device_id = ? AND lease_epoch = ? + AND browser_epoch = ? AND status IN ('provisioning','recovering') + AND release_at IS NULL AND hard_expires_at > ? AND idle_expires_at > ? + RETURNING status`, + input.sessionId, + input.leaseId, + input.deviceId, + input.leaseEpoch, + input.browserEpoch, + now, + now, + ).toArray(); + await this.scheduleNextAlarm(); + return { accepted: changed.length === 1, close: changed.length !== 1 }; + } + + async markProvisionFailed(input: { + sessionId: string; + leaseId: string; + leaseEpoch: number; + browserEpoch: string; + deviceId: string; + }): Promise { + this.ctx.storage.sql.exec( + `UPDATE lease SET status = 'closing', needs_reconciliation = 1 + WHERE session_id = ? AND lease_id = ? AND device_id = ? + AND lease_epoch = ? AND browser_epoch = ? + AND status IN ('provisioning','recovering') AND release_at IS NULL`, + input.sessionId, + input.leaseId, + input.deviceId, + input.leaseEpoch, + input.browserEpoch, + ); + await this.scheduleNextAlarm(); + } + + async getLease(sessionId: string, now = Date.now()): Promise { + let lease = this.lease(sessionId); + if ( + lease !== undefined && + lease.release_at === null && + (lease.hard_expires_at <= now || lease.idle_expires_at <= now) && + ["allocating", "provisioning", "connected", "recovering"].includes(lease.status) + ) { + this.ctx.storage.sql.exec( + `UPDATE lease SET status = 'expired' + WHERE session_id = ? AND release_at IS NULL + AND status IN ('allocating','provisioning','connected','recovering') + AND (hard_expires_at <= ? OR idle_expires_at <= ?)`, + sessionId, + now, + now, + ); + lease = this.lease(sessionId); + await this.scheduleNextAlarm(); + } + return lease === undefined ? null : toLeaseResource(lease); + } + + async authorizeCommand(input: { + sessionId: string; + actorPseudonym: string; + credentialFill: boolean; + now?: number; + }): Promise<{ ok: true; idleExpiresAt: number } | { ok: false; reason: "terminal" | "quota" }> { + const now = input.now ?? Date.now(); + const lease = this.lease(input.sessionId); + if ( + lease === undefined || + lease.status !== "connected" || + lease.release_at !== null || + lease.hard_expires_at <= now || + lease.idle_expires_at <= now + ) { + return { ok: false, reason: "terminal" }; + } + const policy = parseQuotaPolicy(this.env.QUOTA_POLICY); + const quotas = [ + { + scope: "commands_session", + subject: input.sessionId, + limit: policy.commandsPerSessionMinute, + }, + { + scope: "commands_tenant", + subject: this.tenantId, + limit: policy.commandsPerTenantMinute, + }, + ...(input.credentialFill + ? [ + { + scope: "credential_fill_actor", + subject: input.actorPseudonym, + limit: policy.credentialFillsPerActorMinute, + }, + ] + : []), + ]; + if (!this.consumeQuotas(quotas, now)) { + await emitTelemetry(this.env, { + event: "quota", + outcome: "command_denied", + tenantId: this.tenantId, + actor: input.actorPseudonym, + sessionId: input.sessionId, + }); + return { ok: false, reason: "quota" }; + } + const idleExpiresAt = Math.min(now + IDLE_EXPIRY_MS, lease.hard_expires_at); + this.ctx.storage.sql.exec( + `UPDATE lease SET last_activity_at = ?, idle_expires_at = ? + WHERE session_id = ? AND status = 'connected' AND release_at IS NULL`, + now, + idleExpiresAt, + input.sessionId, + ); + await this.scheduleNextAlarm(); + return { ok: true, idleExpiresAt }; + } + + async authorizeAttendedCommand(input: { + sessionId: string; + actorPseudonym: string; + credentialFill: boolean; + now?: number; + }): Promise { + const now = input.now ?? Date.now(); + const policy = parseQuotaPolicy(this.env.QUOTA_POLICY); + const allowed = this.consumeQuotas( + [ + { + scope: "commands_session", + subject: input.sessionId, + limit: policy.commandsPerSessionMinute, + }, + { + scope: "commands_tenant", + subject: this.tenantId, + limit: policy.commandsPerTenantMinute, + }, + ...(input.credentialFill + ? [ + { + scope: "credential_fill_actor", + subject: input.actorPseudonym, + limit: policy.credentialFillsPerActorMinute, + }, + ] + : []), + ], + now, + ); + if (!allowed) { + await emitTelemetry(this.env, { + event: "quota", + outcome: "attended_command_denied", + tenantId: this.tenantId, + actor: input.actorPseudonym, + sessionId: input.sessionId, + }); + } + return allowed; + } + + async markRecovering(input: { + sessionId: string; + leaseId: string; + leaseEpoch: number; + browserEpoch: string; + now?: number; + }): Promise { + const now = input.now ?? Date.now(); + const changed = this.ctx.storage.sql.exec<{ status: string }>( + `UPDATE lease + SET status = 'recovering', needs_reconciliation = 1, + provisioning_deadline_at = ? + WHERE session_id = ? AND lease_id = ? AND lease_epoch = ? + AND browser_epoch = ? AND status = 'connected' AND release_at IS NULL + RETURNING status`, + now + DEVICE_LOST_MS, + input.sessionId, + input.leaseId, + input.leaseEpoch, + input.browserEpoch, + ).toArray(); + await this.scheduleNextAlarm(); + return changed.length === 1; + } + + async closeLease( + sessionId: string, + ): Promise<{ found: boolean; cleanupConfirmed: boolean; lease?: LeaseResource }> { + const lease = this.lease(sessionId); + if (lease === undefined) return { found: false, cleanupConfirmed: false }; + if (lease.release_at !== null) { + return { found: true, cleanupConfirmed: true, lease: toLeaseResource(lease) }; + } + if (lease.status === "expired") { + await this.scheduleNextAlarm(); + return { found: true, cleanupConfirmed: false, lease: toLeaseResource(lease) }; + } + this.ctx.storage.sql.exec( + "UPDATE lease SET status = 'closing' WHERE session_id = ? AND release_at IS NULL", + sessionId, + ); + await this.scheduleNextAlarm(); + const next = this.lease(sessionId); + return { + found: true, + cleanupConfirmed: false, + ...(next === undefined ? {} : { lease: toLeaseResource(next) }), + }; + } + + async confirmClosed(input: { + sessionId: string; + leaseId: string; + leaseEpoch: number; + browserEpoch: string; + deviceId: string; + now?: number; + }): Promise { + const now = input.now ?? Date.now(); + const before = this.lease(input.sessionId); + if ( + before === undefined || + before.lease_id !== input.leaseId || + before.device_id !== input.deviceId || + before.lease_epoch !== input.leaseEpoch || + before.browser_epoch !== input.browserEpoch + ) { + return null; + } + if (before.release_at !== null) { + return ( + before.status === "closed" || + before.status === "expired" || + before.status === "lost" + ) + ? { status: before.status, newlyClosed: false } + : null; + } + if ( + !( + before.status === "allocating" || + before.status === "provisioning" || + before.status === "connected" || + before.status === "recovering" || + before.status === "closing" || + before.status === "expired" + ) + ) { + return null; + } + const terminalStatus: UnattendedSessionLifecycle = + before.status === "expired" ? "expired" : "closed"; + const changed = this.ctx.storage.sql.exec<{ status: UnattendedSessionLifecycle }>( + `UPDATE lease SET status = ?, release_at = ?, needs_reconciliation = 0 + WHERE session_id = ? AND lease_id = ? AND device_id = ? + AND lease_epoch = ? AND browser_epoch = ? + AND release_at IS NULL + AND status IN ('allocating','provisioning','connected','recovering','closing','expired') + RETURNING status`, + terminalStatus, + now, + input.sessionId, + input.leaseId, + input.deviceId, + input.leaseEpoch, + input.browserEpoch, + ).toArray(); + await this.scheduleNextAlarm(); + return changed.length === 1 && changed[0]?.status === terminalStatus + ? { status: terminalStatus, newlyClosed: true } + : null; + } + + async setDialogDelivery( + sessionId: string, + delivery: "ok" | "interrupted" | "overflow", + ): Promise { + this.ctx.storage.sql.exec( + `UPDATE lease SET dialog_delivery = + CASE + WHEN dialog_delivery = 'overflow' THEN 'overflow' + WHEN ? = 'overflow' THEN 'overflow' + WHEN ? = 'interrupted' THEN 'interrupted' + ELSE dialog_delivery + END + WHERE session_id = ?`, + delivery, + delivery, + sessionId, + ); + } + + async revokeDevice( + deviceId: string, + expectedCredential?: { + credentialDigest: string; + credentialVersion: number; + }, + now = Date.now(), + ): Promise { + if ( + expectedCredential !== undefined && + !this.credentialFenceMatches(deviceId, expectedCredential) + ) { + return false; + } + const leases = this.activeLeasesForDevice(deviceId); + this.ctx.storage.sql.exec( + "UPDATE device SET enabled = 0 WHERE device_id = ?", + deviceId, + ); + this.ctx.storage.sql.exec( + `UPDATE lease SET status = 'lost', release_at = ?, needs_reconciliation = 1 + WHERE device_id = ? AND release_at IS NULL + AND status IN ('allocating','provisioning','connected','recovering','closing','expired')`, + now, + deviceId, + ); + await this.scheduleNextAlarm(); + for (const lease of leases) { + try { + const session = await getAgentByName(this.env.SESSION, lease.session_id); + await session.revokeDevice(); + } catch { + // The coordinator's terminal lease remains authoritative. + } + } + return true; + } + + async listDevices(now = Date.now()): Promise { + return this.ctx.storage.sql + .exec("SELECT * FROM device ORDER BY device_id") + .toArray() + .map((device) => { + const used = this.activeLeasesForDevice(device.device_id).length; + const capabilities = parseStringArray(device.capabilities_json); + let status: DeviceStatus["status"]; + if (device.enabled !== 1) status = "disabled"; + else if (!capabilities.includes("safe-write-v2")) status = "incompatible"; + else if (now - device.last_seen_at > DEVICE_OFFLINE_MS) status = "offline"; + else if ( + this.activeLeasesForDevice(device.device_id).some((lease) => lease.status === "recovering") + ) { + status = "recovering"; + } else status = "online"; + return { + deviceId: device.device_id, + status, + capacity: 2, + used, + browser: { browser: device.browser, extVersion: device.ext_version }, + lastSeenAt: new Date(device.last_seen_at).toISOString(), + }; + }); + } + + async consumeDeviceTicketQuota(deviceId: string, now = Date.now()): Promise { + const allowed = this.consumeQuota( + "device_ticket", + deviceId, + now, + parseQuotaPolicy(this.env.QUOTA_POLICY).deviceTicketsPerDeviceMinute, + ); + if (!allowed) { + await emitTelemetry(this.env, { + event: "quota", + outcome: "device_ticket_denied", + tenantId: this.tenantId, + deviceId, + }); + } + return allowed; + } + + async consumeSessionCreateQuota( + actorPseudonym: string, + now = Date.now(), + ): Promise { + const allowed = this.consumeQuota( + "session_create_actor", + actorPseudonym, + now, + parseQuotaPolicy(this.env.QUOTA_POLICY).sessionCreatesPerActorMinute, + ); + if (!allowed) { + await emitTelemetry(this.env, { + event: "quota", + outcome: "session_create_denied", + tenantId: this.tenantId, + actor: actorPseudonym, + }); + } + return allowed; + } + + async alarm(): Promise { + const now = Date.now(); + const devices = this.ctx.storage.sql.exec("SELECT * FROM device").toArray(); + const offlineDeviceIds = devices + .filter( + (device) => + now - device.last_seen_at >= DEVICE_OFFLINE_MS && + now - device.last_seen_at < DEVICE_LOST_MS, + ) + .map((device) => device.device_id); + const lostDeviceIds = devices + .filter((device) => now - device.last_seen_at >= DEVICE_LOST_MS) + .map((device) => device.device_id); + const expiringLeases = this.leaseRows( + `SELECT * FROM lease + WHERE release_at IS NULL + AND status IN ('allocating','provisioning','connected','recovering') + AND (hard_expires_at <= ? OR idle_expires_at <= ?)`, + now, + now, + ); + + for (const deviceId of offlineDeviceIds) { + this.ctx.storage.sql.exec( + `UPDATE lease SET status = 'recovering', needs_reconciliation = 1, + provisioning_deadline_at = ? + WHERE device_id = ? AND release_at IS NULL + AND status IN ('allocating','provisioning','connected')`, + now + (DEVICE_LOST_MS - DEVICE_OFFLINE_MS), + deviceId, + ); + await emitTelemetry(this.env, { + event: "device_offline", + outcome: "recovering", + tenantId: this.tenantId, + deviceId, + }); + } + this.ctx.storage.sql.exec( + `UPDATE lease SET status = 'expired' + WHERE release_at IS NULL + AND status IN ('allocating','provisioning','connected','recovering') + AND (hard_expires_at <= ? OR idle_expires_at <= ?)`, + now, + now, + ); + this.ctx.storage.sql.exec( + `UPDATE lease SET status = 'closing', needs_reconciliation = 1 + WHERE release_at IS NULL AND status IN ('provisioning','recovering') + AND provisioning_deadline_at <= ?`, + now, + ); + for (const deviceId of lostDeviceIds) { + this.ctx.storage.sql.exec( + `UPDATE lease SET status = 'lost', release_at = ?, needs_reconciliation = 1 + WHERE device_id = ? AND release_at IS NULL + AND status IN ('allocating','provisioning','connected','recovering','closing','expired')`, + now, + deviceId, + ); + await emitTelemetry(this.env, { + event: "device_offline", + outcome: "lost", + tenantId: this.tenantId, + deviceId, + }); + } + for (const lease of expiringLeases) { + await emitTelemetry(this.env, { + event: "session_expiry", + outcome: lease.hard_expires_at <= now ? "hard" : "idle", + tenantId: this.tenantId, + deviceId: lease.device_id, + sessionId: lease.session_id, + }); + } + + const cleanup = this.leaseRows( + `SELECT * FROM lease + WHERE release_at IS NULL AND status IN ('closing','expired') + ORDER BY created_at`, + ); + for (const lease of cleanup) { + try { + const device = this.env.DEVICE.getByName(lease.device_id) as DurableObjectStub; + await device.requestClose(toLeaseResource(lease)); + } catch { + // The lease stays reserved until a matching close ACK or device-loss release. + } + } + + const terminal = this.leaseRows( + "SELECT * FROM lease WHERE status IN ('closed','expired','lost')", + ); + for (const lease of terminal) { + try { + const session = await getAgentByName(this.env.SESSION, lease.session_id); + await session.markLifecycle(lease.status, lease.needs_reconciliation === 1); + } catch { + // Status remains authoritative here and will be reconciled on the next read/alarm. + } + } + await this.scheduleNextAlarm(); + } + + private device(deviceId: string): DeviceRow | undefined { + return this.ctx.storage.sql + .exec("SELECT * FROM device WHERE device_id = ?", deviceId) + .toArray()[0]; + } + + private lease(sessionId: string): LeaseRow | undefined { + return this.ctx.storage.sql + .exec("SELECT * FROM lease WHERE session_id = ?", sessionId) + .toArray()[0]; + } + + private leaseRows(query: string, ...values: (string | number)[]): LeaseRow[] { + return this.ctx.storage.sql.exec(query, ...values).toArray(); + } + + private activeLeasesForDevice(deviceId: string): LeaseRow[] { + return this.leaseRows( + `SELECT * FROM lease + WHERE device_id = ? AND release_at IS NULL + AND status IN ('allocating','provisioning','connected','recovering','closing','expired')`, + deviceId, + ); + } + + private advanceCredentialFence(input: { + deviceId: string; + credentialDigest: string; + credentialVersion: number; + }): boolean { + const existing = this.ctx.storage.sql + .exec( + "SELECT * FROM device_credential_fence WHERE device_id = ?", + input.deviceId, + ) + .toArray()[0]; + if ( + existing !== undefined && + (input.credentialVersion < existing.credential_version || + (input.credentialVersion === existing.credential_version && + input.credentialDigest !== existing.credential_digest)) + ) { + return false; + } + this.ctx.storage.sql.exec( + `INSERT INTO device_credential_fence ( + device_id, credential_digest, credential_version + ) VALUES (?, ?, ?) + ON CONFLICT(device_id) DO UPDATE SET + credential_digest = excluded.credential_digest, + credential_version = excluded.credential_version`, + input.deviceId, + input.credentialDigest, + input.credentialVersion, + ); + return true; + } + + private credentialFenceMatches( + deviceId: string, + expected: { + credentialDigest: string; + credentialVersion: number; + }, + ): boolean { + const row = this.ctx.storage.sql + .exec( + "SELECT * FROM device_credential_fence WHERE device_id = ?", + deviceId, + ) + .toArray()[0]; + return ( + row?.credential_digest === expected.credentialDigest && + row.credential_version === expected.credentialVersion + ); + } + + private onlineCompatibleDevices(now: number): DeviceRow[] { + const rows = this.ctx.storage.sql + .exec( + `SELECT * FROM device + WHERE enabled = 1 AND last_seen_at > ? + ORDER BY last_assigned_at ASC, device_id ASC`, + now - DEVICE_OFFLINE_MS, + ) + .toArray() + .filter((device) => + parseStringArray(device.capabilities_json).includes("safe-write-v2") && + !this.activeLeasesForDevice(device.device_id).some( + (lease) => lease.status === "recovering", + ), + ); + return rows.sort((left, right) => { + const capacity = this.activeLeasesForDevice(left.device_id).length - + this.activeLeasesForDevice(right.device_id).length; + if (capacity !== 0) return capacity; + if (left.last_assigned_at !== right.last_assigned_at) { + return left.last_assigned_at - right.last_assigned_at; + } + return left.device_id.localeCompare(right.device_id); + }); + } + + private consumeQuota(scope: string, subject: string, now: number, limit: number): boolean { + return this.consumeQuotas([{ scope, subject, limit }], now); + } + + private consumeQuotas( + quotas: Array<{ scope: string; subject: string; limit: number }>, + now: number, + ): boolean { + const bucket = Math.floor(now / 60_000); + return this.ctx.storage.transactionSync(() => { + for (const quota of quotas) { + const row = this.ctx.storage.sql + .exec<{ count: number }>( + "SELECT count FROM quota_counter WHERE scope = ? AND subject = ? AND bucket = ?", + quota.scope, + quota.subject, + bucket, + ) + .toArray()[0]; + if ((row?.count ?? 0) >= quota.limit) return false; + } + for (const quota of quotas) { + this.ctx.storage.sql.exec( + `INSERT INTO quota_counter (scope, subject, bucket, count) VALUES (?, ?, ?, 1) + ON CONFLICT(scope, subject, bucket) DO UPDATE SET count = count + 1`, + quota.scope, + quota.subject, + bucket, + ); + } + this.ctx.storage.sql.exec("DELETE FROM quota_counter WHERE bucket < ?", bucket - 2); + return true; + }); + } + + private async scheduleNextAlarm(): Promise { + const now = Date.now(); + const deadline = this.ctx.storage.sql + .exec<{ deadline: number | null }>( + `SELECT MIN(deadline) AS deadline FROM ( + SELECT MIN(idle_expires_at, hard_expires_at) AS deadline + FROM lease WHERE release_at IS NULL + AND status IN ('allocating','provisioning','connected','recovering') + UNION ALL + SELECT provisioning_deadline_at AS deadline + FROM lease WHERE release_at IS NULL AND status IN ('provisioning','recovering') + UNION ALL + SELECT last_seen_at + ${DEVICE_OFFLINE_MS} AS deadline FROM device WHERE enabled = 1 + UNION ALL + SELECT last_seen_at + ${DEVICE_LOST_MS} AS deadline FROM device WHERE enabled = 1 + ) WHERE deadline > ?`, + now, + ) + .toArray()[0]?.deadline; + if (deadline === null || deadline === undefined) { + await this.ctx.storage.deleteAlarm(); + return; + } + await this.ctx.storage.setAlarm(Math.max(now + 1, deadline)); + } +} + +function toLeaseResource(row: LeaseRow): LeaseResource { + return { + sessionId: row.session_id, + leaseId: row.lease_id, + deviceId: row.device_id, + status: row.status, + allowedOrigins: parseStringArray(row.allowed_origins_json), + leaseEpoch: row.lease_epoch, + browserEpoch: row.browser_epoch, + createdAt: row.created_at, + lastActivityAt: row.last_activity_at, + idleExpiresAt: row.idle_expires_at, + hardExpiresAt: row.hard_expires_at, + needsReconciliation: row.needs_reconciliation === 1, + dialogDelivery: row.dialog_delivery, + }; +} + +function parseStringArray(value: string): string[] { + try { + const parsed = JSON.parse(value) as unknown; + return Array.isArray(parsed) && parsed.every((item) => typeof item === "string") + ? parsed + : []; + } catch { + return []; + } +} + +function intersects(left: readonly string[], right: readonly string[]): boolean { + const set = new Set(left); + return right.some((value) => set.has(value)); +} + +function isSubset(values: readonly string[], allowed: readonly string[]): boolean { + const set = new Set(allowed); + return values.every((value) => set.has(value)); +} + +function isTerminal(status: UnattendedSessionLifecycle): boolean { + return status === "closed" || status === "expired" || status === "lost"; +} diff --git a/apps/backend/src/types.ts b/apps/backend/src/types.ts index e9c181b..9f187a7 100644 --- a/apps/backend/src/types.ts +++ b/apps/backend/src/types.ts @@ -8,8 +8,20 @@ * state have exactly one definition each. */ -import type { DialogRecord, Event, TabInfo } from "@understudy/protocol"; +import type { + Command, + CommandState, + DialogDelivery, + DialogRecord, + Event, + PendingCommandResponse, + ProtocolCapability, + TabInfo, + UnattendedSessionLifecycle, +} from "@understudy/protocol"; import type { SessionAgent } from "./session"; +import type { DeviceAgent } from "./device"; +import type { TenantDeviceCoordinator } from "./tenant-coordinator"; /** * The non-tabs fields of the extension's hello event: what it reports about @@ -48,6 +60,8 @@ export interface VaultBinding { export interface Env { /** One Durable Object per sessionId (per tenant/case) - DL-006. */ SESSION: DurableObjectNamespace; + DEVICE: DurableObjectNamespace; + TENANT_CONTROL: DurableObjectNamespace; VAULT: VaultBinding; /** Signs/verifies server-minted sessionIds so scopeSession can verify tenant ownership statelessly (M-006, DL-008). */ AUTH_HMAC_SECRET: string; @@ -61,6 +75,13 @@ export interface Env { CALLER_TOKENS: string; /** Extension per-user token(s) (JSON), verified independently of caller auth. Required via `secrets.required`, like CALLER_TOKENS. */ EXTENSION_TOKENS: string; + DEVICE_TOKENS: string; + WS_TICKET_SECRET: string; + QUOTA_POLICY: string; + UNATTENDED_ENABLED_TENANTS: string; + SAFE_WRITE_REQUIRED_TENANTS: string; + RATE_LIMITER?: RateLimit; + ANALYTICS?: AnalyticsEngineDataset; /** * base64url-encoded 32-byte AES-256-GCM key that envelope-encrypts every * vault value (vault.ts). KV holds only ciphertext; without this secret a @@ -77,10 +98,31 @@ export interface Env { */ export type SessionStatus = "pending" | "connected" | "detached"; +export interface LegacyCommandTombstone { + commandId: string; + commandType: Command["type"]; + requestFingerprint: string; +} + +export interface CompletedLegacyWrite extends LegacyCommandTombstone { + event: Extract; +} + +export type PersistedLegacyAwaiting = + | LegacyCommandTombstone + | { commandId: string }; + +export type PersistedLegacyCommandTombstone = PersistedLegacyAwaiting; + +export type PersistedCompletedLegacyWrite = + | CompletedLegacyWrite + | { commandId: string; event: Event }; + /** * Agents-SDK Durable Object state for one session. Must stay JSON- - * serializable (setState round-trips through JSON): awaitingCommandIds is a - * string array standing in for a Set, not a JS Set/Map (DL-007). + * serializable because setState round-trips through JSON. Legacy + * awaitingCommandIds remain readable while new writes persist typed command + * tombstones. */ export interface SessionState { browser: HelloBrowserInfo | null; @@ -89,6 +131,7 @@ export interface SessionState { /** The refMap generation; bumped on navigation / hello resync. */ generation: number; awaitingCommandIds: string[]; + awaitingCommands?: PersistedLegacyAwaiting[]; status: SessionStatus; /** * The one authenticated extension connection allowed to receive Commands @@ -105,22 +148,42 @@ export interface SessionState { * a write under the same commandId (the connector derives it from the * breakwater idempotency key) gets the recorded Event back instead of a * second execution, closing the write-performed-but-response-lost gap. - * Only ever holds action_results for writes: small, and plaintext-free by - * the DL-004 construction (fill_secret results carry ok/error only). + * New entries hold bounded action_results plus the exact command type and + * request fingerprint. Legacy ID-only entries remain conflict tombstones. + * Fill-secret results carry only ok/error and never plaintext. */ - completedWrites: { commandId: string; event: Event }[]; + completedWrites: PersistedCompletedLegacyWrite[]; + legacyCommandTombstones?: PersistedLegacyCommandTombstone[]; /** * Recent page dialogs the extension handled (alert/confirm/prompt/ * beforeunload), oldest first, capped in session.ts. Surfaced to the consumer * via GET /v1/sessions/:id so an agent/governance layer sees what a page said * and how it was auto-answered. An after-the-fact record, not a response * channel: dialogs are answered synchronously extension-side (an open dialog - * blocks the CDP channel), never by a consumer round-trip. BEST-EFFORT and - * capped: a report emitted while the WS is momentarily down is not replayed - * (the dialog is still answered; only its notification is lost), so this is an - * observability surface, not a guaranteed audit log. + * blocks the CDP channel), never by a consumer round-trip. Protocol 2 + * acknowledges and replays records within one browser epoch. The public + * payload list remains capped, so this is an operational surface rather than + * a durable audit log. */ dialogs: DialogRecord[]; + protocolVersion?: 1 | 2; + capabilities?: ProtocolCapability[]; + mode?: "attended" | "unattended"; + unattended?: { + tenantId: string; + deviceId: string; + leaseId: string; + leaseEpoch: number; + browserEpoch: string; + status: UnattendedSessionLifecycle; + createdAt: string; + lastActivityAt: string; + idleExpiresAt: string; + hardExpiresAt: string; + needsReconciliation: boolean; + dialogDelivery: DialogDelivery; + allowedOrigins: string[]; + }; } /** @@ -134,6 +197,32 @@ export type DispatchOutcome = | { ok: true; event: Event } | { ok: false; - reason: "not_connected" | "timed_out" | "resynced" | "duplicate_in_flight"; + reason: + | "not_connected" + | "timed_out" + | "resynced" + | "duplicate_in_flight" + | "session_busy" + | "terminal_session" + | "id_conflict"; message: string; }; + +export type V2DispatchOutcome = + | { kind: "terminal"; event: Event } + | { kind: "pending"; pending: PendingCommandResponse } + | { kind: "not_started"; commandId: string; safeToRetry: true } + | { kind: "timed_out"; commandId: string; safeToRetry: true } + | { kind: "unknown"; commandId: string; safeToRetry: false } + | { kind: "id_conflict"; commandId: string } + | { kind: "busy"; commandId: string } + | { kind: "not_connected"; commandId: string } + | { kind: "unsupported"; commandId: string } + | { kind: "terminal_session"; commandId: string }; + +export interface CommandStatusRecord { + commandId: string; + status: CommandState; + event?: Event; + safeToRetry: boolean; +} diff --git a/apps/backend/src/validation.ts b/apps/backend/src/validation.ts new file mode 100644 index 0000000..3514b93 --- /dev/null +++ b/apps/backend/src/validation.ts @@ -0,0 +1,167 @@ +import { + COMMAND_HTTP_BODY_MAX_BYTES, + UnattendedSessionRequestSchema, + type UnattendedSessionRequest, +} from "@understudy/protocol"; +import type { z } from "zod"; +import { hashProfileStateKey } from "./auth"; +import type { Env } from "./types"; + +export class RequestBodyError extends Error { + constructor( + message: string, + readonly status: 400 | 413 = 400, + readonly category: "syntax" | "schema" | "size" = "syntax", + ) { + super(message); + } +} + +export async function parseBoundedStrictJson( + request: Request, + schema: T, + maxBytes = COMMAND_HTTP_BODY_MAX_BYTES, +): Promise> { + const declaredLength = request.headers.get("content-length"); + if (declaredLength !== null) { + const length = Number(declaredLength); + if (!Number.isSafeInteger(length) || length < 0) { + throw new RequestBodyError("invalid content-length"); + } + if (length > maxBytes) throw new RequestBodyError("request body too large", 413, "size"); + } + + const reader = request.body?.getReader(); + if (reader === undefined) throw new RequestBodyError("invalid body"); + const chunks: Uint8Array[] = []; + let total = 0; + try { + while (true) { + const item = await reader.read(); + if (item.done) break; + total += item.value.byteLength; + if (total > maxBytes) { + await reader.cancel(); + throw new RequestBodyError("request body too large", 413, "size"); + } + chunks.push(item.value); + } + } finally { + reader.releaseLock(); + } + + const bytes = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + let value: unknown; + try { + value = JSON.parse( + new TextDecoder("utf-8", { fatal: true, ignoreBOM: false }).decode(bytes), + ) as unknown; + } catch { + throw new RequestBodyError("invalid body"); + } + const parsed = schema.safeParse(value); + if (!parsed.success) throw new RequestBodyError("invalid body", 400, "schema"); + return parsed.data; +} + +function isLoopback(hostname: string): boolean { + const normalized = hostname.toLowerCase(); + return ( + normalized === "localhost" || + normalized === "127.0.0.1" || + normalized === "[::1]" || + normalized.endsWith(".localhost") + ); +} + +export function canonicalizeOrigins(origins: readonly string[]): string[] { + const canonical = new Set(); + for (const raw of origins) { + if (raw !== raw.trim()) throw new RequestBodyError("invalid allowed origin"); + if (raw.includes("*")) throw new RequestBodyError("wildcard origins are not allowed"); + if (raw.includes("?") || raw.includes("#")) { + throw new RequestBodyError("allowed origin must not contain query or fragment"); + } + if (/^[a-z][a-z0-9+.-]*:\/\/[^/]*@/i.test(raw)) { + throw new RequestBodyError("allowed origin must not contain credentials"); + } + let url: URL; + try { + url = new URL(raw); + } catch { + throw new RequestBodyError("invalid allowed origin"); + } + if ( + url.username !== "" || + url.password !== "" || + (url.pathname !== "" && url.pathname !== "/") || + url.search !== "" || + url.hash !== "" + ) { + throw new RequestBodyError("allowed origin must not contain credentials, path, query, or fragment"); + } + if (url.protocol !== "https:" && !(url.protocol === "http:" && isLoopback(url.hostname))) { + throw new RequestBodyError("allowed origin must use HTTPS"); + } + canonical.add(url.origin); + } + return [...canonical].sort(); +} + +export interface CanonicalUnattendedRequest + extends Omit { + allowedOrigins: string[]; + profileStateHash: string; + fingerprint: string; +} + +export async function canonicalizeUnattendedRequest( + value: unknown, + tenantId: string, + env: Env, +): Promise { + const request = UnattendedSessionRequestSchema.parse(value); + const allowedOrigins = canonicalizeOrigins(request.allowedOrigins); + const profileStateHash = await hashProfileStateKey(tenantId, request.profileStateKey, env); + const fingerprint = await sha256Hex( + JSON.stringify({ + mode: request.mode, + deviceId: request.deviceId?.toLowerCase() ?? null, + allowedOrigins, + profileStateHash, + }), + ); + return { + mode: "unattended", + ...(request.deviceId === undefined ? {} : { deviceId: request.deviceId.toLowerCase() }), + allowedOrigins, + profileStateHash, + fingerprint, + }; +} + +export async function requestFingerprint(command: unknown, dryRun: boolean): Promise { + return sha256Hex(stableJson({ command, dryRun })); +} + +function stableJson(value: unknown): string { + if (value === null || typeof value !== "object") return JSON.stringify(value); + if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`; + const record = value as Record; + return `{${Object.keys(record) + .sort() + .map((key) => `${JSON.stringify(key)}:${stableJson(record[key])}`) + .join(",")}}`; +} + +async function sha256Hex(value: string): Promise { + const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value)); + return Array.from(new Uint8Array(digest), (byte) => + byte.toString(16).padStart(2, "0"), + ).join(""); +} diff --git a/apps/backend/test/auth.test.ts b/apps/backend/test/auth.test.ts index 5e4923a..da5622f 100644 --- a/apps/backend/test/auth.test.ts +++ b/apps/backend/test/auth.test.ts @@ -1,14 +1,21 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi } from "vitest"; import type { Env } from "../src/types"; import { base64urlEncode } from "../src/base64url"; import { authenticate, + authenticatedRateAllowed, + authenticateDevice, + deviceCredentialExists, isValidTenantId, mintSessionId, + mintWsTicket, scopeSession, tenantOf, verifyExtensionToken, + verifyWsTicket, type Actor, + type DeviceIdentity, + type RateLimitIdentity, } from "../src/auth"; const CALLER_TOKENS: Record = { @@ -24,15 +31,50 @@ const EXTENSION_TOKENS: Record = { function makeEnv(overrides: Partial = {}): Env { return { SESSION: {} as unknown as Env["SESSION"], + DEVICE: {} as unknown as Env["DEVICE"], + TENANT_CONTROL: {} as unknown as Env["TENANT_CONTROL"], VAULT: {} as unknown as Env["VAULT"], AUTH_HMAC_SECRET: "test-hmac-secret-do-not-use-in-prod", CALLER_TOKENS: JSON.stringify(CALLER_TOKENS), EXTENSION_TOKENS: JSON.stringify(EXTENSION_TOKENS), + DEVICE_TOKENS: "{}", + WS_TICKET_SECRET: "test-ticket-secret", + QUOTA_POLICY: "", + UNATTENDED_ENABLED_TENANTS: "[]", + SAFE_WRITE_REQUIRED_TENANTS: "[]", VAULT_MASTER_KEY: "unused-by-auth-tests", ...overrides, }; } +function rateLimitEnv(keys: string[]): Env { + return makeEnv({ + RATE_LIMITER: { + limit: vi.fn(async ({ key }: { key: string }) => { + keys.push(key); + return { success: true }; + }), + } as Env["RATE_LIMITER"], + }); +} + +async function deviceTokenEnv( + credential: string, + identity: Pick, + overrides: Partial = {}, +): Promise { + const digest = Array.from( + new Uint8Array( + await crypto.subtle.digest("SHA-256", new TextEncoder().encode(credential)), + ), + (byte) => byte.toString(16).padStart(2, "0"), + ).join(""); + return makeEnv({ + DEVICE_TOKENS: JSON.stringify({ [digest]: identity }), + ...overrides, + }); +} + function flipChar(value: string): string { const first = value.charAt(0); return (first === "A" ? "B" : "A") + value.slice(1); @@ -238,6 +280,84 @@ describe("authenticate", () => { ); }); +describe("authenticatedRateAllowed", () => { + it("keys accepted caller and device bearer whitespace by authenticated identity", async () => { + const callerKeys: string[] = []; + const callerEnv = rateLimitEnv(callerKeys); + const callerRequests = ["Bearer tok-a", "Bearer tok-a"].map( + (authorization) => + new Request("https://understudy.example/v1/sessions", { + headers: { authorization }, + }), + ); + for (const request of callerRequests) { + const actor = await authenticate(request, callerEnv); + if (actor === null) throw new Error("expected caller identity"); + await authenticatedRateAllowed({ kind: "caller", ...actor }, callerEnv); + } + + const deviceKeys: string[] = []; + const deviceIdentity = { + tenantId: "tenantA", + deviceId: "00000000-0000-4000-8000-000000000001", + credentialVersion: 1, + }; + const deviceEnv = await deviceTokenEnv("device-secret", deviceIdentity, { + RATE_LIMITER: rateLimitEnv(deviceKeys).RATE_LIMITER, + }); + const deviceRequests = ["Bearer device-secret", "Bearer device-secret"].map( + (authorization) => + new Request("https://understudy.example/v1/device/connect-ticket", { + headers: { authorization }, + }), + ); + for (const request of deviceRequests) { + const device = await authenticateDevice(request, deviceEnv); + if (device === null) throw new Error("expected device identity"); + await authenticatedRateAllowed( + { kind: "device", tenantId: device.tenantId, deviceId: device.deviceId }, + deviceEnv, + ); + } + + expect(callerKeys[0]).toBe(callerKeys[1]); + expect(deviceKeys[0]).toBe(deviceKeys[1]); + }); + + it("separates identities by actor, device, tenant, and namespace", async () => { + const keys: string[] = []; + const env = rateLimitEnv(keys); + const sharedId = "00000000-0000-4000-8000-000000000001"; + const identities: RateLimitIdentity[] = [ + { kind: "caller", tenantId: "tenantA", actor: "caller-a" }, + { kind: "caller", tenantId: "tenantA", actor: "caller-b" }, + { kind: "caller", tenantId: "tenantB", actor: "caller-a" }, + { kind: "caller", tenantId: "tenantA", actor: sharedId }, + { kind: "device", tenantId: "tenantA", deviceId: sharedId }, + { + kind: "device", + tenantId: "tenantA", + deviceId: "00000000-0000-4000-8000-000000000002", + }, + ]; + + for (const identity of identities) { + await authenticatedRateAllowed(identity, env); + } + + expect(new Set(keys)).toHaveLength(identities.length); + }); + + it("allows authenticated requests when the limiter binding is absent", async () => { + await expect( + authenticatedRateAllowed( + { kind: "caller", tenantId: "tenantA", actor: "caller-a" }, + makeEnv(), + ), + ).resolves.toBe(true); + }); +}); + describe("verifyExtensionToken", () => { it("returns the tenantId for a valid extension token", async () => { const env = makeEnv(); @@ -257,3 +377,165 @@ describe("verifyExtensionToken", () => { }, ); }); + +describe("device authentication and WebSocket tickets", () => { + it("maps only a configured SHA-256 device credential and detects revocation", async () => { + const credential = "device-secret"; + const digest = Array.from( + new Uint8Array( + await crypto.subtle.digest("SHA-256", new TextEncoder().encode(credential)), + ), + (byte) => byte.toString(16).padStart(2, "0"), + ).join(""); + const env = makeEnv({ + DEVICE_TOKENS: JSON.stringify({ + [digest]: { + tenantId: "tenantA", + deviceId: "00000000-0000-4000-8000-000000000001", + credentialVersion: 2, + }, + }), + }); + const identity = await authenticateDevice( + new Request("https://understudy.example/v1/device/connect-ticket", { + headers: { authorization: `Bearer ${credential}` }, + }), + env, + ); + expect(identity).toMatchObject({ + tenantId: "tenantA", + deviceId: "00000000-0000-4000-8000-000000000001", + credentialVersion: 2, + credentialDigest: digest, + }); + if (identity === null) throw new Error("expected device identity"); + await expect(deviceCredentialExists(digest, identity, env)).resolves.toBe(true); + await expect( + deviceCredentialExists(digest, identity, makeEnv({ DEVICE_TOKENS: "{}" })), + ).resolves.toBe(false); + }); + + it("binds signed session tickets to audience, path agent, expiry, and lease claims", async () => { + const env = makeEnv(); + const now = 1_000_000; + const ticket = await mintWsTicket( + { + aud: "session", + tenantId: "tenantA", + deviceId: "00000000-0000-4000-8000-000000000001", + sessionId: "session-1", + leaseId: "lease-1", + leaseEpoch: 1, + browserEpoch: "browser-1", + agentName: "session-1", + }, + env, + now, + ); + + await expect( + verifyWsTicket( + ticket, + { aud: "session", agentName: "session-1" }, + env, + now, + ), + ).resolves.toMatchObject({ + aud: "session", + sessionId: "session-1", + leaseId: "lease-1", + leaseEpoch: 1, + }); + await expect( + verifyWsTicket( + ticket, + { aud: "device-control", agentName: "session-1" }, + env, + now, + ), + ).resolves.toBeNull(); + await expect( + verifyWsTicket( + ticket, + { aud: "session", agentName: "another-session" }, + env, + now, + ), + ).resolves.toBeNull(); + await expect( + verifyWsTicket( + ticket, + { aud: "session", agentName: "session-1" }, + env, + now + 61_000, + ), + ).resolves.toBeNull(); + await expect( + verifyWsTicket( + `${ticket.slice(0, -1)}${ticket.endsWith("A") ? "B" : "A"}`, + { aud: "session", agentName: "session-1" }, + env, + now, + ), + ).resolves.toBeNull(); + }); + + it("requires credential versions only on device-control tickets", async () => { + const env = makeEnv(); + const now = 1_000_000; + const deviceClaims = { + aud: "device-control" as const, + tenantId: "tenantA", + deviceId: "00000000-0000-4000-8000-000000000001", + leaseEpoch: 0, + browserEpoch: "browser-1", + agentName: "00000000-0000-4000-8000-000000000001", + }; + const versioned = await mintWsTicket( + { ...deviceClaims, credentialVersion: 2 }, + env, + now, + ); + const unversioned = await mintWsTicket(deviceClaims, env, now); + const sessionWithVersion = await mintWsTicket( + { + aud: "session", + tenantId: "tenantA", + deviceId: deviceClaims.deviceId, + credentialVersion: 2, + sessionId: "session-1", + leaseId: "lease-1", + leaseEpoch: 1, + browserEpoch: "browser-1", + agentName: "session-1", + }, + env, + now, + ); + + await expect( + verifyWsTicket( + versioned, + { aud: "device-control", agentName: deviceClaims.agentName }, + env, + now, + ), + ).resolves.toMatchObject({ credentialVersion: 2 }); + await expect( + verifyWsTicket( + unversioned, + { aud: "device-control", agentName: deviceClaims.agentName }, + env, + now, + ), + ).resolves.toBeNull(); + await expect( + verifyWsTicket( + sessionWithVersion, + { aud: "session", agentName: "session-1" }, + env, + now, + ), + ).resolves.toBeNull(); + }); +}); diff --git a/apps/backend/test/coordinator.test.ts b/apps/backend/test/coordinator.test.ts index a3a5d2b..eacc216 100644 --- a/apps/backend/test/coordinator.test.ts +++ b/apps/backend/test/coordinator.test.ts @@ -1,24 +1,45 @@ import { describe, it, expect, vi } from "vitest"; import { CfSessionCoordinator, type CoordinatorHost } from "../src/coordinator-cf"; import type { Command, Event } from "@understudy/protocol"; +import type { LegacyCommandTombstone } from "../src/types"; function createFakeHost(connected = true): CoordinatorHost & { sent: string[] } { - let awaiting: string[] = []; + let awaiting: LegacyCommandTombstone[] = []; const sent: string[] = []; return { sendToExtension: (payload: string) => { sent.push(payload); }, hasAuthorizedConnection: () => connected, - getAwaitingCommandIds: () => awaiting, - persistAwaitingCommandIds: (ids: string[]) => { - awaiting = ids; + getAwaitingCommands: () => awaiting, + persistAwaitingCommands: (commands) => { + awaiting = commands.filter( + (entry): entry is LegacyCommandTombstone => + "commandType" in entry, + ); }, + persistCommandTombstone: () => {}, persistStatus: () => {}, + persistLateResult: () => {}, sent, }; } +function tombstone(command: Command): LegacyCommandTombstone { + return { + commandId: command.commandId, + commandType: command.type, + requestFingerprint: "0".repeat(64), + }; +} + +function send( + coordinator: CfSessionCoordinator, + command: Command, +): Promise { + return coordinator.send(command, tombstone(command)); +} + describe("CfSessionCoordinator", () => { it("resolves send() with the matching result event and clears the marker", async () => { // #given a coordinator and a snapshot command @@ -27,8 +48,8 @@ describe("CfSessionCoordinator", () => { const cmd: Command = { type: "snapshot", commandId: "c1", mode: "a11y" }; // #when send() is called and the matching event arrives - const promise = coordinator.send(cmd); - expect(host.getAwaitingCommandIds()).toEqual(["c1"]); + const promise = send(coordinator, cmd); + expect(host.getAwaitingCommands().map((entry) => entry.commandId)).toEqual(["c1"]); expect(host.sent).toEqual([JSON.stringify(cmd)]); const event: Event = { type: "snapshot_result", @@ -41,10 +62,10 @@ describe("CfSessionCoordinator", () => { // #then the promise resolves with that event and the marker is cleared await expect(promise).resolves.toEqual(event); - expect(host.getAwaitingCommandIds()).toEqual([]); + expect(host.getAwaitingCommands()).toEqual([]); }); - it("rejects with a payload-free error when the per-command timeout fires, and clears the marker", async () => { + it("rejects with a payload-free error at timeout but retains the slot until the late result is reconciled", async () => { // #given a coordinator with a short per-command timeout vi.useFakeTimers(); try { @@ -53,16 +74,20 @@ describe("CfSessionCoordinator", () => { const cmd: Command = { type: "click", commandId: "c2", ref: "r1" }; // #when send() is called and no reply arrives before the timeout - const promise = coordinator.send(cmd); + const promise = send(coordinator, cmd); const caught = promise.catch((err: unknown) => err); await vi.advanceTimersByTimeAsync(1000); const err = await caught; - // #then it rejects with a payload-free error and the marker is cleared + // #then it rejects with a payload-free error while the durable marker + // keeps later commands out of the extension's FIFO. expect(err).toBeInstanceOf(Error); expect((err as Error).message).toBe("command timed out: c2 (click) after 1000ms"); expect((err as Error).message).not.toContain("r1"); - expect(host.getAwaitingCommandIds()).toEqual([]); + expect(host.getAwaitingCommands().map((entry) => entry.commandId)).toEqual(["c2"]); + + coordinator.resolvePending({ type: "action_result", commandId: "c2", ok: true }); + expect(host.getAwaitingCommands()).toEqual([]); } finally { vi.useRealTimers(); } @@ -75,14 +100,14 @@ describe("CfSessionCoordinator", () => { const cmd: Command = { type: "get_tabs", commandId: "c-fast" }; // #when send() is called - const err = await coordinator.send(cmd).catch((e: unknown) => e); + const err = await send(coordinator, cmd).catch((e: unknown) => e); // #then it rejects with the route-mappable prefix before parking anything expect(err).toBeInstanceOf(Error); expect((err as Error).message).toBe( "session not connected: no authorized extension connection", ); - expect(host.getAwaitingCommandIds()).toEqual([]); + expect(host.getAwaitingCommands()).toEqual([]); expect(host.sent).toEqual([]); }); @@ -99,7 +124,7 @@ describe("CfSessionCoordinator", () => { const cmd: Command = { type: "get_tabs", commandId: "c-send-race" }; // #when the coordinator attempts delivery - const err = await coordinator.send(cmd).catch((e: unknown) => e); + const err = await send(coordinator, cmd).catch((e: unknown) => e); // #then it maps to the existing not-connected family and immediately // clears the timer, in-memory pending entry, and persisted marker @@ -107,7 +132,7 @@ describe("CfSessionCoordinator", () => { expect((err as Error).message).toBe( "session not connected: authoritative extension connection unavailable during send", ); - expect(host.getAwaitingCommandIds()).toEqual([]); + expect(host.getAwaitingCommands()).toEqual([]); expect(vi.getTimerCount()).toBe(0); // #then the same commandId can dispatch after reconnect; no stale @@ -115,7 +140,7 @@ describe("CfSessionCoordinator", () => { host.sendToExtension = (payload: string) => { host.sent.push(payload); }; - const retry = coordinator.send(cmd); + const retry = send(coordinator, cmd); expect(host.sent).toEqual([JSON.stringify(cmd)]); coordinator.resolvePending({ type: "tabs_result", commandId: cmd.commandId, tabs: [] }); await expect(retry).resolves.toEqual({ @@ -137,7 +162,7 @@ describe("CfSessionCoordinator", () => { // #when resolvePending is called for a commandId that was never sent // #then it does not throw and leaves the marker untouched expect(() => coordinator.resolvePending(event)).not.toThrow(); - expect(host.getAwaitingCommandIds()).toEqual([]); + expect(host.getAwaitingCommands()).toEqual([]); }); it("reconciles a late result whose marker survived a simulated hibernation, without resolving anything", () => { @@ -150,8 +175,8 @@ describe("CfSessionCoordinator", () => { const host = createFakeHost(); const first = new CfSessionCoordinator(host); const cmd: Command = { type: "get_tabs", commandId: "c3" }; - void first.send(cmd); - expect(host.getAwaitingCommandIds()).toEqual(["c3"]); + void send(first, cmd); + expect(host.getAwaitingCommands().map((entry) => entry.commandId)).toEqual(["c3"]); const woken = new CfSessionCoordinator(host); const event: Event = { type: "tabs_result", commandId: "c3", tabs: [] }; @@ -159,13 +184,13 @@ describe("CfSessionCoordinator", () => { // #when the late result arrives at the woken (fresh) coordinator // #then it reconciles the marker and does not throw expect(() => woken.resolvePending(event)).not.toThrow(); - expect(host.getAwaitingCommandIds()).toEqual([]); + expect(host.getAwaitingCommands()).toEqual([]); } finally { vi.useRealTimers(); } }); - it("logs only commandId and type - never a fill_secret's secretRef or a type command's plaintext", () => { + it("does not log command metadata, refs, or plaintext", async () => { // #given a spy on console.log and two sensitive commands const host = createFakeHost(); const coordinator = new CfSessionCoordinator(host); @@ -183,45 +208,57 @@ describe("CfSessionCoordinator", () => { text: "hunter2-plaintext", }; + let second: Promise | undefined; try { - // #when both commands are sent - void coordinator.send(fillSecret); - void coordinator.send(typeCmd); - - // #then every logged call carries exactly {commandId, type} - never ref/secretRef/text - const loggedMetadata = logSpy.mock.calls.map((call) => call[1]); - expect(loggedMetadata).toContainEqual({ commandId: "c4", type: "fill_secret" }); - expect(loggedMetadata).toContainEqual({ commandId: "c5", type: "type" }); - - const serializedCalls = JSON.stringify(logSpy.mock.calls); - expect(serializedCalls).not.toContain("vault://super-secret-password"); - expect(serializedCalls).not.toContain("hunter2-plaintext"); - expect(serializedCalls).not.toContain("s1e2"); - expect(serializedCalls).not.toContain("s1e3"); + // #when both commands are sent one at a time + const first = send(coordinator, fillSecret); + coordinator.resolvePending({ type: "action_result", commandId: "c4", ok: true }); + await first; + second = send(coordinator, typeCmd); + + // #then command execution emits no ad hoc logs. The HTTP boundary owns + // bounded, pseudonymized command telemetry through the shared emitter. + expect(logSpy).not.toHaveBeenCalled(); } finally { logSpy.mockRestore(); - coordinator.resolvePending({ type: "action_result", commandId: "c4", ok: true }); coordinator.resolvePending({ type: "action_result", commandId: "c5", ok: true }); + if (second !== undefined) await second; } }); - it("abandonInFlight rejects every pending command and clears the marker", async () => { - // #given two outstanding commands + it("abandonInFlight rejects the pending command and clears the marker", async () => { + // #given one outstanding command const host = createFakeHost(); const coordinator = new CfSessionCoordinator(host); const cmdA: Command = { type: "get_tabs", commandId: "c6" }; - const cmdB: Command = { type: "snapshot", commandId: "c7", mode: "dom" }; - const promiseA = coordinator.send(cmdA); - const promiseB = coordinator.send(cmdB); - expect(host.getAwaitingCommandIds()).toEqual(["c6", "c7"]); + const promiseA = send(coordinator, cmdA); + expect(host.getAwaitingCommands().map((entry) => entry.commandId)).toEqual(["c6"]); // #when a fresh hello resync abandons in-flight commands coordinator.abandonInFlight("session resynced: hello received"); // #then both reject with the given reason and the marker is cleared await expect(promiseA).rejects.toThrow("session resynced: hello received"); - await expect(promiseB).rejects.toThrow("session resynced: hello received"); - expect(host.getAwaitingCommandIds()).toEqual([]); + expect(host.getAwaitingCommands()).toEqual([]); + }); + + it("refuses a distinct command while another command owns the session slot", async () => { + const host = createFakeHost(); + const coordinator = new CfSessionCoordinator(host); + const firstCommand: Command = { type: "get_tabs", commandId: "c-busy-a" }; + const first = send(coordinator, firstCommand); + + await expect( + send(coordinator, { + type: "snapshot", + commandId: "c-busy-b", + mode: "a11y", + }), + ).rejects.toThrow("session busy: another command owns the session slot"); + expect(host.sent).toHaveLength(1); + + coordinator.resolvePending({ type: "tabs_result", commandId: "c-busy-a", tabs: [] }); + await first; }); it("refuses a second send for a commandId already in flight, leaving the first undisturbed", async () => { @@ -229,11 +266,11 @@ describe("CfSessionCoordinator", () => { const host = createFakeHost(); const coordinator = new CfSessionCoordinator(host); const cmd: Command = { type: "click", commandId: "c-dup", ref: "r1" }; - const first = coordinator.send(cmd); + const first = send(coordinator, cmd); // #when the same commandId is sent again mid-flight (stable consumer- // derived ids make this reachable) - const err = await coordinator.send(cmd).catch((e: unknown) => e); + const err = await send(coordinator, cmd).catch((e: unknown) => e); // #then the duplicate is refused with the mappable prefix, nothing extra // hit the wire, and the original still resolves normally @@ -244,6 +281,6 @@ describe("CfSessionCoordinator", () => { const event: Event = { type: "action_result", commandId: "c-dup", ok: true }; coordinator.resolvePending(event); await expect(first).resolves.toEqual(event); - expect(host.getAwaitingCommandIds()).toEqual([]); + expect(host.getAwaitingCommands()).toEqual([]); }); }); diff --git a/apps/backend/test/device.test.ts b/apps/backend/test/device.test.ts new file mode 100644 index 0000000..de8a1e0 --- /dev/null +++ b/apps/backend/test/device.test.ts @@ -0,0 +1,264 @@ +import { env } from "cloudflare:workers"; +import { getAgentByName } from "agents"; +import type { Connection, ConnectionContext } from "agents"; +import { PROTOCOL_CAPABILITIES, PROTOCOL_VERSION } from "@understudy/protocol"; +import { runInDurableObject } from "cloudflare:test"; +import { describe, expect, it, vi } from "vitest"; +import { mintWsTicket, type DeviceIdentity } from "../src/auth"; +import type { DeviceAgent } from "../src/device"; + +const TENANT_ID = "tenantA"; + +function identity( + deviceId: string, + version: number, + digestByte: string, +): DeviceIdentity { + return { + tenantId: TENANT_ID, + deviceId, + credentialVersion: version, + credentialDigest: digestByte.repeat(64), + }; +} + +async function ticket( + deviceId: string, + version: number, + browserEpoch: string, +): Promise { + return mintWsTicket( + { + aud: "device-control", + tenantId: TENANT_ID, + deviceId, + credentialVersion: version, + leaseEpoch: 0, + browserEpoch, + agentName: deviceId, + }, + env, + ); +} + +function fakeConnection(id: string): { + connection: Connection; + close: ReturnType; + send: ReturnType; +} { + const holder: { id: string; state: unknown } = { id, state: null }; + const close = vi.fn(); + const send = vi.fn(); + return { + connection: Object.assign(holder, { + close, + send, + setState(next: unknown) { + holder.state = next; + }, + }) as unknown as Connection, + close, + send, + }; +} + +function context(deviceId: string, ticketValue: string): ConnectionContext { + return { + request: new Request( + `https://understudy.example/agents/device/${deviceId}?ticket=${ticketValue}`, + ), + } as ConnectionContext; +} + +describe("DeviceAgent authority fencing", () => { + it("rejects an unconsumed ticket after its credential version rotates", async () => { + const deviceId = crypto.randomUUID(); + const stub = await getAgentByName(env.DEVICE, deviceId); + const oldTicket = await ticket(deviceId, 1, "browser-1"); + const candidate = fakeConnection("old-ticket"); + + await runInDurableObject(stub, async (instance: DeviceAgent) => { + await expect( + instance.authorizeCredential(identity(deviceId, 1, "a")), + ).resolves.toBe(true); + await expect( + instance.authorizeCredential(identity(deviceId, 2, "b")), + ).resolves.toBe(true); + await instance.onConnect( + candidate.connection, + context(deviceId, oldTicket), + ); + }); + + expect(candidate.close).toHaveBeenCalledWith( + 1008, + "invalid or replayed device ticket", + ); + expect(candidate.send).not.toHaveBeenCalled(); + }); + + it("promotes and replaces synchronously before later asynchronous work", async () => { + const deviceId = crypto.randomUUID(); + const stub = await getAgentByName(env.DEVICE, deviceId); + const first = fakeConnection("first"); + const second = fakeConnection("second"); + const firstTicket = await ticket(deviceId, 1, "browser-1"); + const secondTicket = await ticket(deviceId, 1, "browser-1"); + + await runInDurableObject(stub, async (instance: DeviceAgent) => { + Object.assign(instance, { + getConnections: () => [first.connection, second.connection], + }); + await instance.authorizeCredential(identity(deviceId, 1, "a")); + await instance.onConnect( + first.connection, + context(deviceId, firstTicket), + ); + await instance.onConnect( + second.connection, + context(deviceId, secondTicket), + ); + + expect(instance.state.activeConnectionId).toBe(second.connection.id); + }); + + expect(first.close).toHaveBeenCalledWith( + 4001, + "replaced by newer authorized device connection", + ); + expect(second.close).not.toHaveBeenCalled(); + }); + + it("acknowledges first, replayed, and lost closures while emitting release telemetry only once", async () => { + const deviceId = crypto.randomUUID(); + const browserEpoch = "browser-closure"; + const stub = await getAgentByName(env.DEVICE, deviceId); + const candidate = fakeConnection("closure"); + const deviceTicket = await ticket(deviceId, 1, browserEpoch); + + await runInDurableObject(stub, async (instance: DeviceAgent) => { + Object.assign(instance, { + getConnections: () => [candidate.connection], + }); + await instance.authorizeCredential(identity(deviceId, 1, "a")); + await instance.onConnect( + candidate.connection, + context(deviceId, deviceTicket), + ); + await instance.onMessage( + candidate.connection, + JSON.stringify({ + type: "device_hello", + protocolVersion: PROTOCOL_VERSION, + capabilities: [...PROTOCOL_CAPABILITIES], + deviceId, + browserEpoch, + browser: "Chrome/125", + extVersion: "0.1.0", + allowedOrigins: ["https://app.example"], + }), + ); + }); + + const coordinator = env.TENANT_CONTROL.getByName(TENANT_ID); + const allocation = await coordinator.createLease({ + idempotencyKey: crypto.randomUUID(), + fingerprint: "f".repeat(64), + sessionId: `session-${crypto.randomUUID()}`, + deviceId, + allowedOrigins: ["https://app.example"], + profileStateHash: crypto.randomUUID(), + actorPseudonym: "actor", + }); + if (allocation.kind !== "created") { + throw new Error("expected created lease"); + } + const session = await getAgentByName( + env.SESSION, + allocation.lease.sessionId, + ); + await session.initializeUnattended(TENANT_ID, allocation.lease); + const frame = { + type: "closed", + sessionId: allocation.lease.sessionId, + leaseId: allocation.lease.leaseId, + leaseEpoch: allocation.lease.leaseEpoch, + browserEpoch: allocation.lease.browserEpoch, + } as const; + const acknowledgement = { + ...frame, + type: "closed_ack", + } as const; + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + try { + await runInDurableObject(stub, async (instance: DeviceAgent) => { + await instance.onMessage(candidate.connection, JSON.stringify(frame)); + }); + expect(await session.getStatus()).toMatchObject({ + mode: "unattended", + status: "closed", + }); + expect(candidate.send).toHaveBeenLastCalledWith( + JSON.stringify(acknowledgement), + ); + + await runInDurableObject(stub, async (instance: DeviceAgent) => { + await instance.onMessage(candidate.connection, JSON.stringify(frame)); + }); + expect( + candidate.send.mock.calls.map(([raw]) => JSON.parse(raw as string)), + ).toEqual([ + acknowledgement, + acknowledgement, + ]); + + const lostAllocation = await coordinator.createLease({ + idempotencyKey: crypto.randomUUID(), + fingerprint: "e".repeat(64), + sessionId: `session-${crypto.randomUUID()}`, + deviceId, + allowedOrigins: ["https://app.example"], + profileStateHash: crypto.randomUUID(), + actorPseudonym: "actor", + }); + if (lostAllocation.kind !== "created") { + throw new Error("expected created lease"); + } + const lostSession = await getAgentByName( + env.SESSION, + lostAllocation.lease.sessionId, + ); + await lostSession.initializeUnattended(TENANT_ID, lostAllocation.lease); + await coordinator.revokeDevice(deviceId); + const lostFrame = { + type: "closed", + sessionId: lostAllocation.lease.sessionId, + leaseId: lostAllocation.lease.leaseId, + leaseEpoch: lostAllocation.lease.leaseEpoch, + browserEpoch: lostAllocation.lease.browserEpoch, + } as const; + await runInDurableObject(stub, async (instance: DeviceAgent) => { + await instance.onMessage( + candidate.connection, + JSON.stringify(lostFrame), + ); + }); + expect(await lostSession.getStatus()).toMatchObject({ + mode: "unattended", + status: "lost", + }); + expect(candidate.send).toHaveBeenLastCalledWith( + JSON.stringify({ ...lostFrame, type: "closed_ack" }), + ); + + const releaseTelemetry = log.mock.calls + .map(([raw]) => JSON.parse(raw as string) as { + telemetry?: { event?: string }; + }) + .filter((entry) => entry.telemetry?.event === "release"); + expect(releaseTelemetry).toHaveLength(1); + } finally { + log.mockRestore(); + } + }); +}); diff --git a/apps/backend/test/env.d.ts b/apps/backend/test/env.d.ts index 8857d02..ac74294 100644 --- a/apps/backend/test/env.d.ts +++ b/apps/backend/test/env.d.ts @@ -20,7 +20,7 @@ declare namespace Cloudflare { interface Env extends BackendEnv {} interface GlobalProps { mainModule: typeof import("../src/index"); - durableNamespaces: "SessionAgent"; + durableNamespaces: "SessionAgent" | "DeviceAgent" | "TenantDeviceCoordinator"; } } diff --git a/apps/backend/test/service.test.ts b/apps/backend/test/service.test.ts index bc826e9..4a48cc6 100644 --- a/apps/backend/test/service.test.ts +++ b/apps/backend/test/service.test.ts @@ -1,8 +1,14 @@ import { describe, it, expect, vi } from "vitest"; import { env, exports } from "cloudflare:workers"; import { runInDurableObject } from "cloudflare:test"; -import { safeParseCommand, safeParseEvent } from "@understudy/protocol"; -import type { Command } from "@understudy/protocol"; +import { + PROTOCOL_CAPABILITIES, + PROTOCOL_VERSION, + safeParseCommand, + safeParseEvent, + safeParseSessionServerFrame, +} from "@understudy/protocol"; +import type { Command, SessionServerFrame } from "@understudy/protocol"; import type { SessionAgent } from "../src/session"; import type { SessionStatus } from "../src/types"; import { encryptSecret } from "../src/vault"; @@ -54,6 +60,24 @@ function postCommand( ); } +function postCommandV2( + sessionId: string, + token: string, + command: unknown, + dryRun = false, +): Promise { + return exports.default.fetch( + authedRequest(`/v1/sessions/${sessionId}/commands`, token, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Understudy-Command-Contract": "2", + }, + body: JSON.stringify({ command, dryRun }), + }), + ); +} + async function openSession(callerToken: string): Promise { const res = await exports.default.fetch( authedRequest("/v1/sessions", callerToken, { method: "POST" }), @@ -72,6 +96,41 @@ async function openIdempotentSession(callerToken: string, idempotencyKey: string ); } +async function initializeUnattendedSession() { + const sessionId = await openSession(CALLER_TOKEN_A); + const deviceId = crypto.randomUUID(); + const browserEpoch = `browser-${crypto.randomUUID()}`; + const allowedOrigin = `https://${deviceId}.example`; + const coordinator = env.TENANT_CONTROL.getByName("tenantA"); + expect( + await coordinator.registerDevice({ + deviceId, + browser: "Chrome/125", + extVersion: "0.1.0", + browserEpoch, + credentialDigest: "a".repeat(64), + credentialVersion: 1, + allowedOrigins: [allowedOrigin], + capabilities: ["safe-write-v2"], + }), + ).toMatchObject({ accepted: true }); + const allocation = await coordinator.createLease({ + idempotencyKey: crypto.randomUUID(), + fingerprint: crypto.randomUUID().replaceAll("-", "").repeat(2), + sessionId, + deviceId, + allowedOrigins: [allowedOrigin], + profileStateHash: crypto.randomUUID(), + actorPseudonym: "actor", + }); + if (allocation.kind !== "created") { + throw new Error("expected created lease"); + } + const session = await getSessionStub(sessionId); + await session.initializeUnattended("tenantA", allocation.lease); + return { coordinator, sessionId, deviceId, lease: allocation.lease }; +} + /** Opens the fake-extension WS at the real onConnect-authed route (DL-006 critical fact). */ async function connectFakeExtension(sessionId: string, token = EXTENSION_TOKEN_A): Promise { const res = await exports.default.fetch( @@ -84,6 +143,48 @@ async function connectFakeExtension(sessionId: string, token = EXTENSION_TOKEN_A return socket; } +async function connectSafeExtension(sessionId: string): Promise { + const socket = await connectFakeExtension(sessionId); + socket.send( + JSON.stringify({ + type: "hello", + protocolVersion: PROTOCOL_VERSION, + capabilities: [...PROTOCOL_CAPABILITIES], + browser: "Chrome/125", + extVersion: "0.1.0", + tabs: [ + { + tabId: 7, + url: "https://example.com/", + title: "Example", + active: true, + }, + ], + }), + ); + const stub = await getSessionStub(sessionId); + expect(await stub.waitForProtocolV2Connection(2_000)).toBe(true); + return socket; +} + +function waitForServerFrame( + socket: WebSocket, + type: T, +): Promise> { + return new Promise((resolve, reject) => { + const timeout = setTimeout( + () => reject(new Error(`timed out waiting for ${type}`)), + 10_000, + ); + socket.addEventListener("message", (event: MessageEvent) => { + const parsed = safeParseSessionServerFrame(JSON.parse(event.data as string)); + if (!parsed.success || parsed.data.type !== type) return; + clearTimeout(timeout); + resolve(parsed.data as Extract); + }); + }); +} + /** * The Agents SDK broadcasts its own framework messages (cf_agent_identity, * cf_agent_state, cf_agent_mcp_servers - state sync, sent on connect and on @@ -274,6 +375,63 @@ describe("GET /v1/sessions/:sessionId", () => { expect(res.status).not.toBe(403); expect(await res.json()).toEqual({ error: "not found" }); }); + + it("keeps an unattended closing session pollable after DELETE", async () => { + const { sessionId } = await initializeUnattendedSession(); + + const deleted = await exports.default.fetch( + authedRequest(`/v1/sessions/${sessionId}`, CALLER_TOKEN_A, { + method: "DELETE", + }), + ); + expect(deleted.status).toBe(202); + expect(deleted.headers.get("Location")).toBe( + new URL(`/v1/sessions/${encodeURIComponent(sessionId)}`, BASE).toString(), + ); + + const status = await exports.default.fetch( + authedRequest(`/v1/sessions/${sessionId}`, CALLER_TOKEN_A), + ); + expect(status.status).toBe(200); + expect(await status.json()).toMatchObject({ + mode: "unattended", + status: "closing", + }); + }); + + it.each(["closed", "expired", "lost"] as const)( + "returns 410 for an unattended %s session", + async (terminalStatus) => { + const { coordinator, sessionId, deviceId, lease } = + await initializeUnattendedSession(); + if (terminalStatus === "closed") { + expect( + await coordinator.confirmClosed({ + sessionId, + leaseId: lease.leaseId, + deviceId, + leaseEpoch: lease.leaseEpoch, + browserEpoch: lease.browserEpoch, + }), + ).toEqual({ status: "closed", newlyClosed: true }); + } else if (terminalStatus === "expired") { + expect( + await coordinator.getLease(sessionId, lease.idleExpiresAt), + ).toMatchObject({ status: "expired" }); + } else { + expect(await coordinator.revokeDevice(deviceId)).toBe(true); + } + + const status = await exports.default.fetch( + authedRequest(`/v1/sessions/${sessionId}`, CALLER_TOKEN_A), + ); + expect(status.status).toBe(410); + expect(await status.json()).toMatchObject({ + mode: "unattended", + status: terminalStatus, + }); + }, + ); }); describe("command parsing", () => { @@ -298,6 +456,44 @@ describe("command parsing", () => { socket.close(1000, "done"); } }); + + it.each([ + { + label: "truthy-looking dryRun", + body: { + command: { type: "click", commandId: "strict-dry-run", ref: "r1" }, + dryRun: "true", + }, + }, + { + label: "unknown request field", + body: { + command: { type: "get_tabs", commandId: "strict-extra" }, + dryRun: false, + unexpected: true, + }, + }, + ])("rejects $label with zero WebSocket traffic", async ({ body }) => { + const sessionId = await openSession(CALLER_TOKEN_A); + const socket = await connectFakeExtension(sessionId); + const received = collectCommands(socket); + try { + const response = await exports.default.fetch( + authedRequest(`/v1/sessions/${sessionId}/commands`, CALLER_TOKEN_A, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }), + ); + + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ error: "invalid command" }); + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(received).toEqual([]); + } finally { + socket.close(1000, "done"); + } + }); }); describe("command round-trip via a live extension WebSocket", () => { @@ -346,6 +542,445 @@ describe("command round-trip via a live extension WebSocket", () => { }); }); +describe("attended session retirement", () => { + it("retires authority on the first DELETE and rejects every later command or reconnect", async () => { + const sessionId = await openSession(CALLER_TOKEN_A); + const socket = await connectFakeExtension(sessionId); + const closeFrame = waitForServerFrame(socket, "close_session"); + const socketClosed = waitForSocketClose(socket); + + const first = await exports.default.fetch( + authedRequest(`/v1/sessions/${sessionId}`, CALLER_TOKEN_A, { + method: "DELETE", + }), + ); + expect(first.status).toBe(204); + expect(await closeFrame).toEqual({ + type: "close_session", + closeTab: false, + }); + expect(await socketClosed).toEqual({ + code: 4003, + reason: "session deleted", + }); + + const terminalStatus = await exports.default.fetch( + authedRequest(`/v1/sessions/${sessionId}`, CALLER_TOKEN_A), + ); + expect(terminalStatus.status).toBe(410); + expect(await terminalStatus.json()).toMatchObject({ + status: "detached", + }); + + const repeated = await exports.default.fetch( + authedRequest(`/v1/sessions/${sessionId}`, CALLER_TOKEN_A, { + method: "DELETE", + }), + ); + expect(repeated.status).toBe(204); + + const legacy = await postCommand(sessionId, CALLER_TOKEN_A, { + type: "get_tabs", + commandId: "after-delete-legacy", + }); + expect(legacy.status).toBe(410); + expect(await legacy.json()).toEqual({ error: "session is terminal" }); + + const v2 = await postCommandV2(sessionId, CALLER_TOKEN_A, { + type: "get_tabs", + commandId: "after-delete-v2", + }); + expect(v2.status).toBe(410); + expect(await v2.json()).toEqual({ error: "session is terminal" }); + + const vaultGetSpy = vi.spyOn(env.VAULT, "get"); + const fill = await postCommand(sessionId, CALLER_TOKEN_A, { + type: "fill_secret", + commandId: "after-delete-fill", + ref: "owned-ref", + secretRef: "vault://tenantA/terminal", + }); + expect(fill.status).toBe(410); + expect(vaultGetSpy).not.toHaveBeenCalled(); + vaultGetSpy.mockRestore(); + + const reconnectResponse = await exports.default.fetch( + new Request( + `${BASE}/agents/session/${sessionId}?token=${EXTENSION_TOKEN_A}`, + { headers: { Upgrade: "websocket" } }, + ), + ); + const reconnect = getWebSocket(reconnectResponse); + const reconnectClosed = waitForSocketClose(reconnect); + reconnect.accept(); + expect(await reconnectClosed).toEqual({ + code: 4003, + reason: "session deleted", + }); + }); + + it("abandons an admitted legacy command as terminal when DELETE races its result", async () => { + const sessionId = await openSession(CALLER_TOKEN_A); + const socket = await connectFakeExtension(sessionId); + const incoming = waitForCommand(socket); + const command = postCommand(sessionId, CALLER_TOKEN_A, { + type: "get_tabs", + commandId: "delete-race-legacy", + }); + await incoming; + + const deleted = await exports.default.fetch( + authedRequest(`/v1/sessions/${sessionId}`, CALLER_TOKEN_A, { + method: "DELETE", + }), + ); + expect(deleted.status).toBe(204); + const response = await command; + expect(response.status).toBe(410); + expect(await response.json()).toEqual({ error: "session is terminal" }); + }); + + it("never grants a prepared v2 write after DELETE", async () => { + const sessionId = await openSession(CALLER_TOKEN_A); + const socket = await connectSafeExtension(sessionId); + const observed: SessionServerFrame[] = []; + socket.addEventListener("message", (event: MessageEvent) => { + const parsed = safeParseSessionServerFrame(JSON.parse(event.data as string)); + if (parsed.success) observed.push(parsed.data); + }); + const preparePromise = waitForServerFrame(socket, "write_prepare"); + const command = postCommandV2(sessionId, CALLER_TOKEN_A, { + type: "click", + commandId: "delete-race-v2", + ref: "owned-ref", + }); + const prepare = await preparePromise; + + const deleted = await exports.default.fetch( + authedRequest(`/v1/sessions/${sessionId}`, CALLER_TOKEN_A, { + method: "DELETE", + }), + ); + expect(deleted.status).toBe(204); + expect(prepare.commandId).toBe("delete-race-v2"); + const response = await command; + expect(response.status).toBe(410); + await new Promise((resolve) => setTimeout(resolve, 50)); + expect( + observed.filter( + (frame) => + frame.type === "write_grant" && + frame.command.commandId === "delete-race-v2", + ), + ).toEqual([]); + }); +}); + +describe("command contract v2", () => { + it("durably prepares and grants a write before accepting its result", async () => { + const sessionId = await openSession(CALLER_TOKEN_A); + const socket = await connectSafeExtension(sessionId); + try { + const preparePromise = waitForServerFrame(socket, "write_prepare"); + const responsePromise = postCommandV2(sessionId, CALLER_TOKEN_A, { + type: "click", + commandId: "v2-write", + ref: "owned-ref", + }); + const prepare = await preparePromise; + const grantPromise = waitForServerFrame(socket, "write_grant"); + socket.send( + JSON.stringify({ + type: "write_ready", + attemptId: prepare.attemptId, + commandId: prepare.commandId, + deadlineAt: prepare.deadlineAt, + requestFingerprint: prepare.requestFingerprint, + }), + ); + const grant = await grantPromise; + expect(grant.command).toEqual({ + type: "click", + commandId: "v2-write", + ref: "owned-ref", + }); + socket.send( + JSON.stringify({ + type: "command_result", + attemptId: grant.attemptId, + commandId: "v2-write", + event: { + type: "action_result", + commandId: "v2-write", + ok: true, + }, + }), + ); + + const response = await responsePromise; + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + type: "action_result", + commandId: "v2-write", + ok: true, + }); + } finally { + socket.close(1000, "done"); + } + }); + + it("conflicts a reused command ID with a changed request fingerprint", async () => { + const sessionId = await openSession(CALLER_TOKEN_A); + const socket = await connectSafeExtension(sessionId); + try { + const preparePromise = waitForServerFrame(socket, "write_prepare"); + const firstResponse = postCommandV2(sessionId, CALLER_TOKEN_A, { + type: "click", + commandId: "v2-conflict", + ref: "first-ref", + }); + const prepare = await preparePromise; + const grantPromise = waitForServerFrame(socket, "write_grant"); + socket.send( + JSON.stringify({ + type: "write_ready", + attemptId: prepare.attemptId, + commandId: prepare.commandId, + deadlineAt: prepare.deadlineAt, + requestFingerprint: prepare.requestFingerprint, + }), + ); + const grant = await grantPromise; + socket.send( + JSON.stringify({ + type: "command_result", + attemptId: grant.attemptId, + commandId: "v2-conflict", + event: { + type: "action_result", + commandId: "v2-conflict", + ok: true, + }, + }), + ); + expect((await firstResponse).status).toBe(200); + + const conflict = await postCommandV2(sessionId, CALLER_TOKEN_A, { + type: "click", + commandId: "v2-conflict", + ref: "changed-ref", + }); + expect(conflict.status).toBe(409); + expect(await conflict.json()).toEqual({ + code: "command_id_conflict", + commandId: "v2-conflict", + }); + } finally { + socket.close(1000, "done"); + } + }); + + it("returns session_busy for a distinct command while one attempt owns the slot", async () => { + const sessionId = await openSession(CALLER_TOKEN_A); + const socket = await connectSafeExtension(sessionId); + try { + const preparePromise = waitForServerFrame(socket, "write_prepare"); + const firstResponse = postCommandV2(sessionId, CALLER_TOKEN_A, { + type: "click", + commandId: "v2-busy-a", + ref: "first-ref", + }); + const prepare = await preparePromise; + + const busy = await postCommandV2(sessionId, CALLER_TOKEN_A, { + type: "key", + commandId: "v2-busy-b", + keys: "Escape", + }); + expect(busy.status).toBe(429); + expect(await busy.json()).toEqual({ + code: "session_busy", + commandId: "v2-busy-b", + }); + + const grantPromise = waitForServerFrame(socket, "write_grant"); + socket.send( + JSON.stringify({ + type: "write_ready", + attemptId: prepare.attemptId, + commandId: prepare.commandId, + deadlineAt: prepare.deadlineAt, + requestFingerprint: prepare.requestFingerprint, + }), + ); + const grant = await grantPromise; + socket.send( + JSON.stringify({ + type: "command_result", + attemptId: grant.attemptId, + commandId: "v2-busy-a", + event: { + type: "action_result", + commandId: "v2-busy-a", + ok: true, + }, + }), + ); + expect((await firstResponse).status).toBe(200); + } finally { + socket.close(1000, "done"); + } + }); + + it("never returns 202 to a legacy connector after the extension selects protocol v2", async () => { + const sessionId = await openSession(CALLER_TOKEN_A); + const socket = await connectSafeExtension(sessionId); + try { + const preparePromise = waitForServerFrame(socket, "write_prepare"); + const legacyResponse = postCommand(sessionId, CALLER_TOKEN_A, { + type: "click", + commandId: "legacy-on-v2", + ref: "owned-ref", + }); + const prepare = await preparePromise; + const grantPromise = waitForServerFrame(socket, "write_grant"); + socket.send( + JSON.stringify({ + type: "write_ready", + attemptId: prepare.attemptId, + commandId: prepare.commandId, + deadlineAt: prepare.deadlineAt, + requestFingerprint: prepare.requestFingerprint, + }), + ); + const grant = await grantPromise; + socket.send( + JSON.stringify({ + type: "command_result", + attemptId: grant.attemptId, + commandId: "legacy-on-v2", + event: { + type: "action_result", + commandId: "legacy-on-v2", + ok: true, + }, + }), + ); + const response = await legacyResponse; + expect(response.status).toBe(200); + expect(response.status).not.toBe(202); + } finally { + socket.close(1000, "done"); + } + }); + + it( + "atomically wins the prepare timeout and never grants a late-ready write", + async () => { + const sessionId = await openSession(CALLER_TOKEN_A); + const socket = await connectSafeExtension(sessionId); + const frames: SessionServerFrame[] = []; + socket.addEventListener("message", (event: MessageEvent) => { + const parsed = safeParseSessionServerFrame(JSON.parse(event.data as string)); + if (parsed.success) frames.push(parsed.data); + }); + try { + const preparePromise = waitForServerFrame(socket, "write_prepare"); + const responsePromise = postCommandV2(sessionId, CALLER_TOKEN_A, { + type: "click", + commandId: "v2-safe-timeout", + ref: "owned-ref", + }); + const prepare = await preparePromise; + const response = await responsePromise; + expect(response.status).toBe(504); + expect(await response.json()).toEqual({ + code: "command_not_started", + commandId: "v2-safe-timeout", + safeToRetry: true, + }); + + socket.send( + JSON.stringify({ + type: "write_ready", + attemptId: prepare.attemptId, + commandId: prepare.commandId, + deadlineAt: prepare.deadlineAt, + requestFingerprint: prepare.requestFingerprint, + }), + ); + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(frames.some((frame) => frame.type === "write_grant")).toBe(false); + } finally { + socket.close(1000, "done"); + } + }, + 10_000, + ); + + it( + "converges simultaneous safe retries of one logical command onto one new attempt", + async () => { + const sessionId = await openSession(CALLER_TOKEN_A); + const socket = await connectSafeExtension(sessionId); + try { + const firstPrepare = waitForServerFrame(socket, "write_prepare"); + const firstResponse = postCommandV2(sessionId, CALLER_TOKEN_A, { + type: "click", + commandId: "v2-retry-race", + ref: "owned-ref", + }); + await firstPrepare; + expect((await firstResponse).status).toBe(504); + + const retryPrepare = waitForServerFrame(socket, "write_prepare"); + const retries = [ + postCommandV2(sessionId, CALLER_TOKEN_A, { + type: "click", + commandId: "v2-retry-race", + ref: "owned-ref", + }), + postCommandV2(sessionId, CALLER_TOKEN_A, { + type: "click", + commandId: "v2-retry-race", + ref: "owned-ref", + }), + ]; + const prepare = await retryPrepare; + const grantPromise = waitForServerFrame(socket, "write_grant"); + socket.send( + JSON.stringify({ + type: "write_ready", + attemptId: prepare.attemptId, + commandId: prepare.commandId, + deadlineAt: prepare.deadlineAt, + requestFingerprint: prepare.requestFingerprint, + }), + ); + const grant = await grantPromise; + socket.send( + JSON.stringify({ + type: "command_result", + attemptId: grant.attemptId, + commandId: "v2-retry-race", + event: { + type: "action_result", + commandId: "v2-retry-race", + ok: true, + }, + }), + ); + + const responses = await Promise.all(retries); + expect(responses.map((response) => response.status).sort()).toEqual([200, 202]); + } finally { + socket.close(1000, "done"); + } + }, + 15_000, + ); +}); + describe("fill_secret", () => { it("resolves the vault secret and types it via the extension without leaking the plaintext", async () => { // #given a seeded vault secret and a connected fake extension @@ -355,7 +990,7 @@ describe("fill_secret", () => { // Captures every raw WS frame (Command AND Agents-SDK framework // messages alike) so the no-leak check below can assert the plaintext - // appears on the wire exactly once - the one hop where it must travel. + // appears on the single wire hop where it must travel. const rawFrames: string[] = []; socket.addEventListener("message", (event: MessageEvent) => { rawFrames.push(event.data as string); @@ -1008,12 +1643,12 @@ describe("error taxonomy (route mapping)", () => { expect(res.status).toBe(504); expect(await res.json()).toEqual({ error: "command timed out" }); - // #then the awaiting marker was cleared by the REAL DO-level timeout - // (the fake-timer coordinator test covers this deterministically; - // this re-asserts it through the integrated path, post-settlement) + // #then the durable marker still fences the session. Dropping it + // here would admit another command while this command remains queued + // in the extension and could execute later. const stub = await getSessionStub(sessionId); await runInDurableObject(stub, (instance: SessionAgent) => { - expect(instance.state.awaitingCommandIds).toEqual([]); + expect(instance.state.awaitingCommandIds).toEqual(["c-silent"]); }); } finally { socket.close(1000, "done"); @@ -1123,7 +1758,7 @@ describe("idempotent write replay (stable commandId contract)", () => { }); // #then the recorded Event is replayed byte-for-byte and the extension - // never saw a second click - the write executed exactly once + // never saw a second click: the write executed at most once expect(retryRes.status).toBe(200); expect(await retryRes.json()).toEqual(first); await new Promise((resolve) => setTimeout(resolve, 50)); @@ -1271,8 +1906,8 @@ describe("idempotent write replay (stable commandId contract)", () => { await new Promise((resolve) => setTimeout(resolve, 50)); // #then a retry of the same commandId still replays the recorded Event - // unchanged - completedWrites is untouched by the resync, and replay is - // keyed by commandId alone (no generation dependence) + // unchanged: completedWrites survives resync, and the exact command type + // and request fingerprint still match. const retryRes = await postCommand(sessionId, CALLER_TOKEN_A, { type: "click", commandId: "ik_resync:click", @@ -1296,7 +1931,7 @@ describe("idempotent write replay (stable commandId contract)", () => { // #when the same read commandId is posted again // #then it round-trips to the extension again - reads are free to - // re-execute; only writes carry the exactly-once contract + // re-execute; only writes carry the at-most-once contract expect((await roundTripGetTabs(socket, sessionId, "read-1")).status).toBe(200); await new Promise((resolve) => setTimeout(resolve, 50)); expect(received.filter((cmd) => cmd.type === "get_tabs")).toHaveLength(2); @@ -1446,7 +2081,7 @@ describe("two-tenant vault isolation (cross-tenant secretRef scoping, server-sid } }); - it("refuses a cross-tenant secretRef before replay - a reused commandId cannot serve a cached own-tenant result", async () => { + it("rejects a changed cross-tenant fill under a completed commandId", async () => { // #given tenantB completed a legitimate OWN-tenant fill under a commandId // (caching an ok:true write result), and tenantA's secret is also seeded await seedVault("vault://tenantB/own-pw", "tenantB-own"); @@ -1480,14 +2115,10 @@ describe("two-tenant vault isolation (cross-tenant secretRef scoping, server-sid secretRef: "vault://tenantA/okta-pw", }); - // #then the guard (which runs BEFORE replay) refuses it: the cached - // ok:true is NOT served, and tenantA's vault is never read - expect(await res.json()).toEqual({ - type: "action_result", - commandId: "ik_shared:fill", - ok: false, - error: "fill_secret: secret could not be resolved", - }); + // #then exact replay binding rejects the changed request without + // serving the cached result or reading tenantA's vault. + expect(res.status).toBe(409); + expect(await res.json()).toEqual({ code: "command_id_conflict" }); expect(vaultGetSpy).not.toHaveBeenCalled(); } finally { vaultGetSpy.mockRestore(); @@ -1625,6 +2256,8 @@ describe("dialog surfacing (extension → DO state → GET /v1/sessions/:id)", ( socket.send( JSON.stringify({ type: "dialog", + dialogId: "dialog-confirm-1", + occurredAt: "2026-07-26T00:00:00.000Z", tabId: 3, dialogType: "confirm", message: "Delete this item?", @@ -1641,6 +2274,8 @@ describe("dialog surfacing (extension → DO state → GET /v1/sessions/:id)", ( const status = (await res.json()) as { dialogs: unknown[] }; expect(status.dialogs).toEqual([ { + dialogId: "dialog-confirm-1", + occurredAt: "2026-07-26T00:00:00.000Z", tabId: 3, dialogType: "confirm", message: "Delete this item?", @@ -1663,6 +2298,8 @@ describe("dialog surfacing (extension → DO state → GET /v1/sessions/:id)", ( socket.send( JSON.stringify({ type: "dialog", + dialogId: "dialog-alert-1", + occurredAt: "2026-07-26T00:00:00.000Z", tabId: 1, dialogType: "alert", message: "Saved", @@ -1673,6 +2310,8 @@ describe("dialog surfacing (extension → DO state → GET /v1/sessions/:id)", ( socket.send( JSON.stringify({ type: "dialog", + dialogId: "dialog-prompt-1", + occurredAt: "2026-07-26T00:00:01.000Z", tabId: 1, dialogType: "prompt", message: "Name?", diff --git a/apps/backend/test/session.test.ts b/apps/backend/test/session.test.ts index b08b5ef..08fce6a 100644 --- a/apps/backend/test/session.test.ts +++ b/apps/backend/test/session.test.ts @@ -2,10 +2,16 @@ import { describe, it, expect, vi } from "vitest"; import { env, exports } from "cloudflare:workers"; import { runInDurableObject, evictDurableObject } from "cloudflare:test"; import type { Connection, ConnectionContext } from "agents"; -import type { Command } from "@understudy/protocol"; +import type { + Command, + CommandState, + UnattendedSessionLifecycle, +} from "@understudy/protocol"; import { mintSessionId } from "../src/auth"; import type { SessionAgent } from "../src/session"; +import type { LeaseResource } from "../src/tenant-coordinator"; import type { SessionState } from "../src/types"; +import { requestFingerprint } from "../src/validation"; import { EXTENSION_TOKEN_A, EXTENSION_TOKEN_B } from "./tokens"; import { BASE, getSessionStub, getWebSocket } from "./helpers"; @@ -67,6 +73,108 @@ function withoutActiveConnectionId(state: SessionState): SessionState { return legacy as SessionState; } +function unattendedLease(sessionId: string): LeaseResource { + const now = Date.now(); + return { + sessionId, + leaseId: crypto.randomUUID(), + deviceId: crypto.randomUUID(), + status: "connected", + allowedOrigins: ["https://example.com"], + leaseEpoch: 1, + browserEpoch: crypto.randomUUID(), + createdAt: now, + lastActivityAt: now, + idleExpiresAt: now + 60_000, + hardExpiresAt: now + 120_000, + needsReconciliation: false, + dialogDelivery: "ok", + }; +} + +interface SeededAttempt { + commandId: string; + attemptId: string; + state: "preparing" | "ready" | "granted"; +} + +function seedAttempt( + instance: SessionAgent, + input: { + state: SeededAttempt["state"]; + commandType: Command["type"]; + dryRun: boolean; + isWrite: boolean; + }, +): SeededAttempt { + const commandId = crypto.randomUUID(); + const attemptId = crypto.randomUUID(); + const now = Date.now(); + instance.sql` + INSERT INTO command_journal ( + command_id, fingerprint, command_type, dry_run, state, attempt_id, + ready_deadline_at, execution_deadline_at, result_json, created_at, + updated_at, is_write + ) VALUES ( + ${commandId}, ${crypto.randomUUID()}, ${input.commandType}, + ${input.dryRun ? 1 : 0}, ${input.state}, ${attemptId}, + ${now + 60_000}, ${input.state === "granted" ? now + 60_000 : null}, + NULL, ${now}, ${now}, ${input.isWrite ? 1 : 0} + ) + `; + return { commandId, attemptId, state: input.state }; +} + +function seedActiveAttempts(instance: SessionAgent): { + preparing: SeededAttempt; + ready: SeededAttempt; + grantedRead: SeededAttempt; + grantedDryRun: SeededAttempt; + grantedWrite: SeededAttempt; +} { + return { + preparing: seedAttempt(instance, { + state: "preparing", + commandType: "get_tabs", + dryRun: false, + isWrite: false, + }), + ready: seedAttempt(instance, { + state: "ready", + commandType: "click", + dryRun: false, + isWrite: true, + }), + grantedRead: seedAttempt(instance, { + state: "granted", + commandType: "get_tabs", + dryRun: false, + isWrite: false, + }), + grantedDryRun: seedAttempt(instance, { + state: "granted", + commandType: "click", + dryRun: true, + isWrite: true, + }), + grantedWrite: seedAttempt(instance, { + state: "granted", + commandType: "click", + dryRun: false, + isWrite: true, + }), + }; +} + +async function commandState( + instance: SessionAgent, + attempt: SeededAttempt, +): Promise { + const status = await instance.getCommandStatus(attempt.commandId); + if (status === null) throw new Error("seeded command disappeared"); + return status.status; +} + /** * The worker-level gate (index.ts onBeforeConnect) now refuses bad upgrades * before the DO accepts anything - service.test.ts covers that layer. These @@ -348,7 +456,9 @@ describe("dispatch / resolvePending", () => { // #then only the replacement receives it; there is no broadcast expect(previous.send).not.toHaveBeenCalled(); - expect(replacement.send).toHaveBeenCalledWith(JSON.stringify(cmd)); + await vi.waitFor(() => { + expect(replacement.send).toHaveBeenCalledWith(JSON.stringify(cmd)); + }); // #when the old socket forges the matching result, it is ignored even // though its authorized bit is still true @@ -389,8 +499,9 @@ describe("dispatch / resolvePending", () => { Object.assign(instance, { getConnections: () => [FAKE_CONNECTION] }); setAuthoritative(instance); const dispatchPromise = instance.dispatch(cmd); - // The marker is parked synchronously before dispatch() suspends. - expect(instance.state.awaitingCommandIds).toContain("s1"); + await vi.waitFor(() => { + expect(instance.state.awaitingCommandIds).toContain("s1"); + }); // #when the matching result event arrives from the extension await instance.onMessage( @@ -458,6 +569,303 @@ describe("DO eviction resilience (DL-007)", () => { // #then it is reconciled (marker cleared) rather than mis-resolving or throwing await runInDurableObject(stub, (instance: SessionAgent) => { expect(instance.state.awaitingCommandIds).toEqual([]); + expect(instance.state.completedWrites).toEqual([]); + }); + }); +}); + +describe("legacy replay tombstones", () => { + it("retains and replays only an exact late write action result", async () => { + const sessionId = crypto.randomUUID(); + const stub = await getSessionStub(sessionId); + const command: Command = { + type: "click", + commandId: "late-write", + ref: "owned-ref", + }; + const requestFingerprintValue = await requestFingerprint(command, false); + const tombstone = { + commandId: command.commandId, + commandType: command.type, + requestFingerprint: requestFingerprintValue, + } as const; + + const outcomes = await runInDurableObject( + stub, + async (instance: SessionAgent) => { + Object.assign(instance, { + getConnections: () => [FAKE_CONNECTION], + }); + instance.setState({ + ...instance.state, + activeConnectionId: FAKE_CONNECTION.id, + awaitingCommandIds: [command.commandId], + awaitingCommands: [tombstone], + legacyCommandTombstones: [tombstone], + }); + await instance.onMessage( + FAKE_CONNECTION, + JSON.stringify({ + type: "action_result", + commandId: command.commandId, + ok: true, + }), + ); + const replay = await instance.dispatch(command); + const conflict = await instance.dispatch({ + ...command, + ref: "changed-ref", + }); + return { + replay, + conflict, + completedWrites: instance.state.completedWrites, + }; + }, + ); + + expect(outcomes.completedWrites).toEqual([ + { + ...tombstone, + event: { + type: "action_result", + commandId: command.commandId, + ok: true, + }, + }, + ]); + expect(outcomes.replay).toEqual({ + ok: true, + event: { + type: "action_result", + commandId: command.commandId, + ok: true, + }, + }); + expect(outcomes.conflict).toMatchObject({ + ok: false, + reason: "id_conflict", + }); + }); + + it("never retains a late read result", async () => { + const sessionId = crypto.randomUUID(); + const stub = await getSessionStub(sessionId); + const command: Command = { + type: "snapshot", + commandId: "late-read", + mode: "a11y", + }; + const tombstone = { + commandId: command.commandId, + commandType: command.type, + requestFingerprint: await requestFingerprint(command, false), + } as const; + + await runInDurableObject(stub, async (instance: SessionAgent) => { + Object.assign(instance, { + getConnections: () => [FAKE_CONNECTION], + }); + instance.setState({ + ...instance.state, + activeConnectionId: FAKE_CONNECTION.id, + awaitingCommandIds: [command.commandId], + awaitingCommands: [tombstone], + legacyCommandTombstones: [tombstone], + }); + await instance.onMessage( + FAKE_CONNECTION, + JSON.stringify({ + type: "snapshot_result", + commandId: command.commandId, + tree: [], + tabId: 7, + url: "https://example.com/", + }), + ); + expect(instance.state.completedWrites).toEqual([]); + expect(instance.state.awaitingCommandIds).toEqual([]); + }); + }); + + it("rejects an oversized late write result from the replay cache", async () => { + const stub = await getSessionStub(crypto.randomUUID()); + const command: Command = { + type: "click", + commandId: "late-write-oversized", + ref: "owned-ref", + }; + const tombstone = { + commandId: command.commandId, + commandType: command.type, + requestFingerprint: await requestFingerprint(command, false), + } as const; + + await runInDurableObject(stub, async (instance: SessionAgent) => { + const harness = instance as unknown as { + rememberLegacyLateResult( + marker: typeof tombstone, + event: { + type: "action_result"; + commandId: string; + ok: boolean; + error: string; + }, + ): void; + }; + harness.rememberLegacyLateResult(tombstone, { + type: "action_result", + commandId: command.commandId, + ok: false, + error: "x".repeat(17 * 1024), + }); + expect(instance.state.completedWrites).toEqual([]); + expect(instance.state.legacyCommandTombstones).toEqual([tombstone]); + await expect(instance.dispatch(command)).resolves.toMatchObject({ + ok: false, + reason: "id_conflict", + }); + }); + }); + + it("strips a pre-migration oversized read payload to an ID-only conflict", async () => { + const sessionId = crypto.randomUUID(); + const stub = await getSessionStub(sessionId); + const result = await runInDurableObject( + stub, + async (instance: SessionAgent) => { + instance.setState({ + ...instance.state, + completedWrites: [ + { + commandId: "legacy-completed", + event: { + type: "screenshot_result", + commandId: "legacy-completed", + mime: "image/png", + b64: "x".repeat(17 * 1024), + tabId: 7, + url: "https://example.com/", + }, + }, + ], + }); + const outcome = await instance.dispatch({ + type: "click", + commandId: "legacy-completed", + ref: "owned-ref", + }); + return { + outcome, + completedWrites: instance.state.completedWrites, + tombstones: instance.state.legacyCommandTombstones, + }; + }, + ); + expect(result.outcome).toMatchObject({ + ok: false, + reason: "id_conflict", + }); + expect(result.completedWrites).toEqual([]); + expect(result.tombstones).toEqual([{ commandId: "legacy-completed" }]); + }); + + it("keeps a pre-migration awaiting ID fenced across hello until its late event arrives", async () => { + const stub = await getSessionStub(crypto.randomUUID()); + await runInDurableObject(stub, async (instance: SessionAgent) => { + Object.assign(instance, { + getConnections: () => [FAKE_CONNECTION], + }); + instance.setState({ + ...instance.state, + activeConnectionId: FAKE_CONNECTION.id, + awaitingCommandIds: ["legacy-awaiting"], + awaitingCommands: undefined, + }); + + await instance.onMessage( + FAKE_CONNECTION, + JSON.stringify({ + type: "hello", + browser: "chrome", + extVersion: "1.0.0", + tabs: [], + }), + ); + expect(instance.state.awaitingCommandIds).toEqual(["legacy-awaiting"]); + await expect( + instance.dispatch({ + type: "click", + commandId: "legacy-awaiting", + ref: "owned-ref", + }), + ).resolves.toMatchObject({ + ok: false, + reason: "id_conflict", + }); + + await instance.onMessage( + FAKE_CONNECTION, + JSON.stringify({ + type: "action_result", + commandId: "legacy-awaiting", + ok: true, + }), + ); + expect(instance.state.awaitingCommandIds).toEqual([]); + expect(instance.state.completedWrites).toEqual([]); + expect(instance.state.legacyCommandTombstones).toContainEqual({ + commandId: "legacy-awaiting", + }); + await expect( + instance.dispatch({ + type: "click", + commandId: "legacy-awaiting", + ref: "owned-ref", + }), + ).resolves.toMatchObject({ + ok: false, + reason: "id_conflict", + }); + }); + }); + + it("converts a mismatched typed replay payload into an ID-only tombstone", async () => { + const stub = await getSessionStub(crypto.randomUUID()); + const command: Command = { + type: "click", + commandId: "mismatched-result", + ref: "owned-ref", + }; + const tombstone = { + commandId: command.commandId, + commandType: command.type, + requestFingerprint: await requestFingerprint(command, false), + } as const; + + await runInDurableObject(stub, async (instance: SessionAgent) => { + instance.setState({ + ...instance.state, + completedWrites: [ + { + ...tombstone, + event: { + type: "action_result", + commandId: "another-command", + ok: true, + }, + }, + ], + }); + + await expect(instance.dispatch(command)).resolves.toMatchObject({ + ok: false, + reason: "id_conflict", + }); + expect(instance.state.completedWrites).toEqual([]); + expect(instance.state.legacyCommandTombstones).toEqual([ + { commandId: command.commandId }, + ]); }); }); }); @@ -474,11 +882,13 @@ describe("hello resync", () => { const cmd: Command = { type: "get_tabs", commandId: "resync-1" }; let outcome!: ReturnType; - await runInDurableObject(stub, (instance: SessionAgent) => { + await runInDurableObject(stub, async (instance: SessionAgent) => { Object.assign(instance, { getConnections: () => [FAKE_CONNECTION] }); setAuthoritative(instance); outcome = instance.dispatch(cmd); - expect(instance.state.awaitingCommandIds).toContain("resync-1"); + await vi.waitFor(() => { + expect(instance.state.awaitingCommandIds).toContain("resync-1"); + }); }); // #when a fresh `hello` arrives (the extension resynced) @@ -514,10 +924,149 @@ describe("hello resync", () => { }); }); +describe("unattended terminal lifecycle settlement", () => { + it.each(["closing", "closed", "expired", "lost"] as const)( + "settles every active attempt and notifies a waiter when lifecycle becomes %s", + async (lifecycle) => { + const sessionId = crypto.randomUUID(); + const stub = await getSessionStub(sessionId); + + await runInDurableObject(stub, async (instance: SessionAgent) => { + await instance.initializeUnattended("tenantA", unattendedLease(sessionId)); + instance.setState({ + ...instance.state, + activeConnectionId: "active-connection", + status: "connected", + }); + const attempts = seedActiveAttempts(instance); + const internals = instance as unknown as { + waitForAttempt( + attemptId: string, + predicate: (row: { state: CommandState }) => boolean, + timeoutMs: number, + ): Promise<{ state: CommandState }>; + writesBlocked(): boolean; + }; + const waiter = internals.waitForAttempt( + attempts.preparing.attemptId, + (row) => row.state !== "preparing", + 250, + ); + + await instance.markLifecycle(lifecycle, true); + + await expect(waiter).resolves.toMatchObject({ state: "not_started" }); + await expect(commandState(instance, attempts.preparing)).resolves.toBe( + "not_started", + ); + await expect(commandState(instance, attempts.ready)).resolves.toBe( + "not_started", + ); + await expect(commandState(instance, attempts.grantedRead)).resolves.toBe( + "timed_out", + ); + await expect(commandState(instance, attempts.grantedDryRun)).resolves.toBe( + "timed_out", + ); + await expect(commandState(instance, attempts.grantedWrite)).resolves.toBe( + "unknown", + ); + expect(internals.writesBlocked()).toBe(true); + expect(instance.state.unattended?.status).toBe(lifecycle); + expect(instance.state.activeConnectionId).toBe( + lifecycle === "closing" ? "active-connection" : null, + ); + expect(instance.state.status).toBe( + lifecycle === "closing" ? "connected" : "detached", + ); + }); + }, + ); + + it("revokeDevice settles preparing, ready, and granted attempts", async () => { + const sessionId = crypto.randomUUID(); + const stub = await getSessionStub(sessionId); + + await runInDurableObject(stub, async (instance: SessionAgent) => { + await instance.initializeUnattended("tenantA", unattendedLease(sessionId)); + const attempts = seedActiveAttempts(instance); + + await instance.revokeDevice(); + + await expect(commandState(instance, attempts.preparing)).resolves.toBe( + "not_started", + ); + await expect(commandState(instance, attempts.ready)).resolves.toBe( + "not_started", + ); + await expect(commandState(instance, attempts.grantedRead)).resolves.toBe( + "timed_out", + ); + await expect(commandState(instance, attempts.grantedDryRun)).resolves.toBe( + "timed_out", + ); + await expect(commandState(instance, attempts.grantedWrite)).resolves.toBe( + "unknown", + ); + expect( + ( + instance as unknown as { + writesBlocked(): boolean; + } + ).writesBlocked(), + ).toBe(true); + expect(instance.state.unattended?.status).toBe("lost"); + }); + }); + + it.each([ + "allocating", + "provisioning", + "connected", + "recovering", + ] as const)( + "does not settle active attempts for non-terminal lifecycle %s", + async (lifecycle: UnattendedSessionLifecycle) => { + const sessionId = crypto.randomUUID(); + const stub = await getSessionStub(sessionId); + + await runInDurableObject(stub, async (instance: SessionAgent) => { + await instance.initializeUnattended("tenantA", unattendedLease(sessionId)); + const attempts = seedActiveAttempts(instance); + + await instance.markLifecycle(lifecycle, false); + + await expect(commandState(instance, attempts.preparing)).resolves.toBe( + "preparing", + ); + await expect(commandState(instance, attempts.ready)).resolves.toBe("ready"); + await expect(commandState(instance, attempts.grantedRead)).resolves.toBe( + "granted", + ); + await expect(commandState(instance, attempts.grantedDryRun)).resolves.toBe( + "granted", + ); + await expect(commandState(instance, attempts.grantedWrite)).resolves.toBe( + "granted", + ); + expect( + ( + instance as unknown as { + writesBlocked(): boolean; + } + ).writesBlocked(), + ).toBe(false); + }); + }, + ); +}); + describe("dialog recording (onMessage → SessionState.dialogs)", () => { function dialogEvent(message: string): string { return JSON.stringify({ type: "dialog", + dialogId: `dialog-${message}`, + occurredAt: "2026-07-26T00:00:00.000Z", tabId: 1, dialogType: "alert", message, diff --git a/apps/backend/test/tenant-coordinator.test.ts b/apps/backend/test/tenant-coordinator.test.ts new file mode 100644 index 0000000..39e7180 --- /dev/null +++ b/apps/backend/test/tenant-coordinator.test.ts @@ -0,0 +1,411 @@ +import { describe, expect, it } from "vitest"; +import { env } from "cloudflare:workers"; +import type { + RegisterDeviceInput, + TenantDeviceCoordinator, +} from "../src/tenant-coordinator"; + +const DEVICE_A = "00000000-0000-4000-8000-000000000001"; +const DEVICE_B = "00000000-0000-4000-8000-000000000002"; +const BROWSER_EPOCH = "browser-epoch-1"; + +function coordinator(): DurableObjectStub { + return env.TENANT_CONTROL.getByName(`tenant-${crypto.randomUUID()}`); +} + +async function register( + stub: DurableObjectStub, + deviceId: string, + now = 1_000_000, +): Promise { + await stub.registerDevice({ + deviceId, + browser: "Chrome/125", + extVersion: "0.1.0", + browserEpoch: BROWSER_EPOCH, + credentialDigest: "a".repeat(64), + credentialVersion: 1, + allowedOrigins: [ + "https://one.example", + "https://two.example", + "https://three.example", + "https://four.example", + ], + capabilities: ["safe-write-v2"], + now, + }); +} + +function leaseInput( + index: number, + origin: string, + overrides: Partial<{ + deviceId: string; + idempotencyKey: string; + fingerprint: string; + profileStateHash: string; + }> = {}, +) { + return { + idempotencyKey: overrides.idempotencyKey ?? crypto.randomUUID(), + fingerprint: overrides.fingerprint ?? `${index}`.repeat(64).slice(0, 64), + sessionId: `session-${index}`, + ...(overrides.deviceId === undefined ? {} : { deviceId: overrides.deviceId }), + allowedOrigins: [origin], + profileStateHash: overrides.profileStateHash ?? `profile-${index}`, + actorPseudonym: "actor", + now: 1_000_001 + index, + }; +} + +describe("TenantDeviceCoordinator allocation", () => { + it("rejects stale or conflicting credential registrations monotonically", async () => { + const stub = coordinator(); + const base = { + deviceId: DEVICE_A, + browser: "Chrome/125", + extVersion: "0.1.0", + browserEpoch: BROWSER_EPOCH, + credentialDigest: "b".repeat(64), + credentialVersion: 2, + allowedOrigins: ["https://one.example"], + capabilities: ["safe-write-v2"], + now: 1_000_000, + } satisfies RegisterDeviceInput; + + await expect(stub.registerDevice(base)).resolves.toEqual({ + accepted: true, + epochChanged: false, + }); + await expect( + stub.registerDevice({ + ...base, + browserEpoch: "stale-epoch", + credentialDigest: "a".repeat(64), + credentialVersion: 1, + }), + ).resolves.toEqual({ accepted: false, epochChanged: false }); + await expect( + stub.registerDevice({ + ...base, + browserEpoch: "conflicting-epoch", + credentialDigest: "c".repeat(64), + }), + ).resolves.toEqual({ accepted: false, epochChanged: false }); + await expect( + stub.registerDevice({ + ...base, + browserEpoch: "browser-epoch-2", + credentialDigest: "d".repeat(64), + credentialVersion: 3, + }), + ).resolves.toEqual({ accepted: true, epochChanged: true }); + + await expect( + stub.heartbeat(DEVICE_A, "conflicting-epoch", [], 1_000_001), + ).resolves.toMatchObject({ ok: false }); + await expect( + stub.heartbeat(DEVICE_A, "browser-epoch-2", [], 1_000_001), + ).resolves.toMatchObject({ ok: true }); + await expect( + stub.revokeDevice( + DEVICE_A, + { + credentialDigest: base.credentialDigest, + credentialVersion: base.credentialVersion, + }, + 1_000_002, + ), + ).resolves.toBe(false); + await expect( + stub.heartbeat(DEVICE_A, "browser-epoch-2", [], 1_000_003), + ).resolves.toMatchObject({ ok: true }); + }); + + it("atomically admits two disjoint leases on one device and rejects a third", async () => { + const stub = coordinator(); + await register(stub, DEVICE_A); + + const first = await stub.createLease(leaseInput(1, "https://one.example")); + const second = await stub.createLease(leaseInput(2, "https://two.example")); + const third = await stub.createLease(leaseInput(3, "https://three.example")); + + expect(first.kind).toBe("created"); + expect(second.kind).toBe("created"); + expect(third).toEqual({ kind: "capacity" }); + expect((await stub.listDevices(1_000_010))[0]).toMatchObject({ + deviceId: DEVICE_A, + capacity: 2, + used: 2, + }); + }); + + it("rejects overlapping origins and equal profile-state hashes", async () => { + const originStub = coordinator(); + await register(originStub, DEVICE_A); + await originStub.createLease(leaseInput(1, "https://one.example")); + expect( + await originStub.createLease(leaseInput(2, "https://one.example")), + ).toEqual({ kind: "collision" }); + + const profileStub = coordinator(); + await register(profileStub, DEVICE_A); + await profileStub.createLease( + leaseInput(1, "https://one.example", { profileStateHash: "same" }), + ); + expect( + await profileStub.createLease( + leaseInput(2, "https://two.example", { profileStateHash: "same" }), + ), + ).toEqual({ kind: "collision" }); + }); + + it("converges identical create keys, conflicts changed requests, and tombstones terminal keys", async () => { + const stub = coordinator(); + await register(stub, DEVICE_A); + const key = crypto.randomUUID(); + const input = leaseInput(1, "https://one.example", { + idempotencyKey: key, + fingerprint: "a".repeat(64), + }); + const first = await stub.createLease(input); + const replay = await stub.createLease(input); + const conflict = await stub.createLease({ + ...input, + fingerprint: "b".repeat(64), + }); + expect(first.kind).toBe("created"); + expect(replay.kind).toBe("replay"); + expect(conflict).toEqual({ kind: "conflict" }); + if (first.kind !== "created") throw new Error("expected created lease"); + + await stub.closeLease(input.sessionId); + await stub.confirmClosed({ + sessionId: first.lease.sessionId, + leaseId: first.lease.leaseId, + deviceId: first.lease.deviceId, + leaseEpoch: first.lease.leaseEpoch, + browserEpoch: first.lease.browserEpoch, + now: 1_000_100, + }); + expect(await stub.createLease(input)).toEqual({ kind: "terminal", status: "closed" }); + }); + + it("accepts an authenticated active host closure and rejects stale fences", async () => { + const stub = coordinator(); + const now = Date.now(); + await register(stub, DEVICE_A, now); + const created = await stub.createLease( + { ...leaseInput(1, "https://one.example"), now: now + 1 }, + ); + if (created.kind !== "created") throw new Error("expected created lease"); + expect(await stub.getLease(created.lease.sessionId, now + 2)).toMatchObject({ + status: "provisioning", + leaseEpoch: created.lease.leaseEpoch, + browserEpoch: created.lease.browserEpoch, + }); + + expect( + await stub.confirmClosed({ + sessionId: created.lease.sessionId, + leaseId: "stale-lease", + deviceId: created.lease.deviceId, + leaseEpoch: created.lease.leaseEpoch, + browserEpoch: created.lease.browserEpoch, + now: now + 2, + }), + ).toBeNull(); + expect( + await stub.confirmClosed({ + sessionId: created.lease.sessionId, + leaseId: created.lease.leaseId, + deviceId: DEVICE_B, + leaseEpoch: created.lease.leaseEpoch, + browserEpoch: created.lease.browserEpoch, + now: now + 2, + }), + ).toBeNull(); + expect( + await stub.confirmClosed({ + sessionId: created.lease.sessionId, + leaseId: created.lease.leaseId, + deviceId: created.lease.deviceId, + leaseEpoch: created.lease.leaseEpoch + 1, + browserEpoch: created.lease.browserEpoch, + now: now + 2, + }), + ).toBeNull(); + expect( + await stub.confirmClosed({ + sessionId: created.lease.sessionId, + leaseId: created.lease.leaseId, + deviceId: created.lease.deviceId, + leaseEpoch: created.lease.leaseEpoch, + browserEpoch: "stale-browser", + now: now + 2, + }), + ).toBeNull(); + expect(await stub.getLease(created.lease.sessionId, now + 3)).toMatchObject({ + status: "provisioning", + }); + expect( + await stub.confirmClosed({ + sessionId: created.lease.sessionId, + leaseId: created.lease.leaseId, + deviceId: created.lease.deviceId, + leaseEpoch: created.lease.leaseEpoch, + browserEpoch: created.lease.browserEpoch, + now: now + 3, + }), + ).toEqual({ status: "closed", newlyClosed: true }); + expect( + await stub.confirmClosed({ + sessionId: created.lease.sessionId, + leaseId: created.lease.leaseId, + deviceId: created.lease.deviceId, + leaseEpoch: created.lease.leaseEpoch, + browserEpoch: created.lease.browserEpoch, + now: now + 4, + }), + ).toEqual({ status: "closed", newlyClosed: false }); + expect((await stub.listDevices(now + 4))[0]).toMatchObject({ + used: 0, + }); + + const lost = await stub.createLease({ + ...leaseInput(2, "https://two.example"), + now: now + 5, + }); + if (lost.kind !== "created") throw new Error("expected created lease"); + await stub.revokeDevice(DEVICE_A, undefined, now + 6); + expect( + await stub.confirmClosed({ + sessionId: lost.lease.sessionId, + leaseId: lost.lease.leaseId, + deviceId: lost.lease.deviceId, + leaseEpoch: lost.lease.leaseEpoch, + browserEpoch: lost.lease.browserEpoch, + now: now + 7, + }), + ).toEqual({ status: "lost", newlyClosed: false }); + expect(await stub.getLease(lost.lease.sessionId, now + 8)).toMatchObject({ + status: "lost", + }); + }); + + it("never falls back from an explicit full device and auto-selects by used capacity", async () => { + const stub = coordinator(); + await register(stub, DEVICE_A); + await register(stub, DEVICE_B); + + const first = await stub.createLease( + leaseInput(1, "https://one.example", { deviceId: DEVICE_A }), + ); + const second = await stub.createLease( + leaseInput(2, "https://two.example", { deviceId: DEVICE_A }), + ); + expect(first.kind).toBe("created"); + expect(second.kind).toBe("created"); + expect( + await stub.createLease( + leaseInput(3, "https://three.example", { deviceId: DEVICE_A }), + ), + ).toEqual({ kind: "capacity" }); + + const auto = await stub.createLease(leaseInput(4, "https://four.example")); + expect(auto.kind).toBe("created"); + if (auto.kind === "created") expect(auto.lease.deviceId).toBe(DEVICE_B); + }); + + it("marks an unreported connected assignment recovering and reports it for blank-tab reconciliation", async () => { + const stub = coordinator(); + await register(stub, DEVICE_A); + const created = await stub.createLease(leaseInput(1, "https://one.example")); + if (created.kind !== "created") throw new Error("expected created lease"); + await stub.markProvisioned({ + sessionId: created.lease.sessionId, + leaseId: created.lease.leaseId, + deviceId: created.lease.deviceId, + leaseEpoch: created.lease.leaseEpoch, + browserEpoch: created.lease.browserEpoch, + now: 1_000_010, + }); + + const heartbeat = await stub.heartbeat( + DEVICE_A, + BROWSER_EPOCH, + [], + 1_000_020, + ); + expect(heartbeat.ok).toBe(true); + expect(heartbeat.assignments).toEqual([]); + expect(heartbeat.recoveries).toHaveLength(1); + expect(heartbeat.recoveries[0]).toMatchObject({ + sessionId: created.lease.sessionId, + status: "recovering", + needsReconciliation: true, + }); + }); + + it("returns the durable CAS result for provisioning and recovery transitions", async () => { + const stub = coordinator(); + await register(stub, DEVICE_A); + const created = await stub.createLease(leaseInput(1, "https://one.example")); + if (created.kind !== "created") throw new Error("expected created lease"); + const fence = { + sessionId: created.lease.sessionId, + leaseId: created.lease.leaseId, + deviceId: created.lease.deviceId, + leaseEpoch: created.lease.leaseEpoch, + browserEpoch: created.lease.browserEpoch, + }; + + expect( + await stub.markProvisioned({ + ...fence, + deviceId: DEVICE_B, + now: 1_000_009, + }), + ).toEqual({ + accepted: false, + close: true, + }); + expect(await stub.markProvisioned({ ...fence, now: 1_000_010 })).toEqual({ + accepted: true, + close: false, + }); + expect(await stub.markProvisioned({ ...fence, now: 1_000_011 })).toEqual({ + accepted: false, + close: true, + }); + expect(await stub.markRecovering({ ...fence, now: 1_000_012 })).toBe(true); + expect(await stub.markRecovering({ ...fence, now: 1_000_013 })).toBe(false); + }); + + it("does not count provisioning as activity and materializes expiry on status reads", async () => { + const stub = coordinator(); + await register(stub, DEVICE_A); + const created = await stub.createLease(leaseInput(1, "https://one.example")); + if (created.kind !== "created") throw new Error("expected created lease"); + + await stub.markProvisioned({ + sessionId: created.lease.sessionId, + leaseId: created.lease.leaseId, + deviceId: created.lease.deviceId, + leaseEpoch: created.lease.leaseEpoch, + browserEpoch: created.lease.browserEpoch, + now: created.lease.createdAt + 30_000, + }); + expect(await stub.getLease(created.lease.sessionId, created.lease.createdAt + 30_001)) + .toMatchObject({ + status: "connected", + lastActivityAt: created.lease.createdAt, + idleExpiresAt: created.lease.idleExpiresAt, + }); + + expect(await stub.getLease(created.lease.sessionId, created.lease.idleExpiresAt)) + .toMatchObject({ + status: "expired", + }); + }); +}); diff --git a/apps/backend/test/validation.test.ts b/apps/backend/test/validation.test.ts new file mode 100644 index 0000000..f6dc964 --- /dev/null +++ b/apps/backend/test/validation.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from "vitest"; +import { CommandRequestSchema } from "@understudy/protocol"; +import { canonicalizeOrigins, parseBoundedStrictJson, RequestBodyError } from "../src/validation"; + +describe("parseBoundedStrictJson", () => { + it("rejects oversized declared and streamed bodies before schema use", async () => { + const declared = new Request("https://understudy.example/commands", { + method: "POST", + headers: { "content-length": "1025" }, + body: "{}", + }); + await expect( + parseBoundedStrictJson(declared, CommandRequestSchema, 1024), + ).rejects.toMatchObject({ status: 413, category: "size" }); + + const streamed = new Request("https://understudy.example/commands", { + method: "POST", + body: JSON.stringify({ value: "x".repeat(2048) }), + }); + await expect( + parseBoundedStrictJson(streamed, CommandRequestSchema, 1024), + ).rejects.toMatchObject({ status: 413, category: "size" }); + }); + + it("distinguishes syntax from strict schema failures", async () => { + const syntax = new Request("https://understudy.example/commands", { + method: "POST", + body: "{", + }); + await expect( + parseBoundedStrictJson(syntax, CommandRequestSchema), + ).rejects.toMatchObject({ category: "syntax" }); + + const schema = new Request("https://understudy.example/commands", { + method: "POST", + body: JSON.stringify({ + command: { type: "get_tabs", commandId: "c" }, + dryRun: false, + extra: true, + }), + }); + await expect( + parseBoundedStrictJson(schema, CommandRequestSchema), + ).rejects.toMatchObject({ category: "schema" }); + }); +}); + +describe("canonicalizeOrigins", () => { + it("canonicalizes, deduplicates, and sorts exact origins", () => { + expect( + canonicalizeOrigins([ + "https://B.example:443/", + "http://localhost:8787", + "https://b.example", + "https://a.example", + ]), + ).toEqual([ + "http://localhost:8787", + "https://a.example", + "https://b.example", + ]); + }); + + it.each([ + "http://example.com", + "https://*.example.com", + "https://user@example.com", + "https://example.com/path", + "https://example.com?", + "https://example.com#", + " https://example.com", + ])("rejects unsafe or non-origin input %s", (origin) => { + expect(() => canonicalizeOrigins([origin])).toThrow(RequestBodyError); + }); +}); diff --git a/apps/backend/vitest.config.ts b/apps/backend/vitest.config.ts index 1976cb0..77d01bc 100644 --- a/apps/backend/vitest.config.ts +++ b/apps/backend/vitest.config.ts @@ -16,7 +16,17 @@ export default defineConfig({ AUTH_HMAC_SECRET: "test-hmac-secret-do-not-use-in-prod", CALLER_TOKENS: JSON.stringify(CALLER_TOKENS), EXTENSION_TOKENS: JSON.stringify(EXTENSION_TOKENS), + DEVICE_TOKENS: "{}", + WS_TICKET_SECRET: "test-ticket-secret-do-not-use-in-prod", VAULT_MASTER_KEY: TEST_VAULT_MASTER_KEY, + QUOTA_POLICY: JSON.stringify({ + sessionCreatesPerActorMinute: 10_000, + commandsPerSessionMinute: 10_000, + commandsPerTenantMinute: 100_000, + credentialFillsPerActorMinute: 10_000, + deviceTicketsPerDeviceMinute: 10_000, + sessionCommandCap: 10_000, + }), }, }, }), diff --git a/apps/backend/wrangler.jsonc b/apps/backend/wrangler.jsonc index 48fdbc4..58808c6 100644 --- a/apps/backend/wrangler.jsonc +++ b/apps/backend/wrangler.jsonc @@ -12,12 +12,18 @@ // SessionAgent itself lands in src/session.ts at M-004 (wired into the // Worker entry, src/index.ts, at M-005); the binding is declared now // so wrangler.jsonc doesn't churn again for that milestone. - { "name": "SESSION", "class_name": "SessionAgent" } + { "name": "SESSION", "class_name": "SessionAgent" }, + { "name": "DEVICE", "class_name": "DeviceAgent" }, + { "name": "TENANT_CONTROL", "class_name": "TenantDeviceCoordinator" } ] }, "migrations": [ // Agents SDK Durable Objects are SQLite-backed. - { "tag": "v1", "new_sqlite_classes": ["SessionAgent"] } + { "tag": "v1", "new_sqlite_classes": ["SessionAgent"] }, + { + "tag": "v2", + "new_sqlite_classes": ["DeviceAgent", "TenantDeviceCoordinator"] + } ], "kv_namespaces": [ { @@ -48,8 +54,33 @@ // excludes the rest (no "optional" tier exists), which silently breaks // dev auth. They are operationally required anyway - without them the // service 401s every caller and rejects the extension WS. - "required": ["AUTH_HMAC_SECRET", "CALLER_TOKENS", "EXTENSION_TOKENS", "VAULT_MASTER_KEY"] + "required": [ + "AUTH_HMAC_SECRET", + "CALLER_TOKENS", + "EXTENSION_TOKENS", + "DEVICE_TOKENS", + "WS_TICKET_SECRET", + "VAULT_MASTER_KEY" + ] }, + "vars": { + "QUOTA_POLICY": "{\"sessionCreatesPerActorMinute\":10,\"commandsPerSessionMinute\":120,\"commandsPerTenantMinute\":600,\"credentialFillsPerActorMinute\":30,\"deviceTicketsPerDeviceMinute\":30,\"sessionCommandCap\":10000}", + "UNATTENDED_ENABLED_TENANTS": "[]", + "SAFE_WRITE_REQUIRED_TENANTS": "[]" + }, + "analytics_engine_datasets": [ + { + "binding": "ANALYTICS", + "dataset": "understudy_telemetry" + } + ], + "ratelimits": [ + { + "name": "RATE_LIMITER", + "namespace_id": "1001", + "simple": { "limit": 300, "period": 60 } + } + ], "observability": { "enabled": true } diff --git a/apps/extension/CHANGELOG.md b/apps/extension/CHANGELOG.md index 1517a34..b43edfa 100644 --- a/apps/extension/CHANGELOG.md +++ b/apps/extension/CHANGELOG.md @@ -1,5 +1,13 @@ # @understudy/extension +## 0.1.0 + +### Minor Changes + +- Add profile enrollment, device control, two per-session runtimes, extension-owned tab lifecycle, restart reconciliation, and emergency stop. +- Add protocol-2 write journaling, aggregate command deadlines, dialog acknowledgement, exact-origin navigation interception, and popup containment. +- Scope tab discovery and switching to the runtime-owned tab while preserving attended attachment. + ## 0.0.3 ### Patch Changes diff --git a/apps/extension/README.md b/apps/extension/README.md index 80f7719..919ca9e 100644 --- a/apps/extension/README.md +++ b/apps/extension/README.md @@ -1,64 +1,120 @@ -# Extension (M2) - -WXT + React MV3 extension that puppets a user's already-logged-in Chromium tab -over a WebSocket. It holds the WS connection, runs a CDP session against a -single designated tab via `chrome.debugger`, and executes protocol Commands -(`snapshot`, `click`, `type`, `navigate`, `key`, `scroll`, `wait`, `resolve_ref`, -`get_tabs`, `switch_tab`) as schema-valid `@understudy/protocol` Events. The peer in M2 is -a throwaway stub server (`scripts/stub-server.mjs`); the real Hono backend is -M3. - -## Layout - -| Path | What | -| --- | --- | -| `src/driver/a11y.ts` | `buildA11ySnapshot` — pure AX-tree → pruned `A11yNode[]` + opaque session/attachment/generation-bound ref map. No `chrome.*`. | -| `src/driver/keymap.ts` | `parseKeys` — pure key-spec parser (`"Ctrl+Enter"` etc.) → CDP `Input.dispatchKeyEvent` fields. No `chrome.*`. | -| `src/driver/cdp-events.ts` | `classifyCdpEvent` — pure classifier turning a raw CDP event into the effects the background worker should apply. No `chrome.*`. | -| `src/driver/cdp.ts` | `CdpSession` — the one `chrome.debugger` channel per attached tab: FIFO command queue, per-command timeouts, and the executors backing every protocol Command. | -| `src/core/ws-client.ts` | `ReconnectingWs` — WebSocket with backoff reconnect and a self-driven pong heartbeat. | -| `src/core/command-ingress.ts` | `CommandIngress` — preserves command arrival order through async dedupe claims and provides a drain barrier for WebSocket session changes. | -| `src/core/router.ts` | `routeCommand` — dispatches a parsed `Command` to a `CdpSession` executor or a tab-management handler, always returning exactly one `Event`. | -| `src/events.ts`, `src/tabs.ts`, `src/messaging.ts` | Shared leaf helpers (`action_result` builder, tab-info query) and the sidepanel↔service-worker `Port` message types. | -| `src/entrypoints/background.ts` | The MV3 service worker: owns the WS connection, the CDP session, wake-time reattachment, and the alarm/heartbeat keepalive. | -| `src/entrypoints/sidepanel/` | React panel (WS status, WS URL field, Attach/Detach, live log) talking to the background worker over a `chrome.runtime.Port`. | -| `scripts/stub-server.mjs` | Throwaway M2 verification peer — validates every extension-emitted Event against the real protocol schemas. | - -## Develop - -Run from the repo root (`pnpm-workspace.yaml` scopes `@understudy/extension`): + + +# Host Understudy sessions in Chromium + +The Manifest V3 extension supports attended control of one selected tab and unattended control of at most two extension-owned tabs. Unattended hosting requires a tenant-dedicated Chrome profile because tabs share profile cookies and browser storage. + +## Understand the runtime + +The background service worker separates profile and session responsibilities: + +| Module | Responsibility | +|---|---| +| `core/profile-client.ts` | Enrollment, browser epoch, device ticket, and control socket | +| `core/session-manager.ts` | Runtime maps by session, lease, and tab | +| `core/session-runtime.ts` | One tab, CDP session, command queue, session socket, journal, and dialog outbox | +| `core/write-journal.ts` | Awaited `prepared`, `started`, `completed_unacked`, and `unknown` states | +| `core/dialog-outbox.ts` | Browser-epoch-scoped dialog acknowledgement and replay | +| `driver/cdp.ts` | CDP execution, aggregate deadlines, top-level origin interception, and popup containment | +| `entrypoints/background.ts` | Profile host plus compatible attended session | +| `entrypoints/sidepanel/` | Enrollment, capacity, emergency stop, and attended controls | + +Each unattended lease creates a new `about:blank` tab in an unfocused automation window. The extension never adopts an existing tab for unattended work. + +## Build the production extension + +Run: ```bash -pnpm --filter @understudy/extension dev # wxt dev server (throwaway profile — no logins) -pnpm --filter @understudy/extension build # wxt build -> .output/chrome-mv3/ -pnpm --filter @understudy/extension typecheck # wxt prepare && tsc --noEmit -pnpm --filter @understudy/extension test # vitest run -pnpm --filter @understudy/extension stub # node scripts/stub-server.mjs +pnpm --filter @understudy/protocol build +pnpm --filter @understudy/extension typecheck +pnpm --filter @understudy/extension test +pnpm --filter @understudy/extension build ``` -`@understudy/protocol` resolves from source via a WXT `alias` in -`wxt.config.ts` (not `exports`/`dist`), so typecheck and dev don't depend on -the protocol package being built first. The stub server is the exception — it -imports the built `dist/`, so run `pnpm --filter @understudy/protocol build` -before `pnpm --filter @understudy/extension stub`. +Load `apps/extension/.output/chrome-mv3/` through `chrome://extensions`. Use the production build for real-browser tests. WXT development mode creates a disposable profile and does not prove behavior against the operator’s login state. + +The manifest requires Chrome 125 or newer and requests debugger, tabs, active tab, storage, alarms, side panel, and host permissions. + +## Enroll a dedicated profile + +Open the side panel and enter: + +- HTTPS service origin +- Device UUID +- Raw device credential +- One exact allowed origin per line +- **Enable unattended hosting** + +The credential field is write-only and must be supplied on every save. Panel state and logs never read it back. + +The local origin policy is the maximum this profile will host. Each session request must be a subset. + +## Understand storage + +`chrome.storage.local` contains: + +- Service origin +- Enabled state +- Device ID +- Raw device credential +- Local origin policy +- A staged replacement profile while old lease cleanup is incomplete +- A credential-revocation marker that suppresses reconnects + +The extension restricts local-storage access to trusted extension contexts. + +`chrome.storage.session` contains browser epoch, versioned lease assignments, cleanup intent, queued closure fences, tab IDs, ref generations, write journal entries, and dialog outbox records. A closure fence remains queued until the backend returns an exact `closed_ack`. The extension resends queued closures after reconnects. It never contains command bodies, typed text, secret plaintext, secret references, screenshots, accessibility trees, or prior URLs. + +Browser restart clears execution authority. The extension creates fresh blank tabs for live recovering leases and never restores old URLs. + +Changing the service origin, device ID, credential, or origin policy fences hosting immediately. The extension disables the old profile, closes its owned tabs, queues closure frames through the old control identity, then promotes the staged profile. If the old service is unavailable, the replacement stays inactive until cleanup can finish. + +## Control tabs safely + +One runtime can see only its own tab. `hello`, `get_tabs`, and `switch_tab` never report or activate another profile tab. + +Top-level navigations must remain within `allowedOrigins`. Explicit navigate commands are checked before dispatch. CDP Fetch interception also blocks redirects and JavaScript navigation. Cross-origin subresources and iframes remain allowed; the origin policy is not an egress firewall. + +Popup targets related to a controlled tab are paused and closed before execution. `tabs.onCreated` adds a second ownership check. + +## Recover command state + +Writes use protocol 2: + +```text +persist prepared + -> write_ready + -> persist started + -> execute once + -> persist completed_unacked + -> replay until result_ack +``` + +A storage failure before `started` prevents the browser action. A same-epoch service-worker restart: + +- Cancels an ungranted preparation unless the backend still recognizes it +- Marks a started write without a durable result unknown +- Replays a completed unacknowledged result + +A browser epoch change blocks writes for recovering sessions. Delete the old session and create a new session to resume writes. + +## Deliver dialogs + +The extension persists each dialog before answering it. It accepts alerts and before-unload dialogs, dismisses confirms and prompts, then replays the record until `dialog_ack`. + +The outbox holds at most 256 records and 256 KiB. Overflow still answers the browser dialog and reports content-free health. + +## Stop automation + +**Stop all** invalidates the control socket, fences every session runtime, and stops every session socket before profile or assignment persistence. Profile disable, replacement, credential revocation, and terminal control failures use the same ordering. A rejected storage write therefore leaves every in-memory runtime non-accepting. -## Manifest +Tab removal remains sequential and confirmation-based after the synchronous fence. The extension closes only tabs proven to belong to current leases and does not close unrelated tabs. If Chrome reports a failed removal and the tab still exists, the extension retains lease ownership and its heartbeat fence. The 30-second backstop alarm retries cleanup. Release cleanup queues a `closed` frame only after confirmed removal, while recovery cleanup omits the lease so the backend can provision a fresh blank tab. -`wxt.config.ts` declares: +Cleanup-only control connections send device hello, heartbeat, and queued closure frames through the retired profile. They reject provision and session-ticket frames. They stop only after release cleanup finishes and the backend acknowledges every queued closure. A staged profile remains inactive until both conditions hold. -- `minimum_chrome_version: "116"` — the version whose MV3 service-worker idle - timer is reset by WebSocket traffic, which the heartbeat in - `src/core/ws-client.ts` relies on. -- `permissions: ["debugger", "tabs", "activeTab", "storage", "alarms"]` (WXT - auto-adds `sidePanel`) and `host_permissions: [""]`. No - `offscreen` permission and no content script — deliberately deferred until a - real agent loop shows eviction pain (see the repo-root `docs/technical-plan.md`). +Credential revocation persists a terminal local marker, discards backend-terminal lease ownership, and suppresses control-ticket reconnects across service-worker restarts. -## Verifying end-to-end +Attended **Detach tab** detaches CDP but never closes the selected user tab. -Unit tests (`pnpm --filter @understudy/extension test`) cover the pure driver -logic and the router. They do not prove the extension against a real, -logged-in Chromium tab over the real WebSocket wire — for that, follow -[`RUNBOOK.md`](RUNBOOK.md): build the protocol dist, build the extension, load -it unpacked, start the stub server, and drive it with real commands while -watching for `EVENT SCHEMA VIOLATION` logs. +Follow [`RUNBOOK.md`](RUNBOOK.md) for the real-Chromium acceptance and recovery procedure. diff --git a/apps/extension/RUNBOOK.md b/apps/extension/RUNBOOK.md index a71498f..1e3a0dd 100644 --- a/apps/extension/RUNBOOK.md +++ b/apps/extension/RUNBOOK.md @@ -1,190 +1,199 @@ -# M2 verification runbook + -The exact human-run steps that prove Milestone M2: the extension drives a real, -logged-in Chromium tab over the **real WebSocket wire** and every Event it emits -is schema-valid against `@understudy/protocol`. +# Verify unattended sessions in a real Chromium profile -The peer is a throwaway stub (`scripts/stub-server.mjs`, Node + `ws`) that runs -`safeParseEvent` on every inbound message and lets you drive protocol Commands -by typing JSON lines into its stdin. The real Hono backend is M3 — this stub is -what M3 replaces (and a reference for M3's `onMessage`). +This runbook verifies the production extension against one tenant-dedicated Chrome profile. Automated tests do not prove Chrome focus behavior, paused-popup containment, restart recovery, or a real authenticated website session. -## Baseline / environment +## Prepare the operator environment -- Branch `master`, base commit `4a48b94` (`git rev-parse HEAD` to confirm). -- **Node ≥ 22, pnpm 11.5.2** (repo `packageManager`). This machine: Node 24, pnpm 11.5.2. -- **Real Chromium via `wxt build` + Load unpacked — NOT `wxt dev`.** `wxt dev` - starts a throwaway profile with no logins, which defeats the whole point - (puppeting a *logged-in* tab). Always load the built `.output/chrome-mv3/`. -- Chromium **≥ 116** (WS traffic resets the MV3 service-worker idle timer; the - manifest declares `minimum_chrome_version: "116"`). -- Run every `pnpm` command from the repo root. +Before you start: ---- +- Use Chrome 125 or newer +- Create or designate a profile used by one tenant only +- Configure Chrome startup to **New Tab**, not **Continue where you left off** +- Log into the required sites and complete Multi-Factor Authentication (MFA) or CAPTCHA +- Keep the machine, Chrome, and network awake +- Do not open DevTools on a controlled tab +- Do not click the debugger banner’s detach control -## 1. Build the protocol dist +Two sessions in one profile share cookies, local storage, IndexedDB, and browser extensions. Use separate profiles when the sessions require different browser identities. -The stub imports the **real** schemas from `@understudy/protocol`; that package's -`exports` map points at `dist/`, so it must be built first. +## Build and load the extension + +From the repository root: ```bash pnpm --filter @understudy/protocol build +pnpm --filter @understudy/extension typecheck +pnpm --filter @understudy/extension test +pnpm --filter @understudy/extension build ``` -Expect `packages/protocol/dist/index.js` + `index.d.ts` (exporting -`safeParseEvent` / `safeParseCommand` and the `tabs_result` validators). +Then: -## 2. Build the extension +1. Open `chrome://extensions` +2. Enable **Developer mode** +3. Select **Load unpacked** +4. Choose `apps/extension/.output/chrome-mv3/` +5. Approve debugger, tabs, storage, alarms, and host permissions -```bash -pnpm --filter @understudy/extension build -``` +Use this production build. Do not use WXT development mode for acceptance. + +## Enroll one device -Produces `apps/extension/.output/chrome-mv3/` with a `manifest.json` whose -`minimum_chrome_version` is `"116"`, whose `permissions` are -`debugger, tabs, activeTab, storage, alarms, sidePanel` (the 5 declared plus -`sidePanel`, which WXT auto-adds; `` is under `host_permissions`), and -which has both a `background` (service worker, `"type": "module"`) and a -`side_panel` entry. +Provision a device UUID and credential in the backend’s `DEVICE_TOKENS` secret. Store only the credential’s SHA-256 digest in that mapping. -## 3. Load unpacked in real Chromium +Open the extension side panel and enter: -1. Open `chrome://extensions`. -2. Toggle **Developer mode** (top-right) on. -3. Click **Load unpacked** and select `apps/extension/.output/chrome-mv3/`. -4. The extension appears; note its service-worker link (used in step 8). +1. The backend HTTPS origin +2. The device UUID +3. The raw device credential +4. One exact allowed origin per line +5. **Enable unattended hosting** -## 4. Start the stub WS server +Select **Save enrollment**. The status must become `connected`. -From the repo root: +Confirm the device through the caller API: ```bash -pnpm --filter @understudy/extension stub +curl --fail-with-body \ + -H 'Authorization: Bearer caller_token_here' \ + https://understudy.example/v1/devices ``` -It prints: +Done means the response reports the device online with capacity 2, usage 0, current browser and extension versions, and a recent `lastSeenAt`. + +## Create two isolated runtimes + +Use disjoint origin sets and different profile keys: +```bash +curl --fail-with-body \ + -X POST \ + -H 'Authorization: Bearer caller_token_here' \ + -H 'Content-Type: application/json' \ + -H 'Idempotency-Key: 00000000-0000-4000-8000-000000000011' \ + --data '{"mode":"unattended","allowedOrigins":["https://one.example"],"profileStateKey":"account_one"}' \ + https://understudy.example/v1/sessions ``` -listening ws://localhost:8787 + +Repeat with another UUID, `https://two.example`, and `account_two`. + +Done means: + +- Each response reaches `connected`, either immediately or through status polling +- Chrome contains two extension-owned tabs +- Each session status reports one tab only +- `GET /v1/devices` reports usage 2 + +Create a third session. It must fail with `429`. + +Try an overlapping origin and then a reused profile key. Each must fail with `409`. + +## Verify command routing + +Send concurrent reads to both session IDs. Confirm each result reports only its session-owned tab. + +Send input actions to both sessions. Confirm text and clicks never cross tabs. If inactive-tab `Input.*` fails or targets the wrong tab, stop rollout and add the planned profile-wide focus mutex for input operations. + +For one session: + +1. Navigate within its allowed origin and confirm success +2. Trigger a redirect to another origin and confirm rejection +3. Trigger JavaScript top-level navigation to another origin and confirm rejection +4. Open a popup and confirm Chrome closes it before its first request +5. Load a cross-origin image or frame and confirm it still works + +Paused-popup containment is a release gate. Do not weaken the exact-origin boundary if the production build cannot prove it. + +## Verify one-slot cleanup + +Delete one session: + +```bash +curl --fail-with-body \ + -X DELETE \ + -H 'Authorization: Bearer caller_token_here' \ + https://understudy.example/v1/sessions/session_id_here ``` -Leave this terminal focused — you will type command lines into it in step 6. +Poll status if DELETE returns `202`. -## 5. Open the side panel and attach +Done means Chrome closes exactly one leased tab, the other session remains functional, and device usage becomes 1. -1. Click the extension's toolbar icon (or open the side panel for it) to open - the panel. -2. Confirm the **wsUrl** field reads `ws://localhost:8787` (the default) and the - status pill turns **open**. The stub terminal prints `* extension connected` - and a `< hello …` line (browser, ext version, tab count). -3. In a normal tab, navigate to a **real, logged-in** site (something where being - logged in is visible, e.g. an account/settings page). -4. In the panel, click **Attach** (targets the active tab). Chromium shows the - yellow **"… is being debugged"** banner — **this is expected** (it is the - `chrome.debugger` attach; do NOT click "Cancel", which detaches). +## Evict the service worker -## 6. Drive commands (the core acceptance) +Open the extension’s service-worker inspection page from `chrome://extensions` and stop the worker. Do not inspect a controlled tab. -Type each JSON line below into the **stub terminal** (the one running step 4) and -press Enter. `commandId` is auto-filled — you do not type it. Blank lines and -lines starting with `#` are ignored. After each send the stub echoes `> sent …` -and then prints the extension's reply. +Wake the extension by reopening the side panel or sending a command. -> Tip: replace `` with the opaque ref copied verbatim from the most recent -> `snapshot_result` output. Do not parse or construct refs: each is a capability -> bound to the extension WebSocket session, CDP attachment, and snapshot -> generation that produced it. +Done means: -```jsonc -# read the page — first confirm tabId + exact URL, then copy a textbox/searchbox/button ref -{"type":"snapshot","mode":"a11y"} +- The same browser epoch restores assignments from session storage +- The extension does not create duplicate tabs +- A completed unacknowledged result replays +- Unrelated tabs remain untouched -# capture the same exact target as an image -{"type":"snapshot","mode":"screenshot"} +## Restart Chrome -# type into a field you copied a ref for (submit:false = don't press Enter) -{"type":"type","ref":"","text":"hello","submit":false} +Close and reopen Chrome within 90s. This is a deliberate destructive acceptance step for active browser execution. -# click a button/link you copied a ref for -{"type":"click","ref":""} +Done means: -# navigate the tab (a page_event should also arrive) -{"type":"navigate","url":"https://example.com/"} +- Each still-live lease receives a fresh blank tab +- Tab IDs, attachment generations, and accessibility refs change +- No prior URL is restored +- Restored ordinary tabs remain uncontrolled and open +- Session status reports reconciliation +- New writes remain blocked until DELETE and new session creation -# reuse a ref copied BEFORE the navigate above — must be rejected as stale -{"type":"click","ref":""} +## Verify an ambiguous write -# list open tabs — copy a tabId for switch_tab -{"type":"get_tabs"} +Use a non-production test action with an observable idempotent marker. Stop Chrome after the write starts but before the result acknowledgement. -# activate another tab by its tabId from get_tabs -{"type":"switch_tab","tabId":} +Done means command polling returns `command_outcome_unknown`, `safeToRetry` is false, and the backend blocks further writes for that session. The extension must never execute that granted payload again. -# dom snapshot is intentionally unsupported in M2 -{"type":"snapshot","mode":"dom"} +## Rotate the device credential -# fixed delay -{"type":"wait","for":"ms","value":500} -``` +Add the new credential digest with a higher `credentialVersion`, update the extension enrollment, then remove the old digest. -Expected replies (watch the stub terminal): - -| Line | Expected stub output | Visible in Chromium | -|---|---|---| -| `snapshot` a11y | `snapshot_result` with the attached `tabId`, exact bracketed URL (including any fragment), node count, and first ~15 `{ref role "name"}` indented. Confirm the target matches the panel/current tab before using a ref. | — | -| `snapshot` screenshot | `screenshot_result` with the same attached `tabId`, exact bracketed URL, MIME type, and payload length | — | -| `type` | `action_result … ok=true` | the text appears in the field | -| `click` | `action_result … ok=true` | the element is clicked | -| `navigate` | `action_result … ok=true url=…` **and** a `page_event navigated …` | the tab navigates | -| stale `click` | `action_result … ok=false error=…` (stale/unknown capability — no input dispatched) | nothing happens | -| `get_tabs` | `tabs_result` listing the open tabs (tabId/title/url) | — | -| `switch_tab` | `action_result … ok=true` | the other tab becomes active | -| `snapshot` dom | `action_result … ok=false error="dom snapshot unsupported"` | — | -| `wait` ms | `action_result … ok=true` | — | - -## 7. Idle-survival check - -Stop typing and leave everything idle for **~30–40 s**. The stub terminal should -print a `< pong · HH:MM:SS` roughly every **20–25 s** (the SW self-heartbeat, -with a 30 s `chrome.alarms` backstop). Then send any command again, e.g.: - -```jsonc -{"type":"snapshot","mode":"a11y"} -``` +Done means the old control socket closes, old tickets fail, the new credential reconnects, and no replayed ticket replaces the authoritative socket. -It must still succeed — proving the service worker stayed alive (or woke) and the -CDP attachment is intact. +## Run the 24-hour soak -## 8. Eviction / reconcile check (DL-007) +Create a read-only unattended session. Send a valid read less than every 2 hours to refresh idle expiry. -1. In `chrome://extensions`, force-stop the service worker: click the extension's - **service worker** link to open its DevTools and **Stop** it (or toggle the - extension off and on). This simulates MV3 eviction. -2. Send a command (or reopen the panel): +During the soak: -```jsonc -{"type":"get_tabs"} -``` +- Confirm the session expires at its exact 24-hour hard deadline despite activity +- Confirm another idle session expires after 2 hours without a valid command +- Compare Durable Object requests and duration before and after +- Confirm billed duration scales with handler execution, not lease wall time +- Confirm no unknown-write surprise, duplicate tab, or leaked capacity + +## Verify attended compatibility + +Use the side panel’s attended section: -Expected: the SW **wakes**, reconciles the existing attachment via -`chrome.debugger.getTargets()` (re-enables domains + bumps generation rather than -blindly re-attaching — so **no "Already attached" error**), emits a fresh -`< hello …`, the panel's status pill returns to **open** without a manual reload, -and the command returns its normal result. +1. Enter the legacy session WebSocket URL +2. Open the intended user-owned tab +3. Select **Attach active tab** +4. Run a snapshot and one approved test action +5. Select **Detach tab** ---- +Done means the command path negotiates protocol 2 after attachment, reports only that tab, and detaching leaves the tab open. -## Pass signal (the M2 acceptance) +## Record the release decision -**Zero `EVENT SCHEMA VIOLATION` logs across the entire session.** +Enable additional tenants only when all conditions hold: -Every event the extension emitted — `hello`, `snapshot_result`, -`screenshot_result`, `action_result`, `page_event`, `dialog`, `tabs_result`, -`pong` — passed `safeParseEvent` against the real protocol schemas on the real WS wire. -Combined with explicit snapshot target confirmation, the visible page effects -in step 6, the stale-ref rejection, `get_tabs`, idle survival (step 7), and -eviction reconcile (step 8), that is M2 proven end-to-end. +- Two controlled tabs route reads and inputs correctly +- Capacity, origin, and profile collisions fail with the expected statuses +- Redirect and popup containment hold +- Service-worker and browser restart behavior matches this runbook +- Credential rotation fences the predecessor +- The 24-hour and 2-hour expiries hold +- Durable Object duration does not scale with lease wall time +- No granted write executes twice -If a violation *does* print, it is loud and boxed and includes the raw event plus -`error.issues` — read the issues to see exactly which field of which event type -failed, and fix the emitting executor. +On failure, disable new leases, close or terminalize active leases and granted commands, and roll back application code. Keep migration `v2`. diff --git a/apps/extension/package.json b/apps/extension/package.json index 67849d9..71a5dfc 100644 --- a/apps/extension/package.json +++ b/apps/extension/package.json @@ -1,6 +1,6 @@ { "name": "@understudy/extension", - "version": "0.0.3", + "version": "0.1.0", "private": true, "type": "module", "scripts": { diff --git a/apps/extension/src/core/dedupe.test.ts b/apps/extension/src/core/dedupe.test.ts index b7b5cd3..02929b7 100644 --- a/apps/extension/src/core/dedupe.test.ts +++ b/apps/extension/src/core/dedupe.test.ts @@ -111,6 +111,27 @@ describe("WriteDedupe persistence + lifecycle", () => { expect(await revived.claim(CLICK)).toEqual({ kind: "replay", event: CLICK_RESULT }); }); + it("scrubs refs and URLs from the legacy persistence mirror", async () => { + const storage = fakeStorage(); + const dedupe = new WriteDedupe(storage); + await dedupe.claim(CLICK); + await dedupe.remember(CLICK, { + type: "action_result", + commandId: CLICK.commandId, + ok: false, + error: "stale ref s1e1 at https://prior-url.example/", + url: "https://prior-url.example/", + }); + + const serialized = JSON.stringify([...storage.data.values()]); + expect(serialized).not.toContain("s1e1"); + expect(serialized).not.toContain("prior-url.example"); + expect(await new WriteDedupe(storage).claim(CLICK)).toMatchObject({ + kind: "replay", + event: { error: "browser action failed" }, + }); + }); + it("clear() drops the record so a new session cannot replay the old one's writes", async () => { // #given a recorded write and a session change const storage = fakeStorage(); diff --git a/apps/extension/src/core/dedupe.ts b/apps/extension/src/core/dedupe.ts index a4a911b..fb41933 100644 --- a/apps/extension/src/core/dedupe.ts +++ b/apps/extension/src/core/dedupe.ts @@ -88,7 +88,7 @@ export class WriteDedupe { const entries = this.entries ?? []; const next = [ ...entries.filter((entry) => entry.commandId !== cmd.commandId), - { commandId: cmd.commandId, event }, + { commandId: cmd.commandId, event: persistenceSafeEvent(event) }, ]; while (next.length > CAP) next.shift(); this.entries = next; @@ -143,3 +143,16 @@ export class WriteDedupe { return this.hydration; } } + +function persistenceSafeEvent(event: Event): Event { + if (event.type !== "action_result") { + throw new Error("write dedupe accepts action results only"); + } + return { + type: "action_result", + commandId: event.commandId, + ok: event.ok, + ...(event.error === undefined ? {} : { error: "browser action failed" }), + ...(event.simulated === undefined ? {} : { simulated: event.simulated }), + }; +} diff --git a/apps/extension/src/core/dialog-outbox.test.ts b/apps/extension/src/core/dialog-outbox.test.ts new file mode 100644 index 0000000..8745425 --- /dev/null +++ b/apps/extension/src/core/dialog-outbox.test.ts @@ -0,0 +1,195 @@ +import { describe, expect, it, vi } from "vitest"; +import type { DialogRecord } from "@understudy/protocol"; +import type { SessionStorageArea } from "./dedupe"; +import { DialogOutbox, handleDialogWithOutbox } from "./dialog-outbox"; + +class MemoryStorage implements SessionStorageArea { + readonly values: Record = {}; + readonly set = vi.fn(async (items: Record) => { + Object.assign(this.values, items); + }); + + async get(key: string): Promise> { + return { [key]: this.values[key] }; + } + + async remove(key: string): Promise { + delete this.values[key]; + } +} + +function dialog(index: number, message = "Confirm?"): DialogRecord { + return { + dialogId: `dialog-${index}`, + occurredAt: "2026-07-26T00:00:00.000Z", + tabId: 7, + dialogType: "confirm", + message, + url: "https://example.com/", + disposition: "dismiss", + }; +} + +describe("DialogOutbox", () => { + it("persists before delivery and removes a record only after ACK", async () => { + const storage = new MemoryStorage(); + const outbox = new DialogOutbox(storage, "dialogs"); + + await expect(outbox.add(dialog(1))).resolves.toBe("ok"); + expect(await outbox.pending()).toEqual([dialog(1)]); + await outbox.acknowledge("dialog-1"); + expect(await outbox.pending()).toEqual([]); + }); + + it("deduplicates dialog IDs and reports persistence failures as overflow", async () => { + const storage = new MemoryStorage(); + const outbox = new DialogOutbox(storage, "dialogs"); + await outbox.add(dialog(1)); + await outbox.add(dialog(1)); + expect(await outbox.pending()).toHaveLength(1); + + storage.set.mockRejectedValueOnce(new Error("storage unavailable")); + await expect(outbox.add(dialog(2))).resolves.toBe("overflow"); + expect(await outbox.pending()).toEqual([dialog(1)]); + }); + + it("rejects the 257th pending record without evicting the first 256", async () => { + const storage = new MemoryStorage(); + const outbox = new DialogOutbox(storage, "dialogs"); + for (let index = 0; index < 256; index += 1) { + await expect(outbox.add(dialog(index, ""))).resolves.toBe("ok"); + } + + await expect(outbox.add(dialog(256, ""))).resolves.toBe("overflow"); + const pending = await outbox.pending(); + expect(pending).toHaveLength(256); + expect(pending[0]?.dialogId).toBe("dialog-0"); + }); + + it("rejects an oversized dialog before persistence", async () => { + const storage = new MemoryStorage(); + const outbox = new DialogOutbox(storage, "dialogs"); + + await expect(outbox.add(dialog(1, "x".repeat(4 * 1024 + 1)))).resolves.toBe( + "overflow", + ); + expect(storage.set).not.toHaveBeenCalled(); + expect(await outbox.pending()).toEqual([]); + }); + + it("retains two concurrently added records in arrival order", async () => { + const storage = new MemoryStorage(); + const outbox = new DialogOutbox(storage, "dialogs"); + let markStarted!: () => void; + const started = new Promise((resolve) => { + markStarted = resolve; + }); + let release!: () => void; + const blocked = new Promise((resolve) => { + release = resolve; + }); + storage.set.mockImplementationOnce(async (items) => { + markStarted(); + await blocked; + Object.assign(storage.values, items); + }); + + const first = outbox.add(dialog(1)); + await started; + const second = outbox.add(dialog(2)); + release(); + + await expect(Promise.all([first, second])).resolves.toEqual(["ok", "ok"]); + expect(await outbox.pending()).toEqual([dialog(1), dialog(2)]); + }); + + it("serializes concurrent add, ACK, clear, and later add operations", async () => { + const storage = new MemoryStorage(); + const outbox = new DialogOutbox(storage, "dialogs"); + await outbox.add(dialog(0)); + let markStarted!: () => void; + const started = new Promise((resolve) => { + markStarted = resolve; + }); + let release!: () => void; + const blocked = new Promise((resolve) => { + release = resolve; + }); + storage.set.mockImplementationOnce(async (items) => { + markStarted(); + await blocked; + Object.assign(storage.values, items); + }); + + const firstAdd = outbox.add(dialog(1)); + await started; + const acknowledge = outbox.acknowledge("dialog-0"); + const clear = outbox.clear(); + const finalAdd = outbox.add(dialog(2)); + release(); + + await Promise.all([firstAdd, acknowledge, clear, finalAdd]); + expect(await outbox.pending()).toEqual([dialog(2)]); + }); + + it("continues queued operations after one rejects", async () => { + const storage = new MemoryStorage(); + const outbox = new DialogOutbox(storage, "dialogs"); + await outbox.add(dialog(1)); + storage.set.mockRejectedValueOnce(new Error("storage unavailable")); + + const acknowledge = outbox.acknowledge("dialog-1"); + const add = outbox.add(dialog(2)); + + await expect(acknowledge).rejects.toThrow("storage unavailable"); + await expect(add).resolves.toBe("ok"); + expect(await outbox.pending()).toEqual([dialog(1), dialog(2)]); + }); +}); + +describe("handleDialogWithOutbox", () => { + it("delivers overflow only after the browser answer settles", async () => { + const storage = new MemoryStorage(); + storage.set.mockRejectedValueOnce(new Error("storage unavailable")); + const outbox = new DialogOutbox(storage, "dialogs"); + let finishAnswer!: () => void; + const answer = vi.fn( + () => + new Promise((resolve) => { + finishAnswer = resolve; + }), + ); + const deliver = vi.fn(); + + const handled = handleDialogWithOutbox( + outbox, + dialog(1), + answer, + deliver, + ); + await vi.waitFor(() => expect(storage.set).toHaveBeenCalled()); + expect(answer).toHaveBeenCalledOnce(); + expect(deliver).not.toHaveBeenCalled(); + + finishAnswer(); + await handled; + expect(deliver).toHaveBeenCalledWith("overflow"); + }); + + it("delivers the persisted record even when the browser answer fails", async () => { + const storage = new MemoryStorage(); + const deliver = vi.fn(); + + await expect( + handleDialogWithOutbox( + new DialogOutbox(storage, "dialogs"), + dialog(1), + async () => { + throw new Error("CDP failed"); + }, + deliver, + ), + ).rejects.toThrow("CDP failed"); + expect(deliver).toHaveBeenCalledWith("ok"); + }); +}); diff --git a/apps/extension/src/core/dialog-outbox.ts b/apps/extension/src/core/dialog-outbox.ts new file mode 100644 index 0000000..217941e --- /dev/null +++ b/apps/extension/src/core/dialog-outbox.ts @@ -0,0 +1,104 @@ +import { DialogRecordSchema, type DialogRecord } from "@understudy/protocol"; +import type { SessionStorageArea } from "./dedupe"; + +const MAX_RECORDS = 256; +const MAX_BYTES = 256 * 1024; + +export type DialogDelivery = "ok" | "overflow"; + +export async function handleDialogWithOutbox( + outbox: DialogOutbox, + record: DialogRecord, + answer: () => Promise, + deliver: (delivery: DialogDelivery) => void, +): Promise { + const persistence = outbox.add(record); + try { + await answer(); + } finally { + deliver(await persistence); + } +} + +export class DialogOutbox { + private records: DialogRecord[] | null = null; + private operationTail: Promise | null = null; + + constructor( + private readonly storage: SessionStorageArea, + private readonly storageKey: string, + ) {} + + add(record: DialogRecord): Promise { + return this.serialize(async () => { + try { + const parsed = DialogRecordSchema.safeParse(record); + if (!parsed.success) return "overflow"; + await this.hydrate(); + const existing = this.records ?? []; + if (existing.some((item) => item.dialogId === parsed.data.dialogId)) { + return "ok"; + } + const next = [...existing, parsed.data]; + if ( + next.length > MAX_RECORDS || + new TextEncoder().encode(JSON.stringify(next)).byteLength > MAX_BYTES + ) { + return "overflow"; + } + await this.persist(next); + return "ok"; + } catch { + return "overflow"; + } + }); + } + + acknowledge(dialogId: string): Promise { + return this.serialize(async () => { + await this.hydrate(); + await this.persist( + (this.records ?? []).filter((record) => record.dialogId !== dialogId), + ); + }); + } + + pending(): Promise { + return this.serialize(async () => { + await this.hydrate(); + return [...(this.records ?? [])]; + }); + } + + clear(): Promise { + return this.serialize(() => this.persist([])); + } + + private serialize(operation: () => Promise): Promise { + const result = + this.operationTail === null + ? operation() + : this.operationTail.then(operation, operation); + const settled = result.then( + () => undefined, + () => undefined, + ); + this.operationTail = settled; + void settled.then(() => { + if (this.operationTail === settled) this.operationTail = null; + }); + return result; + } + + private async hydrate(): Promise { + if (this.records !== null) return; + const stored = await this.storage.get(this.storageKey); + const value = stored[this.storageKey]; + this.records = Array.isArray(value) ? (value as DialogRecord[]) : []; + } + + private async persist(records: DialogRecord[]): Promise { + await this.storage.set({ [this.storageKey]: records }); + this.records = records; + } +} diff --git a/apps/extension/src/core/profile-client.test.ts b/apps/extension/src/core/profile-client.test.ts new file mode 100644 index 0000000..b3b06d0 --- /dev/null +++ b/apps/extension/src/core/profile-client.test.ts @@ -0,0 +1,1132 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { DeviceControlServerFrame } from "@understudy/protocol"; +import { ProfileClient, type ProfileConfig } from "./profile-client"; + +const CONFIG: ProfileConfig = { + serviceOrigin: "https://old.example", + unattendedEnabled: true, + deviceId: "00000000-0000-4000-8000-000000000001", + deviceCredential: "old-credential", + originPolicy: ["https://app.example"], +}; +const EPOCH = "browser-epoch-1"; + +type Listener = (event: Event & { code?: number; data?: unknown }) => void; + +class FakeWebSocket { + static readonly OPEN = 1; + static instances: FakeWebSocket[] = []; + + readyState = 0; + readonly sent: unknown[] = []; + closeCount = 0; + private readonly listeners = new Map(); + + constructor(readonly url: string) { + FakeWebSocket.instances.push(this); + } + + addEventListener(type: string, listener: Listener): void { + const listeners = this.listeners.get(type) ?? []; + listeners.push(listener); + this.listeners.set(type, listeners); + } + + send(value: string): void { + this.sent.push(JSON.parse(value)); + } + + close(code = 1000): void { + this.closeCount += 1; + this.readyState = 3; + this.emit("close", { code }); + } + + open(): void { + this.readyState = FakeWebSocket.OPEN; + this.emit("open"); + } + + message(frame: DeviceControlServerFrame): void { + this.emit("message", { data: JSON.stringify(frame) }); + } + + emit(type: string, init: { code?: number; data?: unknown } = {}): void { + const event = { type, ...init } as Event & { + code?: number; + data?: unknown; + }; + for (const listener of this.listeners.get(type) ?? []) listener(event); + } +} + +interface BrowserFixture { + local: Record; + session: Record; + localArea: ReturnType; + sessionArea: ReturnType; + removeTab: ReturnType; + getTab: ReturnType; + createWindow: ReturnType; +} + +function installBrowser( + initial: { + local?: Record; + session?: Record; + } = {}, +): BrowserFixture { + const local = { ...(initial.local ?? {}) }; + const session = { ...(initial.session ?? {}) }; + const localArea = storageArea(local); + const sessionArea = storageArea(session); + const removeTab = vi.fn(async () => {}); + const getTab = vi.fn(async (tabId: number) => ({ + id: tabId, + url: "about:blank", + title: "", + active: false, + })); + const createWindow = vi.fn(); + vi.stubGlobal("browser", { + storage: { local: localArea, session: sessionArea }, + runtime: { getManifest: () => ({ version: "0.1.0" }) }, + debugger: { + getTargets: vi.fn(async () => []), + attach: vi.fn(async () => {}), + detach: vi.fn(async () => {}), + sendCommand: vi.fn( + async (_target: unknown, method: string) => + method === "Page.getFrameTree" + ? { + frameTree: { + frame: { + id: "main-frame", + loaderId: "loader-1", + url: "about:blank", + }, + }, + } + : {}, + ), + }, + tabs: { remove: removeTab, get: getTab }, + windows: { create: createWindow }, + }); + return { + local, + session, + localArea, + sessionArea, + removeTab, + getTab, + createWindow, + }; +} + +function storageArea(state: Record) { + return { + get: vi.fn(async (keys: string | string[]) => { + const requested = Array.isArray(keys) ? keys : [keys]; + return Object.fromEntries( + requested + .filter((key) => key in state) + .map((key) => [key, state[key]]), + ); + }), + set: vi.fn(async (values: Record) => { + Object.assign(state, values); + }), + remove: vi.fn(async (keys: string | string[]) => { + for (const key of Array.isArray(keys) ? keys : [keys]) delete state[key]; + }), + setAccessLevel: vi.fn(async () => {}), + }; +} + +function ticketResponse( + status = 200, + json: () => Promise = async () => ({ + ticket: crypto.randomUUID(), + websocketPath: "/agents/device/device", + }), +): Response { + return { + ok: status >= 200 && status < 300, + status, + json, + } as Response; +} + +function persistedConfig( + config: ProfileConfig, +): Record { + return { + ...config, + originPolicy: [...config.originPolicy], + }; +} + +beforeEach(() => { + vi.useFakeTimers(); + FakeWebSocket.instances = []; + vi.stubGlobal("WebSocket", FakeWebSocket); + vi.stubGlobal("navigator", { userAgent: "Chrome/125" }); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); +}); + +describe("ProfileClient generation fencing", () => { + it("does not construct a socket when disable supersedes a pending ticket fetch", async () => { + installBrowser(); + let resolveFetch!: (response: Response) => void; + const fetchMock = vi.fn( + () => + new Promise((resolve) => { + resolveFetch = resolve; + }), + ); + vi.stubGlobal("fetch", fetchMock); + const client = new ProfileClient(); + + const configuring = client.configure(CONFIG); + await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledOnce()); + await client.stopAll(); + resolveFetch(ticketResponse()); + await configuring; + + expect(FakeWebSocket.instances).toHaveLength(0); + expect(client.currentStatus()).toBe("disabled"); + }); + + it("does not construct a socket when disable supersedes response parsing", async () => { + installBrowser(); + let resolveJson!: (value: unknown) => void; + const json = vi.fn( + () => + new Promise((resolve) => { + resolveJson = resolve; + }), + ); + vi.stubGlobal("fetch", vi.fn(async () => ticketResponse(200, json))); + const client = new ProfileClient(); + + const configuring = client.configure(CONFIG); + await vi.waitFor(() => expect(json).toHaveBeenCalledOnce()); + await client.stopAll(); + resolveJson({ + ticket: crypto.randomUUID(), + websocketPath: "/agents/device/device", + }); + await configuring; + + expect(FakeWebSocket.instances).toHaveLength(0); + }); + + it("retries transient ticket failures exponentially and resets after open", async () => { + installBrowser(); + const fetchMock = vi + .fn() + .mockRejectedValueOnce(new Error("offline")) + .mockResolvedValueOnce(ticketResponse(429)) + .mockResolvedValueOnce(ticketResponse(503)) + .mockResolvedValue(ticketResponse()); + vi.stubGlobal("fetch", fetchMock); + const client = new ProfileClient(); + + await client.configure(CONFIG); + expect(fetchMock).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(499); + expect(fetchMock).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(1); + expect(fetchMock).toHaveBeenCalledTimes(2); + await vi.advanceTimersByTimeAsync(1_000); + expect(fetchMock).toHaveBeenCalledTimes(3); + await vi.advanceTimersByTimeAsync(2_000); + expect(fetchMock).toHaveBeenCalledTimes(4); + expect(FakeWebSocket.instances).toHaveLength(1); + + FakeWebSocket.instances[0]?.open(); + FakeWebSocket.instances[0]?.emit("close", { code: 1006 }); + await vi.advanceTimersByTimeAsync(499); + expect(fetchMock).toHaveBeenCalledTimes(4); + await vi.advanceTimersByTimeAsync(1); + expect(fetchMock).toHaveBeenCalledTimes(5); + }); + + it("treats permanent ticket errors and replacement close as terminal", async () => { + installBrowser(); + const fetchMock = vi + .fn() + .mockResolvedValueOnce(ticketResponse()) + .mockResolvedValue(ticketResponse(400)); + vi.stubGlobal("fetch", fetchMock); + const client = new ProfileClient(); + await client.configure(CONFIG); + FakeWebSocket.instances[0]?.open(); + FakeWebSocket.instances[0]?.emit("close", { code: 4001 }); + await vi.advanceTimersByTimeAsync(60_000); + expect(fetchMock).toHaveBeenCalledOnce(); + expect(client.currentStatus()).toBe("error"); + + await client.configure({ + ...CONFIG, + deviceCredential: "replacement-credential", + }); + await vi.advanceTimersByTimeAsync(60_000); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(client.currentStatus()).toBe("error"); + }); + + it("persists replacement fencing across alarms and service-worker restart until an explicit save", async () => { + const fixture = installBrowser({ + session: { "understudy:browserEpoch": EPOCH }, + }); + const fetchMock = vi.fn( + async (_input: RequestInfo | URL, _init?: RequestInit) => + ticketResponse(), + ); + vi.stubGlobal("fetch", fetchMock); + const client = new ProfileClient(); + await client.configure(CONFIG); + FakeWebSocket.instances[0]?.open(); + + FakeWebSocket.instances[0]?.emit("close", { code: 4001 }); + await vi.waitFor(() => + expect(fixture.local["understudy:controlBlock"]).toMatchObject({ + version: 1, + reason: "replaced", + }), + ); + await client.ensureConnection(); + + const restarted = new ProfileClient(); + await restarted.start(); + await restarted.ensureConnection(); + expect(fetchMock).toHaveBeenCalledOnce(); + expect(restarted.currentStatus()).toBe("error"); + + await restarted.configure(CONFIG); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(fixture.local["understudy:controlBlock"]).toBeNull(); + }); + + it("lets an explicit save queued behind replacement cleanup clear the terminal latch", async () => { + const fixture = installBrowser({ + session: { "understudy:browserEpoch": EPOCH }, + }); + const fetchMock = vi.fn( + async (_input: RequestInfo | URL) => ticketResponse(), + ); + vi.stubGlobal("fetch", fetchMock); + const client = new ProfileClient(); + await client.configure(CONFIG); + FakeWebSocket.instances[0]?.open(); + + FakeWebSocket.instances[0]?.emit("close", { code: 4001 }); + const replacement = { + ...CONFIG, + deviceCredential: "replacement-credential", + }; + await client.configure(replacement); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(fixture.local.deviceCredential).toBe("replacement-credential"); + expect(fixture.local["understudy:controlBlock"]).toBeNull(); + expect(client.currentStatus()).toBe("connecting"); + }); + + it("completes epoch initialization before a concurrent configure request", async () => { + const fixture = installBrowser({ + session: { "understudy:browserEpoch": EPOCH }, + }); + const originalGet = fixture.sessionArea.get.getMockImplementation(); + if (originalGet === undefined) throw new Error("storage get mock missing"); + let releaseEpoch!: () => void; + const epochGate = new Promise((resolve) => { + releaseEpoch = resolve; + }); + fixture.sessionArea.get.mockImplementation( + async (keys: string | string[]) => { + const requested = Array.isArray(keys) ? keys : [keys]; + if (requested.includes("understudy:browserEpoch")) await epochGate; + return originalGet(keys); + }, + ); + const fetchMock = vi.fn( + async (_input: RequestInfo | URL, _init?: RequestInit) => + ticketResponse(), + ); + vi.stubGlobal("fetch", fetchMock); + const client = new ProfileClient(); + + const starting = client.start(); + await vi.waitFor(() => expect(fixture.sessionArea.get).toHaveBeenCalled()); + const configuring = client.configure(CONFIG); + releaseEpoch(); + await Promise.all([starting, configuring]); + + expect(client.browserEpoch()).toBe(EPOCH); + expect(fetchMock).toHaveBeenCalledOnce(); + expect(JSON.parse(fetchMock.mock.calls[0]?.[1]?.body as string)).toEqual({ + browserEpoch: EPOCH, + }); + }); + + it("restores ownership before a Stop All request that arrives during startup", async () => { + const assignment = { + sessionId: "session-startup", + leaseId: "lease-startup", + leaseEpoch: 1, + browserEpoch: EPOCH, + allowedOrigins: ["https://app.example"], + tabId: 7, + windowId: 3, + }; + const fixture = installBrowser({ + local: persistedConfig(CONFIG), + session: { + "understudy:browserEpoch": EPOCH, + "understudy:assignments": { + version: 2, + assignments: [assignment], + closedOutbox: [], + }, + }, + }); + const originalGet = fixture.sessionArea.get.getMockImplementation(); + if (originalGet === undefined) throw new Error("storage get mock missing"); + let releaseEpoch!: () => void; + const epochGate = new Promise((resolve) => { + releaseEpoch = resolve; + }); + fixture.sessionArea.get.mockImplementation( + async (keys: string | string[]) => { + const requested = Array.isArray(keys) ? keys : [keys]; + if (requested.includes("understudy:browserEpoch")) await epochGate; + return originalGet(keys); + }, + ); + const fetchMock = vi.fn( + async (_input: RequestInfo | URL, _init?: RequestInit) => + ticketResponse(), + ); + vi.stubGlobal("fetch", fetchMock); + const client = new ProfileClient(); + + const starting = client.start(); + await vi.waitFor(() => expect(fixture.sessionArea.get).toHaveBeenCalled()); + const stopping = client.stopAll(); + releaseEpoch(); + await Promise.all([starting, stopping]); + + expect(client.browserEpoch()).toBe(EPOCH); + expect(fixture.removeTab).toHaveBeenCalledWith(assignment.tabId); + expect(client.sessions.assignments()).toEqual([]); + expect(client.sessions.vacatedLeases()).toEqual([]); + expect(client.sessions.closureOutbox()).toEqual([ + { + sessionId: assignment.sessionId, + leaseId: assignment.leaseId, + leaseEpoch: assignment.leaseEpoch, + browserEpoch: assignment.browserEpoch, + }, + ]); + expect(JSON.parse(fetchMock.mock.calls[0]?.[1]?.body as string)).toEqual({ + browserEpoch: EPOCH, + }); + }); + + it("rejects a ticket websocket URL that changes the configured service origin", async () => { + installBrowser(); + const fetchMock = vi.fn(async () => + ticketResponse(200, async () => ({ + ticket: crypto.randomUUID(), + websocketPath: "https://attacker.example/agents/device/device", + })), + ); + vi.stubGlobal("fetch", fetchMock); + const client = new ProfileClient(); + + await client.configure(CONFIG); + await vi.advanceTimersByTimeAsync(60_000); + + expect(fetchMock).toHaveBeenCalledOnce(); + expect(FakeWebSocket.instances).toHaveLength(0); + expect(client.currentStatus()).toBe("error"); + }); + + it("persists credential revocation and cancels all ticket reconnects", async () => { + const fixture = installBrowser(); + const fetchMock = vi.fn( + async (_input: RequestInfo | URL) => ticketResponse(), + ); + vi.stubGlobal("fetch", fetchMock); + const client = new ProfileClient(); + await client.configure(CONFIG); + const control = FakeWebSocket.instances[0]; + control?.open(); + + control?.message({ + type: "credential_revoked", + }); + await vi.waitFor(() => expect(client.currentStatus()).toBe("error")); + await vi.advanceTimersByTimeAsync(60_000); + + expect(fixture.local.unattendedEnabled).toBe(false); + expect(fixture.local["understudy:credentialRevoked"]).toBe(true); + expect(fetchMock).toHaveBeenCalledOnce(); + }); + + it("releases a provision that finishes after Stop All without acknowledging provision", async () => { + const fixture = installBrowser({ + session: { "understudy:browserEpoch": EPOCH }, + }); + vi.stubGlobal("fetch", vi.fn(async () => ticketResponse())); + let resolveWindow!: (window: unknown) => void; + fixture.createWindow.mockImplementation( + () => + new Promise((resolve) => { + resolveWindow = resolve; + }), + ); + const client = new ProfileClient(); + await client.start(); + await client.configure(CONFIG); + const hosting = FakeWebSocket.instances[0]; + hosting?.open(); + hosting?.message({ + type: "provision", + sessionId: "session-1", + leaseId: "lease-1", + leaseEpoch: 1, + browserEpoch: EPOCH, + allowedOrigins: ["https://app.example"], + sessionTicket: "session-ticket", + }); + await vi.waitFor(() => expect(fixture.createWindow).toHaveBeenCalledOnce()); + + await client.stopAll(); + resolveWindow({ + id: 3, + tabs: [{ id: 7 }], + }); + await vi.waitFor(() => + expect(FakeWebSocket.instances).toHaveLength(2), + ); + const cleanup = FakeWebSocket.instances[1]; + cleanup?.open(); + await vi.waitFor(() => + expect(cleanup?.sent).toContainEqual({ + type: "closed", + sessionId: "session-1", + leaseId: "lease-1", + leaseEpoch: 1, + browserEpoch: EPOCH, + }), + ); + + expect(hosting?.sent).not.toContainEqual( + expect.objectContaining({ type: "provisioned" }), + ); + expect( + FakeWebSocket.instances.filter((socket) => + socket.url.includes("/agents/session/"), + ), + ).toHaveLength(0); + }); + + it("fences a live runtime before disabled profile persistence can fail", async () => { + const fixture = installBrowser({ + session: { "understudy:browserEpoch": EPOCH }, + }); + fixture.createWindow.mockResolvedValue({ + id: 3, + tabs: [{ id: 7 }], + }); + vi.stubGlobal("fetch", vi.fn(async () => ticketResponse())); + const client = new ProfileClient(); + await client.configure(CONFIG); + const control = FakeWebSocket.instances[0]; + control?.open(); + control?.message({ + type: "provision", + sessionId: "session-1", + leaseId: "lease-1", + leaseEpoch: 1, + browserEpoch: EPOCH, + allowedOrigins: ["https://app.example"], + sessionTicket: "session-ticket", + }); + await vi.waitFor(() => + expect(control?.sent).toContainEqual( + expect.objectContaining({ type: "provisioned" }), + ), + ); + const sessionSocket = FakeWebSocket.instances.find((socket) => + socket.url.includes("/agents/session/"), + ); + if (sessionSocket === undefined) throw new Error("session socket missing"); + fixture.localArea.set.mockRejectedValueOnce(new Error("profile write failed")); + + const stopping = client.stopAll(); + + expect(sessionSocket.closeCount).toBe(1); + expect(client.sessions.assignments()).toEqual([ + expect.objectContaining({ cleanupIntent: "release" }), + ]); + await expect(stopping).rejects.toThrow("profile write failed"); + expect(client.sessions.assignments()).toEqual([ + expect.objectContaining({ cleanupIntent: "release" }), + ]); + }); + + it("fences replacement work before hashing or profile persistence", async () => { + const fixture = installBrowser({ + session: { "understudy:browserEpoch": EPOCH }, + }); + fixture.createWindow.mockResolvedValue({ + id: 3, + tabs: [{ id: 7 }], + }); + vi.stubGlobal("fetch", vi.fn(async () => ticketResponse())); + const client = new ProfileClient(); + await client.configure(CONFIG); + const control = FakeWebSocket.instances[0]; + control?.open(); + control?.message({ + type: "provision", + sessionId: "session-1", + leaseId: "lease-1", + leaseEpoch: 1, + browserEpoch: EPOCH, + allowedOrigins: ["https://app.example"], + sessionTicket: "session-ticket", + }); + await vi.waitFor(() => + expect(control?.sent).toContainEqual( + expect.objectContaining({ type: "provisioned" }), + ), + ); + const sessionSocket = FakeWebSocket.instances.find((socket) => + socket.url.includes("/agents/session/"), + ); + if (sessionSocket === undefined) throw new Error("session socket missing"); + let releaseHash!: () => void; + const hashGate = new Promise((resolve) => { + releaseHash = resolve; + }); + const digest = vi + .spyOn(crypto.subtle, "digest") + .mockImplementation(async () => { + await hashGate; + return new Uint8Array(32).buffer; + }); + const writesBefore = fixture.localArea.set.mock.calls.length; + const replacement = { + ...CONFIG, + serviceOrigin: "https://new.example", + deviceCredential: "new-credential", + }; + + const configuring = client.configure(replacement); + + expect(sessionSocket.closeCount).toBe(1); + expect(digest).not.toHaveBeenCalled(); + expect(fixture.localArea.set).toHaveBeenCalledTimes(writesBefore); + await vi.waitFor(() => expect(digest).toHaveBeenCalledOnce()); + expect(fixture.localArea.set).toHaveBeenCalledTimes(writesBefore); + releaseHash(); + await configuring; + }); + + it("keeps a credential-revoked runtime fenced when profile persistence fails", async () => { + const fixture = installBrowser({ + session: { "understudy:browserEpoch": EPOCH }, + }); + fixture.createWindow.mockResolvedValue({ + id: 3, + tabs: [{ id: 7 }], + }); + vi.stubGlobal("fetch", vi.fn(async () => ticketResponse())); + const client = new ProfileClient(); + await client.configure(CONFIG); + const control = FakeWebSocket.instances[0]; + control?.open(); + control?.message({ + type: "provision", + sessionId: "session-1", + leaseId: "lease-1", + leaseEpoch: 1, + browserEpoch: EPOCH, + allowedOrigins: ["https://app.example"], + sessionTicket: "session-ticket", + }); + await vi.waitFor(() => + expect(control?.sent).toContainEqual( + expect.objectContaining({ type: "provisioned" }), + ), + ); + const sessionSocket = FakeWebSocket.instances.find((socket) => + socket.url.includes("/agents/session/"), + ); + if (sessionSocket === undefined) throw new Error("session socket missing"); + fixture.localArea.set.mockRejectedValueOnce(new Error("profile write failed")); + + control?.message({ type: "credential_revoked" }); + + await vi.waitFor(() => expect(sessionSocket.closeCount).toBe(1)); + expect(client.sessions.assignments()).toEqual([ + expect.objectContaining({ cleanupIntent: "discard" }), + ]); + }); +}); + +describe("ProfileClient startup cleanup", () => { + it("retains and resends a closure until the backend acknowledges its exact fence", async () => { + const closure = { + sessionId: "session-closed", + leaseId: "lease-closed", + leaseEpoch: 2, + browserEpoch: EPOCH, + }; + const fixture = installBrowser({ + local: persistedConfig({ ...CONFIG, unattendedEnabled: false }), + session: { + "understudy:browserEpoch": EPOCH, + "understudy:assignments": { + version: 3, + assignments: [], + closedOutbox: [closure], + vacatedLeases: [], + }, + }, + }); + vi.stubGlobal("fetch", vi.fn(async () => ticketResponse())); + const client = new ProfileClient(); + await client.start(); + const first = FakeWebSocket.instances[0]; + first?.open(); + await vi.waitFor(() => + expect(first?.sent).toContainEqual({ type: "closed", ...closure }), + ); + expect(client.sessions.closureOutbox()).toEqual([closure]); + + first?.emit("close", { code: 1006 }); + await vi.advanceTimersByTimeAsync(500); + expect(FakeWebSocket.instances).toHaveLength(2); + const second = FakeWebSocket.instances[1]; + second?.open(); + await vi.waitFor(() => + expect(second?.sent).toContainEqual({ type: "closed", ...closure }), + ); + expect(client.sessions.closureOutbox()).toEqual([closure]); + + second?.message({ type: "closed_ack", ...closure }); + await vi.waitFor(() => expect(client.sessions.closureOutbox()).toEqual([])); + expect( + ( + fixture.session["understudy:assignments"] as { + closedOutbox: unknown[]; + } + ).closedOutbox, + ).toEqual([]); + await vi.waitFor(() => expect(client.currentStatus()).toBe("disabled")); + }); + + it("consumes a vacated lease only after its replacement runtime is installed", async () => { + const vacated = { + sessionId: "session-vacated", + leaseId: "lease-vacated", + leaseEpoch: 2, + browserEpoch: EPOCH, + }; + const fixture = installBrowser({ + local: persistedConfig(CONFIG), + session: { + "understudy:browserEpoch": EPOCH, + "understudy:assignments": { + version: 3, + assignments: [], + closedOutbox: [], + vacatedLeases: [vacated], + }, + }, + }); + fixture.createWindow.mockResolvedValue({ + id: 3, + tabs: [{ id: 7 }], + }); + vi.stubGlobal("fetch", vi.fn(async () => ticketResponse())); + const client = new ProfileClient(); + await client.start(); + const control = FakeWebSocket.instances[0]; + control?.open(); + + control?.message({ + type: "provision", + ...vacated, + allowedOrigins: ["https://app.example"], + sessionTicket: "session-ticket", + }); + + await vi.waitFor(() => + expect(control?.sent).toContainEqual( + expect.objectContaining({ + type: "provisioned", + sessionId: vacated.sessionId, + leaseId: vacated.leaseId, + }), + ), + ); + expect(client.sessions.vacatedLeases()).toEqual([]); + expect(client.sessions.assignments()).toEqual([ + expect.objectContaining({ + sessionId: vacated.sessionId, + leaseId: vacated.leaseId, + }), + ]); + }); + + it("promotes a vacated lease through the old profile before committing a replacement", async () => { + const vacated = { + sessionId: "session-vacated", + leaseId: "lease-vacated", + leaseEpoch: 2, + browserEpoch: EPOCH, + }; + const fixture = installBrowser({ + local: persistedConfig(CONFIG), + session: { + "understudy:browserEpoch": EPOCH, + "understudy:assignments": { + version: 3, + assignments: [], + closedOutbox: [], + vacatedLeases: [vacated], + }, + }, + }); + const fetchMock = vi.fn( + async (_input: RequestInfo | URL) => ticketResponse(), + ); + vi.stubGlobal("fetch", fetchMock); + const client = new ProfileClient(); + await client.start(); + const hosting = FakeWebSocket.instances[0]; + hosting?.open(); + + const replacement: ProfileConfig = { + ...CONFIG, + serviceOrigin: "https://new.example", + deviceCredential: "new-credential", + }; + await client.configure(replacement); + const cleanup = FakeWebSocket.instances[1]; + cleanup?.open(); + + await vi.waitFor(() => + expect(cleanup?.sent).toContainEqual({ type: "closed", ...vacated }), + ); + expect(client.sessions.closureOutbox()).toEqual([vacated]); + expect(FakeWebSocket.instances).toHaveLength(2); + cleanup?.message({ type: "closed_ack", ...vacated }); + await vi.waitFor(() => expect(FakeWebSocket.instances).toHaveLength(3)); + expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([ + "https://old.example/v1/device/connect-ticket", + "https://old.example/v1/device/connect-ticket", + "https://new.example/v1/device/connect-ticket", + ]); + expect(fixture.local.serviceOrigin).toBe("https://new.example"); + }); + + it("discards local ownership without ticket churn after durable credential revocation", async () => { + const assignment = { + sessionId: "session-1", + leaseId: "lease-1", + leaseEpoch: 1, + browserEpoch: EPOCH, + allowedOrigins: ["https://app.example"], + tabId: 7, + windowId: 3, + }; + const fixture = installBrowser({ + local: { + ...persistedConfig({ ...CONFIG, unattendedEnabled: false }), + "understudy:credentialRevoked": true, + }, + session: { + "understudy:browserEpoch": EPOCH, + "understudy:assignments": { + version: 2, + assignments: [assignment], + closedOutbox: [ + { + sessionId: assignment.sessionId, + leaseId: assignment.leaseId, + leaseEpoch: assignment.leaseEpoch, + browserEpoch: assignment.browserEpoch, + }, + ], + }, + }, + }); + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + const client = new ProfileClient(); + + await client.start(); + + expect(fixture.removeTab).toHaveBeenCalledWith(assignment.tabId); + expect(client.sessions.assignments()).toEqual([]); + expect(client.sessions.closureOutbox()).toEqual([]); + expect(fetchMock).not.toHaveBeenCalled(); + expect(client.currentStatus()).toBe("error"); + }); + + it("releases same-epoch assignments while the persisted profile is disabled", async () => { + const assignment = { + sessionId: "session-1", + leaseId: "lease-1", + leaseEpoch: 1, + browserEpoch: EPOCH, + allowedOrigins: ["https://app.example"], + tabId: 7, + windowId: 3, + }; + const fixture = installBrowser({ + local: persistedConfig({ ...CONFIG, unattendedEnabled: false }), + session: { + "understudy:browserEpoch": EPOCH, + "understudy:assignments": [assignment], + }, + }); + vi.stubGlobal("fetch", vi.fn(async () => ticketResponse())); + const client = new ProfileClient(); + + await client.start(); + expect(fixture.removeTab).toHaveBeenCalledWith(7); + expect(FakeWebSocket.instances).toHaveLength(1); + FakeWebSocket.instances[0]?.open(); + await vi.waitFor(() => + expect(FakeWebSocket.instances[0]?.sent).toContainEqual({ + type: "closed", + sessionId: "session-1", + leaseId: "lease-1", + leaseEpoch: 1, + browserEpoch: EPOCH, + }), + ); + expect(client.sessions.closureOutbox()).toHaveLength(1); + FakeWebSocket.instances[0]?.message({ + type: "closed_ack", + sessionId: "session-1", + leaseId: "lease-1", + leaseEpoch: 1, + browserEpoch: EPOCH, + }); + await vi.waitFor(() => + expect(client.currentStatus()).toBe("disabled"), + ); + }); + + it("keeps an offline replacement staged behind the old cleanup identity", async () => { + const assignment = { + sessionId: "session-1", + leaseId: "lease-1", + leaseEpoch: 1, + browserEpoch: EPOCH, + allowedOrigins: ["https://app.example"], + tabId: 7, + windowId: 3, + }; + const fixture = installBrowser({ + local: persistedConfig(CONFIG), + session: { + "understudy:browserEpoch": EPOCH, + "understudy:assignments": [assignment], + }, + }); + fixture.removeTab.mockRejectedValue(new Error("tab still exists")); + const fetchMock = vi + .fn() + .mockResolvedValueOnce(ticketResponse()) + .mockRejectedValue(new Error("old control offline")); + vi.stubGlobal("fetch", fetchMock); + const client = new ProfileClient(); + await client.start(); + expect(FakeWebSocket.instances).toHaveLength(1); + + fixture.removeTab.mockResolvedValue(undefined); + const replacement: ProfileConfig = { + ...CONFIG, + serviceOrigin: "https://new.example", + deviceCredential: "new-credential", + }; + await client.configure(replacement); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(fetchMock.mock.calls[1]?.[0]).toBe( + "https://old.example/v1/device/connect-ticket", + ); + expect(FakeWebSocket.instances).toHaveLength(1); + expect(client.publicConfig()).toMatchObject({ + serviceOrigin: "https://old.example", + unattendedEnabled: false, + }); + expect(fixture.local.serviceOrigin).toBe("https://old.example"); + expect(fixture.local.unattendedEnabled).toBe(false); + expect(fixture.local["understudy:stagedProfile"]).toEqual(replacement); + expect(client.sessions.closureOutbox()).toHaveLength(1); + }); + + it("promotes a replacement only after an exact durable closure acknowledgement", async () => { + const assignment = { + sessionId: "session-1", + leaseId: "lease-1", + leaseEpoch: 1, + browserEpoch: EPOCH, + allowedOrigins: ["https://app.example"], + tabId: 7, + windowId: 3, + }; + const fixture = installBrowser({ + local: persistedConfig(CONFIG), + session: { + "understudy:browserEpoch": EPOCH, + "understudy:assignments": [assignment], + }, + }); + fixture.removeTab.mockRejectedValueOnce(new Error("tab still exists")); + const fetchMock = vi.fn(async (_input: RequestInfo | URL) => + ticketResponse(), + ); + vi.stubGlobal("fetch", fetchMock); + const client = new ProfileClient(); + await client.start(); + + fixture.removeTab.mockResolvedValue(undefined); + const replacement: ProfileConfig = { + ...CONFIG, + serviceOrigin: "https://new.example", + deviceCredential: "new-credential", + }; + await client.configure(replacement); + + expect(fixture.local.serviceOrigin).toBe(CONFIG.serviceOrigin); + expect(fixture.local.unattendedEnabled).toBe(false); + expect(fixture.local["understudy:stagedProfile"]).toEqual(replacement); + expect(FakeWebSocket.instances).toHaveLength(2); + + const cleanup = FakeWebSocket.instances[1]; + cleanup?.open(); + await vi.waitFor(() => + expect(cleanup?.sent).toContainEqual({ + type: "closed", + sessionId: assignment.sessionId, + leaseId: assignment.leaseId, + leaseEpoch: assignment.leaseEpoch, + browserEpoch: assignment.browserEpoch, + }), + ); + expect(client.sessions.closureOutbox()).toHaveLength(1); + cleanup?.message({ + type: "closed_ack", + sessionId: assignment.sessionId, + leaseId: assignment.leaseId, + leaseEpoch: assignment.leaseEpoch + 1, + browserEpoch: assignment.browserEpoch, + }); + await vi.advanceTimersByTimeAsync(1); + expect(client.sessions.closureOutbox()).toHaveLength(1); + expect(FakeWebSocket.instances).toHaveLength(2); + + cleanup?.message({ + type: "closed_ack", + sessionId: assignment.sessionId, + leaseId: assignment.leaseId, + leaseEpoch: assignment.leaseEpoch, + browserEpoch: assignment.browserEpoch, + }); + await vi.waitFor(() => expect(FakeWebSocket.instances).toHaveLength(3)); + expect(client.sessions.closureOutbox()).toEqual([]); + expect( + ( + fixture.session["understudy:assignments"] as { + closedOutbox: unknown[]; + } + ).closedOutbox, + ).toEqual([]); + + expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([ + "https://old.example/v1/device/connect-ticket", + "https://old.example/v1/device/connect-ticket", + "https://new.example/v1/device/connect-ticket", + ]); + expect(fixture.local.serviceOrigin).toBe(replacement.serviceOrigin); + expect(fixture.local.unattendedEnabled).toBe(true); + expect(fixture.local["understudy:stagedProfile"]).toBeNull(); + }); + + it("rejects provisions and session tickets on cleanup-only connectivity", async () => { + const assignment = { + sessionId: "session-1", + leaseId: "lease-1", + leaseEpoch: 1, + browserEpoch: EPOCH, + allowedOrigins: ["https://app.example"], + tabId: 7, + windowId: 3, + cleanupIntent: "release", + }; + const fixture = installBrowser({ + local: persistedConfig({ ...CONFIG, unattendedEnabled: false }), + session: { + "understudy:browserEpoch": EPOCH, + "understudy:assignments": { + version: 2, + assignments: [assignment], + closedOutbox: [], + }, + }, + }); + fixture.removeTab.mockRejectedValue(new Error("tab still exists")); + vi.stubGlobal("fetch", vi.fn(async () => ticketResponse())); + const client = new ProfileClient(); + + await client.start(); + const cleanup = FakeWebSocket.instances[0]; + cleanup?.open(); + cleanup?.message({ + type: "provision", + sessionId: "session-2", + leaseId: "lease-2", + leaseEpoch: 1, + browserEpoch: EPOCH, + allowedOrigins: ["https://app.example"], + sessionTicket: "session-ticket-2", + }); + cleanup?.message({ + type: "session_ticket", + sessionId: "session-1", + leaseId: "lease-1", + leaseEpoch: 1, + browserEpoch: EPOCH, + sessionTicket: "replacement-ticket", + }); + await vi.advanceTimersByTimeAsync(1_000); + + expect(fixture.createWindow).not.toHaveBeenCalled(); + expect( + FakeWebSocket.instances.filter((socket) => + socket.url.includes("/agents/session/"), + ), + ).toHaveLength(0); + expect(client.sessions.assignments()).toEqual([ + expect.objectContaining({ cleanupIntent: "release" }), + ]); + }); +}); diff --git a/apps/extension/src/core/profile-client.ts b/apps/extension/src/core/profile-client.ts new file mode 100644 index 0000000..780ae6d --- /dev/null +++ b/apps/extension/src/core/profile-client.ts @@ -0,0 +1,1071 @@ +import { + DEVICE_CONTROL_FRAME_MAX_BYTES, + PROTOCOL_CAPABILITIES, + PROTOCOL_VERSION, + WS_CLOSE_REPLACED, + WS_CLOSE_SESSION_TERMINAL, + safeParseDeviceControlServerFrame, + type DeviceControlClientFrame, +} from "@understudy/protocol"; +import { + SessionManager, + StaleProvisionError, + type ClosureRecord, +} from "./session-manager"; +import { ReconnectingWs } from "./ws-client"; + +const BROWSER_EPOCH_KEY = "understudy:browserEpoch"; +const STAGED_CONFIG_KEY = "understudy:stagedProfile"; +const CREDENTIAL_REVOKED_KEY = "understudy:credentialRevoked"; +const CONTROL_BLOCK_KEY = "understudy:controlBlock"; +const CONFIG_KEYS = [ + "serviceOrigin", + "unattendedEnabled", + "deviceId", + "deviceCredential", + "originPolicy", +] as const; +const TICKET_BACKOFF_BASE_MS = 500; +const TICKET_BACKOFF_CAP_MS = 30_000; + +type ControlPurpose = "hosting" | "cleanup"; +type ControlBlockReason = + | "replaced" + | "terminal_close" + | "ticket_rejected" + | "invalid_ticket"; + +interface ControlBlock { + version: 1; + profileKey: string; + reason: ControlBlockReason; +} + +interface ControlAttempt { + generation: number; + config: ProfileConfig; + purpose: ControlPurpose; + controller: AbortController; +} + +export interface ProfileConfig { + serviceOrigin: string; + unattendedEnabled: boolean; + deviceId: string; + deviceCredential: string; + originPolicy: string[]; +} + +export type ProfileStatus = "disabled" | "connecting" | "connected" | "error"; + +export class ProfileClient { + readonly sessions: SessionManager; + private config: ProfileConfig | null = null; + private stagedConfig: ProfileConfig | null = null; + private credentialRevoked = false; + private controlBlock: ControlBlock | null = null; + private blockedProfileIdentity: string | null = null; + private activeProfileKey: string | null = null; + private epoch = ""; + private control: ReconnectingWs | null = null; + private controlAttempt: ControlAttempt | null = null; + private retryTimer: ReturnType | null = null; + private ticketBackoffMs = TICKET_BACKOFF_BASE_MS; + private generation = 0; + private status: ProfileStatus = "disabled"; + private controlFrameTail: Promise = Promise.resolve(); + private configWriteTail: Promise = Promise.resolve(); + private initialization: Promise | null = null; + private lifecycleTail: Promise = Promise.resolve(); + + constructor(private readonly onStatus?: (status: ProfileStatus) => void) { + this.sessions = new SessionManager( + () => this.requiredConfig().serviceOrigin, + () => this.epoch, + ); + } + + start(): Promise { + return this.startRequest(this.generation); + } + + private async startRequest(generation: number): Promise { + await this.enqueueLifecycle(async () => { + await this.ensureInitialized(); + }); + await this.resumeForGeneration(generation); + } + + private ensureInitialized(): Promise { + return (this.initialization ??= this.initialize()); + } + + private async initialize(): Promise { + await this.restrictLocalStorage(); + this.epoch = await this.loadBrowserEpoch(); + const stored = await this.loadProfileState(); + this.config = stored.active; + this.stagedConfig = stored.staged; + this.credentialRevoked = stored.credentialRevoked; + this.controlBlock = stored.controlBlock; + if (this.config !== null) { + this.activeProfileKey = await profileKey(this.config); + } + if (this.config !== null && this.controlBlock !== null) { + if (this.activeProfileKey === this.controlBlock.profileKey) { + this.blockedProfileIdentity = profileIdentity(this.config); + } else { + this.controlBlock = null; + await browser.storage.local.remove(CONTROL_BLOCK_KEY); + } + } else if (this.controlBlock !== null) { + this.controlBlock = null; + await browser.storage.local.remove(CONTROL_BLOCK_KEY); + } + + const restoreIntent = + this.credentialRevoked || + this.blockedProfileIdentity !== null || + this.config === null + ? "discard" + : this.config.unattendedEnabled && this.stagedConfig === null + ? "recover" + : "release"; + await this.sessions.restoreSameEpoch(restoreIntent); + + if (this.config === null) { + await this.sessions.discardServerState(); + this.setStatus("disabled"); + return; + } + if (this.credentialRevoked || this.blockedProfileIdentity !== null) { + await this.sessions.discardServerState(); + this.setStatus("error"); + } + } + + browserEpoch(): string { + return this.epoch; + } + + currentStatus(): ProfileStatus { + return this.status; + } + + publicConfig(): Omit | null { + if (this.config === null) return null; + return { + serviceOrigin: this.config.serviceOrigin, + unattendedEnabled: this.config.unattendedEnabled, + deviceId: this.config.deviceId, + originPolicy: [...this.config.originPolicy], + }; + } + + configure(config: ProfileConfig): Promise { + const normalized = normalizeProfileConfig(config); + const generation = this.invalidateControl(); + const cleanupIntent = this.configureCleanupIntent(normalized); + if (cleanupIntent !== null) { + this.sessions.beginStopAll(cleanupIntent); + } + return this.configureRequest(normalized, generation); + } + + private async configureRequest( + normalized: ProfileConfig, + generation: number, + ): Promise { + await this.enqueueLifecycle(async () => { + await this.ensureInitialized(); + if (!this.isGenerationCurrent(generation)) return; + await this.configureInitialized(normalized, generation); + }); + await this.resumeForGeneration(generation); + } + + private async configureInitialized( + normalized: ProfileConfig, + generation: number, + ): Promise { + const cleanupIntent = this.configureCleanupIntent(normalized); + if (cleanupIntent !== null) { + this.sessions.beginStopAll(cleanupIntent); + } + const normalizedKey = await profileKey(normalized); + if (!this.isGenerationCurrent(generation)) return; + this.blockedProfileIdentity = null; + this.controlBlock = null; + const wasCredentialRevoked = this.credentialRevoked; + if (wasCredentialRevoked) { + this.config = normalized; + this.activeProfileKey = normalizedKey; + this.stagedConfig = null; + if (!(await this.persistProfileState(normalized, null, generation))) return; + await this.sessions.stopAll("discard"); + if (!this.isGenerationCurrent(generation)) return; + await this.sessions.discardServerState(); + if (!this.isGenerationCurrent(generation)) return; + this.credentialRevoked = false; + try { + if (!(await this.persistProfileState(normalized, null, generation))) return; + } catch (error) { + if (this.isGenerationCurrent(generation)) { + this.credentialRevoked = true; + } + throw error; + } + return; + } + this.credentialRevoked = false; + const current = this.config; + const identityChanged = + current !== null && profileIdentity(current) !== profileIdentity(normalized); + const ownsOldWork = + current !== null && + (current.unattendedEnabled || + this.sessions.assignments().length > 0 || + this.sessions.closureOutbox().length > 0 || + this.sessions.vacatedLeases().length > 0); + + if (current !== null && identityChanged && ownsOldWork) { + this.config = { ...current, unattendedEnabled: false }; + this.stagedConfig = normalized; + if ( + !(await this.persistProfileState( + this.config, + this.stagedConfig, + generation, + )) + ) { + return; + } + await this.sessions.stopAll("release"); + if (!this.isGenerationCurrent(generation)) return; + return; + } + + this.config = normalized; + this.activeProfileKey = normalizedKey; + this.stagedConfig = null; + if (!(await this.persistProfileState(normalized, null, generation))) return; + if (!normalized.unattendedEnabled) { + await this.sessions.stopAll("release"); + if (!this.isGenerationCurrent(generation)) return; + } + } + + stopAll(): Promise { + const generation = this.invalidateControl(); + this.sessions.beginStopAll( + this.credentialRevoked ? "discard" : "release", + ); + return this.stopAllRequest(generation); + } + + private async stopAllRequest(generation: number): Promise { + await this.enqueueLifecycle(async () => { + await this.ensureInitialized(); + if (!this.isGenerationCurrent(generation)) return; + await this.stopAllInitialized(generation); + }); + await this.resumeForGeneration(generation); + } + + private async stopAllInitialized(generation: number): Promise { + const intent = this.credentialRevoked ? "discard" : "release"; + this.sessions.beginStopAll(intent); + this.stagedConfig = null; + if (this.config !== null) { + this.config = { ...this.config, unattendedEnabled: false }; + if (!(await this.persistProfileState(this.config, null, generation))) return; + } + await this.sessions.stopAll(intent); + if (!this.isGenerationCurrent(generation)) return; + } + + ensureConnection(): Promise { + return this.ensureConnectionRequest(); + } + + private async ensureConnectionRequest(): Promise { + let generation = this.generation; + await this.enqueueLifecycle(async () => { + await this.ensureInitialized(); + generation = this.generation; + await this.sessions.retryCleanup(); + }); + if (!this.isGenerationCurrent(generation)) return; + const attempt = this.controlAttempt; + if (attempt !== null && this.control !== null) { + await this.flushClosureOutbox(attempt, this.control); + return; + } + await this.resumeForGeneration(generation); + } + + private async resumeForGeneration(generation: number): Promise { + if (!this.isGenerationCurrent(generation)) return; + if (this.credentialRevoked || this.isControlBlocked()) { + this.setStatus("error"); + return; + } + const config = this.config; + if (config === null) { + this.setStatus("disabled"); + return; + } + + if ( + this.sessions.closureOutbox().length > 0 || + this.sessions.pendingReleaseCleanup() + ) { + await this.connectControl(config, "cleanup", generation); + return; + } + + if (this.sessions.pendingCleanup()) { + if (config.unattendedEnabled && this.stagedConfig === null) { + await this.connectControl(config, "hosting", generation); + } else { + this.setStatus("disabled"); + } + return; + } + + if (this.stagedConfig !== null) { + await this.promoteStaged(generation); + return; + } + + if (config.unattendedEnabled) { + await this.connectControl(config, "hosting", generation); + } else { + this.setStatus("disabled"); + } + } + + private async connectControl( + config: ProfileConfig, + purpose: ControlPurpose, + generation: number, + ): Promise { + if ( + !this.controlDesired(config, purpose, generation) || + this.control !== null || + this.controlAttempt !== null || + this.retryTimer !== null + ) { + return; + } + + this.setStatus("connecting"); + const attempt: ControlAttempt = { + generation, + config: cloneConfig(config), + purpose, + controller: new AbortController(), + }; + this.controlAttempt = attempt; + let response: Response; + try { + response = await fetch( + new URL("/v1/device/connect-ticket", config.serviceOrigin).toString(), + { + method: "POST", + headers: { + authorization: `Bearer ${config.deviceCredential}`, + "content-type": "application/json", + }, + body: JSON.stringify({ browserEpoch: this.epoch }), + signal: attempt.controller.signal, + }, + ); + } catch (error) { + if (!this.isAttemptCurrent(attempt)) return; + this.controlAttempt = null; + if (isAbortError(error)) return; + this.scheduleRetry(attempt); + return; + } + if (!this.isAttemptCurrent(attempt)) return; + if (!response.ok) { + this.controlAttempt = null; + if (isRetryableTicketStatus(response.status)) { + this.scheduleRetry(attempt); + } else { + await this.blockControlAfterTicketError( + attempt.config, + "ticket_rejected", + ); + } + return; + } + + let value: unknown; + try { + value = await response.json(); + } catch { + if (!this.isAttemptCurrent(attempt)) return; + this.controlAttempt = null; + await this.blockControlAfterTicketError( + attempt.config, + "invalid_ticket", + ); + return; + } + if (!this.isAttemptCurrent(attempt)) return; + if (!isTicketResponse(value)) { + this.controlAttempt = null; + await this.blockControlAfterTicketError( + attempt.config, + "invalid_ticket", + ); + return; + } + + let url: URL; + try { + url = new URL(value.websocketPath, config.serviceOrigin); + if ( + url.origin !== config.serviceOrigin || + (url.protocol !== "https:" && url.protocol !== "http:") + ) { + throw new Error("ticket websocket path changed service origin"); + } + } catch { + this.controlAttempt = null; + await this.blockControlAfterTicketError( + attempt.config, + "invalid_ticket", + ); + return; + } + url.protocol = url.protocol === "https:" ? "wss:" : "ws:"; + url.searchParams.set("ticket", value.ticket); + if (!this.isAttemptCurrent(attempt)) return; + + let peer!: ReconnectingWs; + peer = new ReconnectingWs( + () => url.toString(), + { + onCommand: (raw) => { + if (!this.isPeerCurrent(attempt, peer)) return; + const handleIfCurrent = async () => { + if (this.isPeerCurrent(attempt, peer)) { + await this.onControlFrame(raw, attempt, peer); + } + }; + const handling = this.controlFrameTail.then( + handleIfCurrent, + handleIfCurrent, + ); + this.controlFrameTail = handling.catch(() => {}); + }, + onOpen: () => { + if (!this.isPeerCurrent(attempt, peer)) return; + this.ticketBackoffMs = TICKET_BACKOFF_BASE_MS; + this.setStatus("connected"); + peer.send({ + type: "device_hello", + protocolVersion: PROTOCOL_VERSION, + capabilities: [...PROTOCOL_CAPABILITIES], + deviceId: config.deviceId, + browserEpoch: this.epoch, + browser: navigator.userAgent, + extVersion: browser.runtime.getManifest().version, + allowedOrigins: config.originPolicy, + } satisfies DeviceControlClientFrame); + void this.flushClosureOutbox(attempt, peer).catch(() => {}); + }, + onClose: (event) => { + if (!this.isPeerCurrent(attempt, peer)) return; + if ( + event.code === WS_CLOSE_REPLACED || + event.code === WS_CLOSE_SESSION_TERMINAL + ) { + const reason = + event.code === WS_CLOSE_REPLACED + ? "replaced" + : "terminal_close"; + this.blockedProfileIdentity = profileIdentity(attempt.config); + this.invalidateControl(); + this.sessions.beginStopAll("discard"); + this.setStatus("error"); + void this.enqueueLifecycle(async () => { + await this.ensureInitialized(); + await this.persistControlBlockAndDiscard( + attempt.config, + reason, + ); + }).catch(() => { + this.setStatus("error"); + }); + return; + } + peer.stop(); + this.control = null; + this.controlAttempt = null; + if ( + this.controlDesired( + attempt.config, + attempt.purpose, + attempt.generation, + ) + ) { + this.scheduleRetry(attempt); + } else { + void this.resumeForGeneration(attempt.generation); + } + }, + heartbeatFrame: () => ({ + type: "heartbeat", + deviceId: config.deviceId, + browserEpoch: this.epoch, + leaseIds: this.sessions + .assignments() + .map((assignment) => assignment.leaseId), + }), + }, + DEVICE_CONTROL_FRAME_MAX_BYTES, + ); + if (!this.isAttemptCurrent(attempt)) { + peer.stop(); + return; + } + this.control = peer; + } + + private async onControlFrame( + raw: unknown, + attempt: ControlAttempt, + peer: ReconnectingWs, + ): Promise { + if (!this.isPeerCurrent(attempt, peer)) return; + const parsed = safeParseDeviceControlServerFrame(raw); + if (!parsed.success || !this.isPeerCurrent(attempt, peer)) return; + const frame = parsed.data; + switch (frame.type) { + case "provision": + if (attempt.purpose !== "hosting") return; + try { + const tab = await this.sessions.provision( + frame, + () => this.isHostingPeerCurrent(attempt, peer), + ); + if (!this.isHostingPeerCurrent(attempt, peer)) return; + peer.send({ + type: "provisioned", + sessionId: frame.sessionId, + leaseId: frame.leaseId, + leaseEpoch: frame.leaseEpoch, + browserEpoch: frame.browserEpoch, + tab, + } satisfies DeviceControlClientFrame); + } catch (error) { + if ( + error instanceof StaleProvisionError || + !this.isHostingPeerCurrent(attempt, peer) + ) { + await this.ensureConnection(); + return; + } + peer.send({ + type: "provision_failed", + sessionId: frame.sessionId, + leaseId: frame.leaseId, + leaseEpoch: frame.leaseEpoch, + browserEpoch: frame.browserEpoch, + reason: "local provisioning failed", + } satisfies DeviceControlClientFrame); + } + return; + case "close_lease": + await this.sessions.closeLease(frame, "release"); + if (!this.isPeerCurrent(attempt, peer)) return; + await this.flushClosureOutbox(attempt, peer); + return; + case "session_ticket": + if (attempt.purpose !== "hosting") return; + if (!this.isHostingPeerCurrent(attempt, peer)) return; + this.sessions.connectSessionTicket(frame); + return; + case "credential_revoked": + this.invalidateControl(); + this.sessions.beginStopAll("discard"); + await this.enqueueLifecycle(async () => { + await this.handleCredentialRevoked(); + }); + return; + case "closed_ack": { + const acknowledged = await this.sessions.acknowledgeClosure(frame); + if (!acknowledged || !this.isPeerCurrent(attempt, peer)) return; + await this.flushClosureOutbox(attempt, peer); + return; + } + } + } + + private async flushClosureOutbox( + attempt: ControlAttempt, + peer: ReconnectingWs, + ): Promise { + for (const entry of this.sessions.closureOutbox()) { + if (!this.isPeerCurrent(attempt, peer)) return; + if (!peer.send(closedFrame(entry))) return; + } + if ( + attempt.purpose === "cleanup" && + !this.sessions.pendingReleaseCleanup() && + this.sessions.closureOutbox().length === 0 + ) { + peer.stop(); + if (this.control === peer) this.control = null; + if (this.controlAttempt === attempt) this.controlAttempt = null; + await this.resumeForGeneration(attempt.generation); + } + } + + private async promoteStaged(generation: number): Promise { + const staged = this.stagedConfig; + if (staged === null || !this.isGenerationCurrent(generation)) return; + const stagedKey = await profileKey(staged); + if (!this.isGenerationCurrent(generation)) return; + if (!(await this.persistProfileState(staged, null, generation))) return; + if (!this.isGenerationCurrent(generation)) return; + this.config = staged; + this.activeProfileKey = stagedKey; + this.stagedConfig = null; + if (staged.unattendedEnabled) { + await this.connectControl(staged, "hosting", generation); + } else { + this.setStatus("disabled"); + } + } + + private async handleCredentialRevoked(): Promise { + this.sessions.beginStopAll("discard"); + this.credentialRevoked = true; + this.blockedProfileIdentity = null; + this.controlBlock = null; + this.stagedConfig = null; + if (this.config !== null) { + this.config = { ...this.config, unattendedEnabled: false }; + if (!(await this.persistProfileState(this.config, null))) return; + } + await this.sessions.stopAll("discard"); + await this.sessions.discardServerState(); + this.setStatus("error"); + } + + private async blockControlAfterTicketError( + config: ProfileConfig, + reason: ControlBlockReason, + ): Promise { + this.sessions.beginStopAll("discard"); + this.blockedProfileIdentity = profileIdentity(config); + this.invalidateControl(); + await this.enqueueLifecycle(async () => { + await this.persistControlBlockAndDiscard(config, reason); + }); + } + + private async persistControlBlockAndDiscard( + config: ProfileConfig, + reason: ControlBlockReason, + ): Promise { + this.sessions.beginStopAll("discard"); + if ( + this.config === null || + profileIdentity(this.config) !== profileIdentity(config) + ) { + if (this.blockedProfileIdentity === profileIdentity(config)) { + this.blockedProfileIdentity = null; + } + return; + } + this.blockedProfileIdentity = profileIdentity(config); + const key = this.activeProfileKey; + if (key === null) return; + const block: ControlBlock = { + version: 1, + profileKey: key, + reason, + }; + this.controlBlock = block; + await browser.storage.local.set({ [CONTROL_BLOCK_KEY]: block }); + if (!this.isControlBlocked()) return; + await this.sessions.stopAll("discard"); + if (!this.isControlBlocked()) return; + await this.sessions.discardServerState(); + if (!this.isControlBlocked()) return; + this.setStatus("error"); + } + + private scheduleRetry(attempt: ControlAttempt): void { + if (!this.controlDesired(attempt.config, attempt.purpose, attempt.generation)) { + return; + } + if (this.retryTimer !== null) return; + const delayMs = this.ticketBackoffMs; + this.ticketBackoffMs = Math.min( + this.ticketBackoffMs * 2, + TICKET_BACKOFF_CAP_MS, + ); + this.setStatus("connecting"); + this.retryTimer = setTimeout(() => { + this.retryTimer = null; + void this.connectControl( + attempt.config, + attempt.purpose, + attempt.generation, + ); + }, delayMs); + } + + private invalidateControl(): number { + this.generation += 1; + this.controlAttempt?.controller.abort(); + this.controlAttempt = null; + if (this.retryTimer !== null) { + clearTimeout(this.retryTimer); + this.retryTimer = null; + } + this.ticketBackoffMs = TICKET_BACKOFF_BASE_MS; + this.control?.stop(); + this.control = null; + this.controlFrameTail = Promise.resolve(); + return this.generation; + } + + private isGenerationCurrent(generation: number): boolean { + return generation === this.generation; + } + + private isAttemptCurrent(attempt: ControlAttempt): boolean { + return ( + this.controlAttempt === attempt && + this.controlDesired( + attempt.config, + attempt.purpose, + attempt.generation, + ) + ); + } + + private isPeerCurrent( + attempt: ControlAttempt, + peer: ReconnectingWs, + ): boolean { + return this.control === peer && this.isAttemptCurrent(attempt); + } + + private isHostingPeerCurrent( + attempt: ControlAttempt, + peer: ReconnectingWs, + ): boolean { + return ( + attempt.purpose === "hosting" && + this.isPeerCurrent(attempt, peer) && + this.config?.unattendedEnabled === true && + this.stagedConfig === null + ); + } + + private controlDesired( + config: ProfileConfig, + purpose: ControlPurpose, + generation: number, + ): boolean { + if ( + !this.isGenerationCurrent(generation) || + this.config === null || + this.isControlBlocked() + ) { + return false; + } + if (profileIdentity(this.config) !== profileIdentity(config)) return false; + if (purpose === "hosting") { + return this.config.unattendedEnabled && this.stagedConfig === null; + } + return ( + this.controlAttempt?.purpose === "cleanup" || + this.sessions.closureOutbox().length > 0 || + this.sessions.pendingReleaseCleanup() + ); + } + + private configureCleanupIntent( + normalized: ProfileConfig, + ): "release" | "discard" | null { + if (this.credentialRevoked) return "discard"; + const current = this.config; + if (current === null) return null; + const identityChanged = + profileIdentity(current) !== profileIdentity(normalized); + const disabling = current.unattendedEnabled && !normalized.unattendedEnabled; + if (!identityChanged && !disabling) return null; + const ownsOldWork = + current.unattendedEnabled || + this.sessions.assignments().length > 0 || + this.sessions.closureOutbox().length > 0 || + this.sessions.vacatedLeases().length > 0; + return ownsOldWork ? "release" : null; + } + + private async persistProfileState( + active: ProfileConfig, + staged: ProfileConfig | null, + generation?: number, + ): Promise { + let written = false; + const write = this.configWriteTail.then(async () => { + if ( + generation !== undefined && + !this.isGenerationCurrent(generation) + ) { + return; + } + await browser.storage.local.set({ + ...active, + [STAGED_CONFIG_KEY]: staged === null ? null : cloneConfig(staged), + [CREDENTIAL_REVOKED_KEY]: this.credentialRevoked, + [CONTROL_BLOCK_KEY]: this.controlBlock, + }); + written = + generation === undefined || this.isGenerationCurrent(generation); + }); + this.configWriteTail = write.catch(() => {}); + await write; + return written; + } + + private async loadBrowserEpoch(): Promise { + const stored = await browser.storage.session.get(BROWSER_EPOCH_KEY); + const value = stored[BROWSER_EPOCH_KEY]; + if (typeof value === "string" && value.length > 0) return value; + const epoch = crypto.randomUUID(); + await browser.storage.session.set({ [BROWSER_EPOCH_KEY]: epoch }); + return epoch; + } + + private async loadProfileState(): Promise<{ + active: ProfileConfig | null; + staged: ProfileConfig | null; + credentialRevoked: boolean; + controlBlock: ControlBlock | null; + }> { + const stored = await browser.storage.local.get([ + ...CONFIG_KEYS, + STAGED_CONFIG_KEY, + CREDENTIAL_REVOKED_KEY, + CONTROL_BLOCK_KEY, + ]); + const candidate = { + serviceOrigin: stored.serviceOrigin, + unattendedEnabled: stored.unattendedEnabled, + deviceId: stored.deviceId, + deviceCredential: stored.deviceCredential, + originPolicy: stored.originPolicy, + }; + let active: ProfileConfig | null; + try { + active = normalizeProfileConfig(candidate); + } catch { + active = null; + } + let staged: ProfileConfig | null; + try { + staged = + stored[STAGED_CONFIG_KEY] === null || + stored[STAGED_CONFIG_KEY] === undefined + ? null + : normalizeProfileConfig(stored[STAGED_CONFIG_KEY]); + } catch { + staged = null; + } + return { + active, + staged, + credentialRevoked: stored[CREDENTIAL_REVOKED_KEY] === true, + controlBlock: parseControlBlock(stored[CONTROL_BLOCK_KEY]), + }; + } + + private async restrictLocalStorage(): Promise { + const area = browser.storage.local as Browser.storage.StorageArea & { + setAccessLevel?: (options: { + accessLevel: "TRUSTED_CONTEXTS"; + }) => Promise; + }; + await area.setAccessLevel?.({ accessLevel: "TRUSTED_CONTEXTS" }); + } + + private requiredConfig(): ProfileConfig { + if (this.config === null) { + throw new Error("unattended profile is not configured"); + } + return this.config; + } + + private isControlBlocked(): boolean { + return ( + this.config !== null && + this.blockedProfileIdentity === profileIdentity(this.config) + ); + } + + private enqueueLifecycle(operation: () => Promise): Promise { + const run = this.lifecycleTail.then(operation, operation); + this.lifecycleTail = run.catch(() => {}); + return run; + } + + private setStatus(status: ProfileStatus): void { + this.status = status; + this.onStatus?.(status); + } +} + +function normalizeProfileConfig(value: unknown): ProfileConfig { + if (typeof value !== "object" || value === null) { + throw new Error("invalid profile config"); + } + const input = value as Partial; + const origin = + typeof input.serviceOrigin === "string" + ? new URL(input.serviceOrigin) + : null; + const serviceLoopback = + origin !== null && + (origin.hostname === "localhost" || + origin.hostname === "127.0.0.1" || + origin.hostname === "[::1]" || + origin.hostname.endsWith(".localhost")); + if ( + origin === null || + (origin.protocol !== "https:" && + !(origin.protocol === "http:" && serviceLoopback)) || + origin.username !== "" || + origin.password !== "" || + origin.pathname !== "/" || + origin.search !== "" || + origin.hash !== "" || + typeof input.unattendedEnabled !== "boolean" || + typeof input.deviceId !== "string" || + !/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test( + input.deviceId, + ) || + typeof input.deviceCredential !== "string" || + input.deviceCredential.length < 1 || + input.deviceCredential.length > 4 * 1024 || + !Array.isArray(input.originPolicy) || + input.originPolicy.length < 1 || + input.originPolicy.length > 32 || + !input.originPolicy.every((item) => typeof item === "string") + ) { + throw new Error("invalid profile config"); + } + const originPolicy = [ + ...new Set(input.originPolicy.map(canonicalOrigin)), + ].sort(); + return { + serviceOrigin: origin.origin, + unattendedEnabled: input.unattendedEnabled, + deviceId: input.deviceId.toLowerCase(), + deviceCredential: input.deviceCredential, + originPolicy, + }; +} + +function canonicalOrigin(value: string): string { + if ( + value !== value.trim() || + value.includes("*") || + value.includes("?") || + value.includes("#") + ) { + throw new Error("invalid local origin policy"); + } + const url = new URL(value); + const loopback = + url.hostname === "localhost" || + url.hostname === "127.0.0.1" || + url.hostname === "[::1]" || + url.hostname.endsWith(".localhost"); + if ( + url.username !== "" || + url.password !== "" || + (url.pathname !== "" && url.pathname !== "/") || + (url.protocol !== "https:" && + !(url.protocol === "http:" && loopback)) + ) { + throw new Error("invalid local origin policy"); + } + return url.origin; +} + +function cloneConfig(config: ProfileConfig): ProfileConfig { + return { ...config, originPolicy: [...config.originPolicy] }; +} + +function profileIdentity(config: ProfileConfig): string { + return JSON.stringify({ + serviceOrigin: config.serviceOrigin, + deviceId: config.deviceId, + deviceCredential: config.deviceCredential, + originPolicy: config.originPolicy, + }); +} + +async function profileKey(config: ProfileConfig): Promise { + const digest = await crypto.subtle.digest( + "SHA-256", + new TextEncoder().encode(profileIdentity(config)), + ); + return Array.from(new Uint8Array(digest), (byte) => + byte.toString(16).padStart(2, "0"), + ).join(""); +} + +function parseControlBlock(value: unknown): ControlBlock | null { + if (typeof value !== "object" || value === null) return null; + const candidate = value as Partial; + if ( + candidate.version !== 1 || + typeof candidate.profileKey !== "string" || + !/^[0-9a-f]{64}$/.test(candidate.profileKey) || + (candidate.reason !== "replaced" && + candidate.reason !== "terminal_close" && + candidate.reason !== "ticket_rejected" && + candidate.reason !== "invalid_ticket") + ) { + return null; + } + return candidate as ControlBlock; +} + +function isTicketResponse( + value: unknown, +): value is { ticket: string; websocketPath: string } { + if (typeof value !== "object" || value === null) return false; + const ticket = value as { ticket?: unknown; websocketPath?: unknown }; + return ( + typeof ticket.ticket === "string" && + ticket.ticket.length > 0 && + typeof ticket.websocketPath === "string" && + ticket.websocketPath.length > 0 + ); +} + +function isRetryableTicketStatus(status: number): boolean { + return status === 408 || status === 429 || status >= 500; +} + +function isAbortError(error: unknown): boolean { + return error instanceof DOMException && error.name === "AbortError"; +} + +function closedFrame(entry: ClosureRecord): DeviceControlClientFrame { + return { type: "closed", ...entry }; +} diff --git a/apps/extension/src/core/router.test.ts b/apps/extension/src/core/router.test.ts index 4c8a088..77f6147 100644 --- a/apps/extension/src/core/router.test.ts +++ b/apps/extension/src/core/router.test.ts @@ -36,11 +36,12 @@ function asSession(mock: MockSession): CdpSession { return mock as unknown as CdpSession; } -function stubBrowserTabs(): { query: Mock; update: Mock } { +function stubBrowserTabs(): { get: Mock; query: Mock; update: Mock } { + const get = vi.fn(); const query = vi.fn(); const update = vi.fn(); - vi.stubGlobal("browser", { tabs: { query, update } }); - return { query, update }; + vi.stubGlobal("browser", { tabs: { get, query, update } }); + return { get, query, update }; } afterEach(() => { @@ -85,6 +86,30 @@ describe("routeCommand", () => { expect(result).toEqual(event); }); + it("rejects a result whose complete WebSocket frame exceeds the session limit", async () => { + const mock = createMockSession(); + mock.screenshot.mockResolvedValue({ + type: "screenshot_result", + commandId: "c-large", + mime: "image/png", + b64: "a".repeat(16 * 1024 * 1024), + tabId: 7, + url: "https://example.com/", + }); + + const result = await routeCommand( + { type: "snapshot", commandId: "c-large", mode: "screenshot" }, + asSession(mock), + ); + + expect(result).toEqual({ + type: "action_result", + commandId: "c-large", + ok: false, + error: "command result exceeded protocol limits", + }); + }); + it("rejects a snapshot requested for a tab other than the attached CDP session", async () => { const mock = createMockSession(); const cmd: Command = { type: "snapshot", commandId: "c-mismatch", mode: "a11y", tabId: 8 }; @@ -239,38 +264,54 @@ describe("routeCommand", () => { }); }); - it("returns tabs_result with the mapped open tabs for get_tabs", async () => { - const { query } = stubBrowserTabs(); - query.mockResolvedValue([ - { id: 1, url: "https://a.example/", title: "A", active: true }, - { id: 2, url: "https://b.example/", title: "B", active: false }, - ]); + it("returns only the session-owned tab for get_tabs", async () => { + const { get, query } = stubBrowserTabs(); + get.mockResolvedValue({ + id: 7, + url: "https://owned.example/", + title: "Owned", + active: false, + }); const cmd: Command = { type: "get_tabs", commandId: "c-tabs" }; - const result = await routeCommand(cmd, null); + const result = await routeCommand(cmd, asSession(createMockSession())); - expect(query).toHaveBeenCalledWith({}); + expect(get).toHaveBeenCalledWith(7); + expect(query).not.toHaveBeenCalled(); expect(result).toEqual({ type: "tabs_result", commandId: "c-tabs", tabs: [ - { tabId: 1, url: "https://a.example/", title: "A", active: true }, - { tabId: 2, url: "https://b.example/", title: "B", active: false }, + { tabId: 7, url: "https://owned.example/", title: "Owned", active: false }, ], }); }); - it("activates the tab and returns action_result ok for switch_tab", async () => { + it("accepts the owned tab without activating through the tabs API", async () => { const { update } = stubBrowserTabs(); - update.mockResolvedValue(undefined); const cmd: Command = { type: "switch_tab", commandId: "c-switch", tabId: 7 }; - const result = await routeCommand(cmd, null); + const result = await routeCommand(cmd, asSession(createMockSession())); - expect(update).toHaveBeenCalledWith(7, { active: true }); + expect(update).not.toHaveBeenCalled(); expect(result).toEqual({ type: "action_result", commandId: "c-switch", ok: true }); }); + it("rejects switch_tab for every profile tab not owned by the session", async () => { + const { update } = stubBrowserTabs(); + const cmd: Command = { type: "switch_tab", commandId: "c-switch-other", tabId: 8 }; + + const result = await routeCommand(cmd, asSession(createMockSession())); + + expect(update).not.toHaveBeenCalled(); + expect(result).toEqual({ + type: "action_result", + commandId: "c-switch-other", + ok: false, + error: "tab 8 is not owned by this session", + }); + }); + it("returns action_result failure for a null session on a session-requiring command", async () => { const cmd: Command = { type: "click", commandId: "c-null", ref: "s0e1" }; diff --git a/apps/extension/src/core/router.ts b/apps/extension/src/core/router.ts index 5231274..46fbb74 100644 --- a/apps/extension/src/core/router.ts +++ b/apps/extension/src/core/router.ts @@ -1,7 +1,12 @@ -import type { Command, Event } from "@understudy/protocol"; +import { + SESSION_RESULT_FRAME_MAX_BYTES, + safeParseEvent, + utf8ByteLength, + type Command, + type Event, +} from "@understudy/protocol"; import type { CdpSession } from "../driver/cdp"; import { actionError, errorMessage } from "../events"; -import { queryTabInfos } from "../tabs"; async function withSession( session: CdpSession | null, @@ -21,17 +26,51 @@ async function withSession( return run(session); } -async function routeGetTabs(commandId: string): Promise { - const tabs = await queryTabInfos(); - return { type: "tabs_result", commandId, tabs }; +async function routeGetTabs(commandId: string, session: CdpSession | null): Promise { + if (session === null) return actionError(commandId, "no active CDP session"); + const tab = await browser.tabs.get(session.tabId); + return { + type: "tabs_result", + commandId, + tabs: [ + { + tabId: session.tabId, + url: tab.url ?? session.currentUrl, + title: tab.title ?? "", + active: tab.active, + }, + ], + }; } -async function routeSwitchTab(commandId: string, tabId: number): Promise { - await browser.tabs.update(tabId, { active: true }); +async function routeSwitchTab( + commandId: string, + tabId: number, + session: CdpSession | null, +): Promise { + if (session === null) return actionError(commandId, "no active CDP session"); + if (tabId !== session.tabId) { + return actionError(commandId, `tab ${tabId} is not owned by this session`); + } return { type: "action_result", commandId, ok: true }; } export async function routeCommand(cmd: Command, session: CdpSession | null): Promise { + const event = await routeCommandUnchecked(cmd, session); + const parsed = safeParseEvent(event); + if ( + parsed.success && + utf8ByteLength(JSON.stringify(parsed.data)) <= SESSION_RESULT_FRAME_MAX_BYTES + ) { + return parsed.data; + } + return actionError(cmd.commandId, "command result exceeded protocol limits"); +} + +async function routeCommandUnchecked( + cmd: Command, + session: CdpSession | null, +): Promise { try { switch (cmd.type) { case "snapshot": { @@ -89,9 +128,9 @@ export async function routeCommand(cmd: Command, session: CdpSession | null): Pr ); } case "get_tabs": - return await routeGetTabs(cmd.commandId); + return await routeGetTabs(cmd.commandId, session); case "switch_tab": - return await routeSwitchTab(cmd.commandId, cmd.tabId); + return await routeSwitchTab(cmd.commandId, cmd.tabId, session); default: { const fallback = cmd as Command; return actionError(fallback.commandId, `unhandled command type: ${fallback.type}`); diff --git a/apps/extension/src/core/session-manager.test.ts b/apps/extension/src/core/session-manager.test.ts new file mode 100644 index 0000000..0ae8231 --- /dev/null +++ b/apps/extension/src/core/session-manager.test.ts @@ -0,0 +1,367 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { SessionManager } from "./session-manager"; + +const EPOCH = "browser-epoch-1"; +const ASSIGNMENT = { + sessionId: "session-1", + leaseId: "lease-1", + leaseEpoch: 1, + browserEpoch: EPOCH, + allowedOrigins: ["https://app.example"], + tabId: 7, + windowId: 3, +}; + +type SocketListener = (event: Event) => void; + +class FakeSessionSocket { + static readonly OPEN = 1; + static instances: FakeSessionSocket[] = []; + + readyState = 0; + closed = false; + private readonly listeners = new Map(); + + constructor(readonly url: string) { + FakeSessionSocket.instances.push(this); + } + + addEventListener(type: string, listener: SocketListener): void { + const listeners = this.listeners.get(type) ?? []; + listeners.push(listener); + this.listeners.set(type, listeners); + } + + send(): void {} + + close(): void { + this.closed = true; + } +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("SessionManager cleanup ownership", () => { + it("retains failed recover cleanup and records a vacated lease after confirmed removal", async () => { + const sessionState: Record = { + "understudy:assignments": { + version: 2, + assignments: [{ ...ASSIGNMENT, cleanupIntent: "recover" }], + closedOutbox: [], + }, + }; + let tabExists = true; + const remove = vi.fn(async () => { + if (tabExists) throw new Error("remove failed"); + }); + installBrowser(sessionState, remove, async () => { + if (tabExists) return { id: ASSIGNMENT.tabId }; + throw new Error("tab not found"); + }); + const manager = new SessionManager( + () => "https://service.example", + () => EPOCH, + ); + + await manager.restoreSameEpoch(); + expect(manager.assignments()).toEqual([ + expect.objectContaining({ cleanupIntent: "recover" }), + ]); + expect(manager.closureOutbox()).toEqual([]); + expect(manager.vacatedLeases()).toEqual([]); + + tabExists = false; + await manager.retryCleanup(); + expect(manager.assignments()).toEqual([]); + expect(manager.closureOutbox()).toEqual([]); + expect(manager.vacatedLeases()).toEqual([ + { + sessionId: ASSIGNMENT.sessionId, + leaseId: ASSIGNMENT.leaseId, + leaseEpoch: ASSIGNMENT.leaseEpoch, + browserEpoch: ASSIGNMENT.browserEpoch, + }, + ]); + }); + + it("upgrades pending recovery to release and queues closure only after removal", async () => { + const sessionState: Record = { + "understudy:assignments": { + version: 2, + assignments: [{ ...ASSIGNMENT, cleanupIntent: "recover" }], + closedOutbox: [], + }, + }; + let tabExists = true; + const remove = vi.fn(async () => { + if (tabExists) throw new Error("remove failed"); + }); + installBrowser(sessionState, remove, async () => { + if (tabExists) return { id: ASSIGNMENT.tabId }; + throw new Error("tab not found"); + }); + const manager = new SessionManager( + () => "https://service.example", + () => EPOCH, + ); + + await manager.restoreSameEpoch("release"); + expect(manager.closureOutbox()).toEqual([]); + expect(manager.assignments()).toEqual([ + expect.objectContaining({ cleanupIntent: "release" }), + ]); + + tabExists = false; + await manager.retryCleanup(); + expect(manager.assignments()).toEqual([]); + expect(manager.closureOutbox()).toEqual([ + { + sessionId: ASSIGNMENT.sessionId, + leaseId: ASSIGNMENT.leaseId, + leaseEpoch: ASSIGNMENT.leaseEpoch, + browserEpoch: ASSIGNMENT.browserEpoch, + }, + ]); + expect(manager.vacatedLeases()).toEqual([]); + }); + + it("promotes a vacated lease when the server requests closure", async () => { + const sessionState: Record = { + "understudy:assignments": { + version: 3, + assignments: [], + closedOutbox: [], + vacatedLeases: [ + { + sessionId: ASSIGNMENT.sessionId, + leaseId: ASSIGNMENT.leaseId, + leaseEpoch: ASSIGNMENT.leaseEpoch, + browserEpoch: ASSIGNMENT.browserEpoch, + }, + ], + }, + }; + installBrowser( + sessionState, + async () => {}, + async () => { + throw new Error("tab not found"); + }, + ); + const manager = new SessionManager( + () => "https://service.example", + () => EPOCH, + ); + + await manager.restoreSameEpoch(); + await expect(manager.closeLease(ASSIGNMENT)).resolves.toBe(true); + + expect(manager.vacatedLeases()).toEqual([]); + expect(manager.closureOutbox()).toEqual([ + { + sessionId: ASSIGNMENT.sessionId, + leaseId: ASSIGNMENT.leaseId, + leaseEpoch: ASSIGNMENT.leaseEpoch, + browserEpoch: ASSIGNMENT.browserEpoch, + }, + ]); + }); + + it.each(["release", "discard"] as const)( + "retires attached assignments without reconciling during %s restoration", + async (intent) => { + const sessionState: Record = { + "understudy:assignments": { + version: 3, + assignments: [ASSIGNMENT], + closedOutbox: [], + vacatedLeases: [], + }, + }; + const fixture = installBrowser( + sessionState, + async () => {}, + async () => { + throw new Error("tab not found"); + }, + [{ tabId: ASSIGNMENT.tabId, attached: true }], + ); + const manager = new SessionManager( + () => "https://service.example", + () => EPOCH, + ); + + await manager.restoreSameEpoch(intent); + + expect(fixture.getTargets).not.toHaveBeenCalled(); + expect(fixture.sendCommand).not.toHaveBeenCalled(); + expect(fixture.remove).toHaveBeenCalledWith(ASSIGNMENT.tabId); + expect(manager.assignments()).toEqual([]); + expect(manager.closureOutbox()).toEqual( + intent === "release" + ? [ + { + sessionId: ASSIGNMENT.sessionId, + leaseId: ASSIGNMENT.leaseId, + leaseEpoch: ASSIGNMENT.leaseEpoch, + browserEpoch: ASSIGNMENT.browserEpoch, + }, + ] + : [], + ); + }, + ); + + it("reconciles a healthy attached assignment during ordinary recovery", async () => { + const sessionState: Record = { + "understudy:assignments": { + version: 3, + assignments: [ASSIGNMENT], + closedOutbox: [], + vacatedLeases: [], + }, + }; + const fixture = installBrowser( + sessionState, + async () => {}, + async () => ({ id: ASSIGNMENT.tabId }), + [{ tabId: ASSIGNMENT.tabId, attached: true }], + ); + const manager = new SessionManager( + () => "https://service.example", + () => EPOCH, + ); + + await manager.restoreSameEpoch("recover"); + + expect(fixture.sendCommand).toHaveBeenCalledWith( + { tabId: ASSIGNMENT.tabId }, + "Page.getFrameTree", + undefined, + ); + expect(fixture.remove).not.toHaveBeenCalled(); + expect(manager.assignments()).toHaveLength(1); + expect(manager.assignments()[0]?.cleanupIntent).toBeUndefined(); + }); + + it("fences every runtime and stops every session socket before a failed Stop All write", async () => { + FakeSessionSocket.instances = []; + vi.stubGlobal("WebSocket", FakeSessionSocket); + const second = { + ...ASSIGNMENT, + sessionId: "session-2", + leaseId: "lease-2", + tabId: 8, + windowId: 4, + }; + const sessionState: Record = { + "understudy:assignments": { + version: 3, + assignments: [ASSIGNMENT, second], + closedOutbox: [], + vacatedLeases: [], + }, + }; + const fixture = installBrowser( + sessionState, + async () => {}, + async (tabId) => ({ id: tabId }), + [ + { tabId: ASSIGNMENT.tabId, attached: true }, + { tabId: second.tabId, attached: true }, + ], + ); + const manager = new SessionManager( + () => "https://service.example", + () => EPOCH, + ); + await manager.restoreSameEpoch("recover"); + expect( + manager.connectSessionTicket({ + ...ASSIGNMENT, + sessionTicket: "ticket-1", + }), + ).toBe(true); + expect( + manager.connectSessionTicket({ + ...second, + sessionTicket: "ticket-2", + }), + ).toBe(true); + expect(FakeSessionSocket.instances).toHaveLength(2); + fixture.sessionSet.mockRejectedValueOnce(new Error("persist failed")); + + const stopping = manager.stopAll("release"); + + expect(manager.assignments()).toEqual([ + expect.objectContaining({ leaseId: "lease-1", cleanupIntent: "release" }), + expect.objectContaining({ leaseId: "lease-2", cleanupIntent: "release" }), + ]); + expect(FakeSessionSocket.instances.every((socket) => socket.closed)).toBe( + true, + ); + await expect(stopping).rejects.toThrow("persist failed"); + expect(manager.assignments()).toEqual([ + expect.objectContaining({ leaseId: "lease-1", cleanupIntent: "release" }), + expect.objectContaining({ leaseId: "lease-2", cleanupIntent: "release" }), + ]); + }); +}); + +function installBrowser( + sessionState: Record, + remove: (tabId: number) => Promise, + get: (tabId: number) => Promise, + targets: Array<{ tabId: number; attached: boolean }> = [], +): { + remove: ReturnType; + sessionSet: ReturnType; + getTargets: ReturnType; + sendCommand: ReturnType; +} { + const removeMock = vi.fn(remove); + const sessionSet = vi.fn(async (values: Record) => { + Object.assign(sessionState, values); + }); + const getTargets = vi.fn(async () => targets); + const sendCommand = vi.fn( + async (_target: unknown, method: string) => + method === "Page.getFrameTree" + ? { + frameTree: { + frame: { + id: "main-frame", + loaderId: "loader-1", + url: "about:blank", + }, + }, + } + : {}, + ); + vi.stubGlobal("browser", { + storage: { + session: { + get: vi.fn(async (key: string) => ({ + [key]: sessionState[key], + })), + set: sessionSet, + }, + }, + debugger: { + getTargets, + sendCommand, + }, + tabs: { + remove: removeMock, + get: vi.fn(get), + }, + }); + return { + remove: removeMock, + sessionSet, + getTargets, + sendCommand, + }; +} diff --git a/apps/extension/src/core/session-manager.ts b/apps/extension/src/core/session-manager.ts new file mode 100644 index 0000000..02e95ea --- /dev/null +++ b/apps/extension/src/core/session-manager.ts @@ -0,0 +1,612 @@ +import type { TabInfo } from "@understudy/protocol"; +import type { Browser } from "wxt/browser"; +import { + SessionRuntime, + type CleanupIntent, + type ManagedAssignment, + type RuntimeAssignment, + type RuntimeHost, +} from "./session-runtime"; + +const MANAGER_STATE_KEY = "understudy:assignments"; +const CAPACITY = 2; +const SERVER_RECORD_CAP = 100; + +export interface ProvisionInput { + sessionId: string; + leaseId: string; + leaseEpoch: number; + browserEpoch: string; + allowedOrigins: string[]; + sessionTicket: string; +} + +export type ClosureRecord = Pick< + RuntimeAssignment, + "sessionId" | "leaseId" | "leaseEpoch" | "browserEpoch" +>; + +interface PersistedManagerState { + version: 3; + assignments: ManagedAssignment[]; + closedOutbox: ClosureRecord[]; + vacatedLeases: ClosureRecord[]; +} + +export class SessionManager implements RuntimeHost { + private readonly bySession = new Map(); + private readonly byLease = new Map(); + private readonly byTab = new Map(); + private closedOutbox: ClosureRecord[] = []; + private vacated: ClosureRecord[] = []; + private persistTail: Promise = Promise.resolve(); + + constructor( + private readonly getServiceOrigin: () => string, + private readonly getBrowserEpoch: () => string, + ) {} + + serviceOrigin(): string { + return this.getServiceOrigin(); + } + + browserEpoch(): string { + return this.getBrowserEpoch(); + } + + isCurrent(runtime: SessionRuntime): boolean { + return ( + runtime.assignment.browserEpoch === this.browserEpoch() && + this.bySession.get(runtime.sessionId) === runtime && + this.byLease.get(runtime.leaseId) === runtime && + this.byTab.get(runtime.tabId) === runtime + ); + } + + async provision( + input: ProvisionInput, + isCurrent: () => boolean = () => true, + ): Promise { + if (!isCurrent()) throw new StaleProvisionError(); + const existing = this.byLease.get(input.leaseId); + if (existing !== undefined) { + if ( + existing.sessionId !== input.sessionId || + existing.assignment.leaseEpoch !== input.leaseEpoch || + existing.assignment.browserEpoch !== input.browserEpoch + ) { + throw new Error("lease assignment conflict"); + } + const tab = await this.tabInfo(existing.tabId); + if (!isCurrent()) { + await this.cleanup(existing, "release"); + throw new StaleProvisionError(); + } + existing.connect(input.sessionTicket); + return tab; + } + const vacated = this.vacated.find( + (entry) => entry.leaseId === input.leaseId, + ); + if (vacated !== undefined && !sameClosure(vacated, input)) { + throw new Error("vacated lease assignment conflict"); + } + if (input.browserEpoch !== this.browserEpoch()) { + throw new Error("browser epoch mismatch"); + } + if (this.byLease.size >= CAPACITY) { + throw new Error("controlled-tab capacity exhausted"); + } + + const createdWindow = await browser.windows.create({ + focused: false, + type: "normal", + url: "about:blank", + }); + const tab = createdWindow?.tabs?.[0]; + if (createdWindow?.id === undefined || tab?.id === undefined) { + throw new Error("Chrome did not return the extension-owned automation tab"); + } + const assignment: ManagedAssignment = { + sessionId: input.sessionId, + leaseId: input.leaseId, + leaseEpoch: input.leaseEpoch, + browserEpoch: input.browserEpoch, + allowedOrigins: input.allowedOrigins, + tabId: tab.id, + windowId: createdWindow.id, + }; + const runtime = new SessionRuntime(assignment, this); + this.install(runtime); + await this.persist(); + if (!isCurrent()) { + await this.cleanup(runtime, "release"); + throw new StaleProvisionError(); + } + + try { + await runtime.attach(); + if (!isCurrent()) { + await this.cleanup(runtime, "release"); + throw new StaleProvisionError(); + } + const info = await this.tabInfo(runtime.tabId); + if (!isCurrent()) { + await this.cleanup(runtime, "release"); + throw new StaleProvisionError(); + } + runtime.connect(input.sessionTicket); + if (vacated !== undefined) { + await this.consumeVacated(vacated); + if (!isCurrent()) { + await this.cleanup(runtime, "release"); + throw new StaleProvisionError(); + } + } + return info; + } catch (error) { + if (this.isCurrent(runtime) && runtime.assignment.cleanupIntent === undefined) { + await this.cleanup( + runtime, + error instanceof StaleProvisionError ? "release" : "discard", + ); + } + throw error; + } + } + + connectSessionTicket(input: { + sessionId: string; + leaseId: string; + leaseEpoch: number; + browserEpoch: string; + sessionTicket: string; + }): boolean { + const runtime = this.byLease.get(input.leaseId); + if ( + runtime === undefined || + runtime.sessionId !== input.sessionId || + runtime.assignment.leaseEpoch !== input.leaseEpoch || + runtime.assignment.browserEpoch !== input.browserEpoch || + runtime.assignment.cleanupIntent !== undefined + ) { + return false; + } + try { + runtime.connect(input.sessionTicket); + return true; + } catch { + return false; + } + } + + async closeLease( + input: ClosureRecord, + intent: CleanupIntent = "release", + ): Promise { + const runtime = this.byLease.get(input.leaseId); + if ( + runtime === undefined || + runtime.sessionId !== input.sessionId || + runtime.assignment.leaseEpoch !== input.leaseEpoch || + runtime.assignment.browserEpoch !== input.browserEpoch + ) { + const vacated = this.vacated.find((entry) => sameClosure(entry, input)); + if (vacated !== undefined) { + const previousOutbox = [...this.closedOutbox]; + const previousVacated = this.vacated; + if (intent === "release") { + this.enqueueClosure(vacated); + } + this.vacated = this.vacated.filter( + (entry) => !sameClosure(entry, vacated), + ); + try { + await this.persist(); + } catch (error) { + this.closedOutbox = previousOutbox; + this.vacated = previousVacated; + throw error; + } + return true; + } + return intent === "release" && this.hasOutboxEntry(input); + } + return this.cleanup(runtime, intent); + } + + async restoreSameEpoch( + unreconciledIntent: CleanupIntent = "recover", + ): Promise { + const stored = await browser.storage.session.get(MANAGER_STATE_KEY); + const persisted = parseManagerState(stored[MANAGER_STATE_KEY]); + this.closedOutbox = persisted.closedOutbox; + this.vacated = persisted.vacatedLeases; + if (unreconciledIntent === "release") { + this.promoteVacatedLeases(); + } else if (unreconciledIntent === "discard") { + this.closedOutbox = []; + this.vacated = []; + } + const restored: SessionRuntime[] = []; + for (const raw of persisted.assignments) { + if (raw.browserEpoch !== this.browserEpoch()) continue; + const runtime = new SessionRuntime({ ...raw }, this); + this.install(runtime); + restored.push(runtime); + } + if ( + unreconciledIntent === "release" || + unreconciledIntent === "discard" + ) { + for (const runtime of restored) { + runtime.beginCleanup(unreconciledIntent); + } + await this.persist(); + await this.retryCleanup(); + return; + } + + const targets = await browser.debugger.getTargets(); + for (const runtime of restored) { + const raw = runtime.assignment; + if (runtime.assignment.cleanupIntent !== undefined) { + continue; + } + const target = targets.find((candidate) => candidate.tabId === raw.tabId); + if (target?.attached !== true) { + runtime.beginCleanup(unreconciledIntent); + continue; + } + try { + await runtime.reconcileSameEpoch(); + } catch { + runtime.beginCleanup(unreconciledIntent); + } + } + await this.persist(); + await this.retryCleanup(); + } + + async retryCleanup(): Promise { + for (const runtime of [...this.byLease.values()]) { + const intent = runtime.assignment.cleanupIntent; + if (intent !== undefined) await this.cleanup(runtime, intent); + } + } + + async onCdpEvent( + source: { tabId?: number }, + method: string, + params: unknown, + ): Promise { + if (source.tabId === undefined) return; + const runtime = this.byTab.get(source.tabId); + if (runtime === undefined) return; + await runtime.onCdpEvent(method, params); + } + + async onDebuggerDetach(source: { tabId?: number }): Promise { + if (source.tabId === undefined) return; + const runtime = this.byTab.get(source.tabId); + if (runtime === undefined) return; + await runtime.onDebuggerDetach(); + } + + async closeRelatedPopup(tab: Browser.tabs.Tab): Promise { + if ( + tab.id !== undefined && + tab.openerTabId !== undefined && + this.byTab.has(tab.openerTabId) && + !this.byTab.has(tab.id) + ) { + await browser.tabs.remove(tab.id).catch(() => {}); + } + } + + async stopAll(intent: CleanupIntent = "release"): Promise { + this.beginStopAll(intent); + if (intent === "release") { + const previousOutbox = [...this.closedOutbox]; + const previousVacated = this.vacated; + try { + this.promoteVacatedLeases(); + await this.persist(); + } catch (error) { + this.closedOutbox = previousOutbox; + this.vacated = previousVacated; + throw error; + } + } else if (intent === "discard") { + this.closedOutbox = []; + this.vacated = []; + await this.persist(); + } + for (const runtime of [...this.byLease.values()]) { + await this.cleanup(runtime, intent); + } + } + + beginStopAll(intent: CleanupIntent): void { + for (const runtime of [...this.byLease.values()]) { + runtime.beginCleanup(intent); + } + } + + assignments(): ManagedAssignment[] { + return [...this.byLease.values()].map((runtime) => ({ + ...runtime.assignment, + allowedOrigins: [...runtime.assignment.allowedOrigins], + })); + } + + pendingCleanup(): boolean { + return [...this.byLease.values()].some( + (runtime) => runtime.assignment.cleanupIntent !== undefined, + ); + } + + pendingReleaseCleanup(): boolean { + return [...this.byLease.values()].some( + (runtime) => runtime.assignment.cleanupIntent === "release", + ); + } + + closureOutbox(): ClosureRecord[] { + return this.closedOutbox.map((entry) => ({ ...entry })); + } + + vacatedLeases(): ClosureRecord[] { + return this.vacated.map((entry) => ({ ...entry })); + } + + async acknowledgeClosure(entry: ClosureRecord): Promise { + if (!this.hasOutboxEntry(entry)) return false; + const previous = this.closedOutbox; + this.closedOutbox = previous.filter( + (candidate) => !sameClosure(candidate, entry), + ); + try { + await this.persist(); + } catch (error) { + this.closedOutbox = previous; + throw error; + } + return true; + } + + async discardServerState(): Promise { + if (this.closedOutbox.length === 0 && this.vacated.length === 0) return; + const previousOutbox = this.closedOutbox; + const previousVacated = this.vacated; + this.closedOutbox = []; + this.vacated = []; + try { + await this.persist(); + } catch (error) { + this.closedOutbox = previousOutbox; + this.vacated = previousVacated; + throw error; + } + } + + async onFenced(runtime: SessionRuntime): Promise { + if (!this.isCurrent(runtime)) return; + await this.cleanup(runtime, "recover"); + } + + async onTabChanged(_runtime: SessionRuntime): Promise { + // URLs and titles are intentionally not persisted. + } + + private install(runtime: SessionRuntime): void { + this.bySession.set(runtime.sessionId, runtime); + this.byLease.set(runtime.leaseId, runtime); + this.byTab.set(runtime.tabId, runtime); + } + + private uninstall(runtime: SessionRuntime): void { + if (this.bySession.get(runtime.sessionId) === runtime) { + this.bySession.delete(runtime.sessionId); + } + if (this.byLease.get(runtime.leaseId) === runtime) { + this.byLease.delete(runtime.leaseId); + } + if (this.byTab.get(runtime.tabId) === runtime) { + this.byTab.delete(runtime.tabId); + } + } + + private async cleanup( + runtime: SessionRuntime, + intent: CleanupIntent, + ): Promise { + if (!this.isCurrent(runtime)) return false; + runtime.beginCleanup(intent); + await this.persist(); + if (!(await runtime.close(true))) { + await this.persist(); + return false; + } + if (runtime.assignment.cleanupIntent === "release") { + this.enqueueClosure(runtime.assignment); + } else if (runtime.assignment.cleanupIntent === "recover") { + this.enqueueVacated(runtime.assignment); + } + this.uninstall(runtime); + await this.persist(); + return true; + } + + private enqueueClosure(assignment: ClosureRecord): void { + const entry: ClosureRecord = { + sessionId: assignment.sessionId, + leaseId: assignment.leaseId, + leaseEpoch: assignment.leaseEpoch, + browserEpoch: assignment.browserEpoch, + }; + if (this.hasOutboxEntry(entry)) return; + if (this.closedOutbox.length >= SERVER_RECORD_CAP) { + throw new Error("closure outbox capacity exhausted"); + } + this.closedOutbox.push(entry); + } + + private enqueueVacated(assignment: ClosureRecord): void { + const entry: ClosureRecord = { + sessionId: assignment.sessionId, + leaseId: assignment.leaseId, + leaseEpoch: assignment.leaseEpoch, + browserEpoch: assignment.browserEpoch, + }; + if (this.vacated.some((candidate) => sameClosure(candidate, entry))) return; + if (this.vacated.length >= SERVER_RECORD_CAP) { + throw new Error("vacated lease capacity exhausted"); + } + this.vacated.push(entry); + } + + private promoteVacatedLeases(): void { + for (const entry of this.vacated) this.enqueueClosure(entry); + this.vacated = []; + } + + private async consumeVacated(entry: ClosureRecord): Promise { + const previous = this.vacated; + this.vacated = previous.filter( + (candidate) => !sameClosure(candidate, entry), + ); + try { + await this.persist(); + } catch (error) { + this.vacated = previous; + throw error; + } + } + + private hasOutboxEntry(entry: ClosureRecord): boolean { + return this.closedOutbox.some((candidate) => sameClosure(candidate, entry)); + } + + private async persist(): Promise { + const write = this.persistTail.then(async () => { + const state: PersistedManagerState = { + version: 3, + assignments: this.assignments(), + closedOutbox: this.closureOutbox(), + vacatedLeases: this.vacatedLeases(), + }; + await browser.storage.session.set({ [MANAGER_STATE_KEY]: state }); + }); + this.persistTail = write.catch(() => {}); + await write; + } + + private async tabInfo(tabId: number): Promise { + const tab = await browser.tabs.get(tabId); + return { + tabId, + url: tab.url ?? "about:blank", + title: tab.title ?? "", + active: tab.active, + }; + } +} + +export class StaleProvisionError extends Error { + constructor() { + super("provisioning was superseded"); + } +} + +function parseManagerState(value: unknown): PersistedManagerState { + if (Array.isArray(value)) { + return { + version: 3, + assignments: value.filter(isManagedAssignment), + closedOutbox: [], + vacatedLeases: [], + }; + } + if (typeof value !== "object" || value === null) return emptyManagerState(); + const candidate = value as { + version?: unknown; + assignments?: unknown; + closedOutbox?: unknown; + vacatedLeases?: unknown; + }; + if ( + candidate.version === 2 && + Array.isArray(candidate.assignments) && + Array.isArray(candidate.closedOutbox) + ) { + return { + version: 3, + assignments: candidate.assignments.filter(isManagedAssignment), + closedOutbox: candidate.closedOutbox.filter(isClosureRecord), + vacatedLeases: [], + }; + } + if ( + candidate.version !== 3 || + !Array.isArray(candidate.assignments) || + !Array.isArray(candidate.closedOutbox) || + !Array.isArray(candidate.vacatedLeases) + ) { + return emptyManagerState(); + } + return { + version: 3, + assignments: candidate.assignments.filter(isManagedAssignment), + closedOutbox: candidate.closedOutbox.filter(isClosureRecord), + vacatedLeases: candidate.vacatedLeases.filter(isClosureRecord), + }; +} + +function emptyManagerState(): PersistedManagerState { + return { + version: 3, + assignments: [], + closedOutbox: [], + vacatedLeases: [], + }; +} + +function isManagedAssignment(value: unknown): value is ManagedAssignment { + if (typeof value !== "object" || value === null) return false; + const item = value as Partial; + return ( + typeof item.sessionId === "string" && + typeof item.leaseId === "string" && + typeof item.leaseEpoch === "number" && + typeof item.browserEpoch === "string" && + Array.isArray(item.allowedOrigins) && + item.allowedOrigins.every((origin) => typeof origin === "string") && + typeof item.tabId === "number" && + typeof item.windowId === "number" && + (item.cleanupIntent === undefined || + item.cleanupIntent === "recover" || + item.cleanupIntent === "release" || + item.cleanupIntent === "discard") + ); +} + +function isClosureRecord(value: unknown): value is ClosureRecord { + if (typeof value !== "object" || value === null) return false; + const item = value as Partial; + return ( + typeof item.sessionId === "string" && + typeof item.leaseId === "string" && + typeof item.leaseEpoch === "number" && + typeof item.browserEpoch === "string" + ); +} + +function sameClosure(left: ClosureRecord, right: ClosureRecord): boolean { + return ( + left.sessionId === right.sessionId && + left.leaseId === right.leaseId && + left.leaseEpoch === right.leaseEpoch && + left.browserEpoch === right.browserEpoch + ); +} diff --git a/apps/extension/src/core/session-runtime.test.ts b/apps/extension/src/core/session-runtime.test.ts new file mode 100644 index 0000000..5295309 --- /dev/null +++ b/apps/extension/src/core/session-runtime.test.ts @@ -0,0 +1,150 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { SessionStorageArea } from "./dedupe"; +import { SessionRuntime, type RuntimeAssignment, type RuntimeHost } from "./session-runtime"; + +const ASSIGNMENT: RuntimeAssignment = { + sessionId: "session-1", + leaseId: "lease-1", + leaseEpoch: 1, + browserEpoch: "epoch-1", + allowedOrigins: ["https://example.com"], + tabId: 7, + windowId: 3, +}; + +function host(): RuntimeHost & { onFenced: ReturnType } { + return { + serviceOrigin: () => "https://understudy.example", + browserEpoch: () => "epoch-1", + isCurrent: () => true, + onFenced: vi.fn(async () => {}), + onTabChanged: vi.fn(async () => {}), + }; +} + +function stubBrowser( + remove: () => Promise, + get = vi.fn(), + storage: SessionStorageArea = { + get: vi.fn(async () => ({})), + set: vi.fn(async () => {}), + remove: vi.fn(async () => {}), + }, +): void { + vi.stubGlobal("browser", { + storage: { session: storage }, + tabs: { remove: vi.fn(remove), get }, + }); +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("SessionRuntime close fencing", () => { + it("never downgrades release or discard cleanup ownership", () => { + stubBrowser(async () => {}); + const release = new SessionRuntime( + { ...ASSIGNMENT, cleanupIntent: "release" }, + host(), + ); + release.beginCleanup("recover"); + expect(release.assignment.cleanupIntent).toBe("release"); + + const discard = new SessionRuntime( + { ...ASSIGNMENT, cleanupIntent: "discard" }, + host(), + ); + discard.beginCleanup("release"); + expect(discard.assignment.cleanupIntent).toBe("discard"); + }); + + it("does not let an intentional debugger detach revoke ownership before tab removal", async () => { + let confirmRemoval!: () => void; + stubBrowser( + () => + new Promise((resolve) => { + confirmRemoval = resolve; + }), + ); + const runtimeHost = host(); + const runtime = new SessionRuntime(ASSIGNMENT, runtimeHost); + + const closing = runtime.close(true); + await runtime.onDebuggerDetach(); + expect(runtimeHost.onFenced).not.toHaveBeenCalled(); + confirmRemoval(); + await expect(closing).resolves.toBe(true); + }); + + it("refuses to confirm cleanup when Chrome reports the owned tab still exists", async () => { + stubBrowser( + async () => { + throw new Error("remove failed"); + }, + vi.fn(async () => ({ id: ASSIGNMENT.tabId })), + ); + const runtime = new SessionRuntime(ASSIGNMENT, host()); + + await expect(runtime.close(true)).resolves.toBe(false); + }); +}); + +describe("SessionRuntime dialog handling", () => { + it("answers Page.handleJavaScriptDialog without waiting for a stalled outbox write", async () => { + let markSetStarted!: () => void; + const setStarted = new Promise((resolve) => { + markSetStarted = resolve; + }); + let releaseSet!: () => void; + const setBlocked = new Promise((resolve) => { + releaseSet = resolve; + }); + const values: Record = {}; + const storage: SessionStorageArea = { + get: vi.fn(async (key: string) => ({ [key]: values[key] })), + set: vi.fn(async (items: Record) => { + markSetStarted(); + await setBlocked; + Object.assign(values, items); + }), + remove: vi.fn(async (key: string) => { + delete values[key]; + }), + }; + stubBrowser(async () => {}, vi.fn(), storage); + const runtime = new SessionRuntime(ASSIGNMENT, host()); + const cdpSend = vi.fn(async () => {}); + const send = vi.fn(); + Object.assign(runtime, { + cdp: { + currentUrl: "https://example.com/", + mainFrameId: "main", + send: cdpSend, + }, + send, + }); + + const handling = runtime.onCdpEvent("Page.javascriptDialogOpening", { + type: "confirm", + message: "Continue?", + url: "https://example.com/", + }); + await setStarted; + + expect(cdpSend).toHaveBeenCalledWith("Page.handleJavaScriptDialog", { + accept: false, + }); + expect(send).not.toHaveBeenCalled(); + + releaseSet(); + await handling; + expect(send).toHaveBeenCalledWith( + expect.objectContaining({ + type: "dialog", + dialogType: "confirm", + disposition: "dismiss", + }), + ); + }); +}); diff --git a/apps/extension/src/core/session-runtime.ts b/apps/extension/src/core/session-runtime.ts new file mode 100644 index 0000000..916a6e5 --- /dev/null +++ b/apps/extension/src/core/session-runtime.ts @@ -0,0 +1,487 @@ +import { + PROTOCOL_CAPABILITIES, + PROTOCOL_VERSION, + isWriteCommand, + safeParseSessionServerFrame, + type Command, + type Event, + type SessionServerFrame, + type TabInfo, +} from "@understudy/protocol"; +import { routeCommand } from "./router"; +import { ReconnectingWs } from "./ws-client"; +import { WriteJournal } from "./write-journal"; +import { DialogOutbox, handleDialogWithOutbox } from "./dialog-outbox"; +import { CdpSession } from "../driver/cdp"; +import { classifyCdpEvent } from "../driver/cdp-events"; + +export interface RuntimeAssignment { + sessionId: string; + leaseId: string; + leaseEpoch: number; + browserEpoch: string; + allowedOrigins: string[]; + tabId: number; + windowId: number; +} + +export type CleanupIntent = "recover" | "release" | "discard"; + +export interface ManagedAssignment extends RuntimeAssignment { + cleanupIntent?: CleanupIntent; +} + +export interface RuntimeHost { + serviceOrigin(): string; + browserEpoch(): string; + isCurrent(runtime: SessionRuntime): boolean; + onFenced(runtime: SessionRuntime): Promise; + onTabChanged(runtime: SessionRuntime): Promise; +} + +export class SessionRuntime { + readonly journal: WriteJournal; + readonly dialogs: DialogOutbox; + private peer: ReconnectingWs | null = null; + private cdp: CdpSession | null = null; + private accepting: boolean; + private writesBlocked = false; + private closing = false; + + constructor( + readonly assignment: ManagedAssignment, + private readonly host: RuntimeHost, + ) { + this.accepting = assignment.cleanupIntent === undefined; + this.journal = new WriteJournal( + browser.storage.session, + `understudy:journal:${assignment.sessionId}`, + ); + this.dialogs = new DialogOutbox( + browser.storage.session, + `understudy:dialogs:${assignment.sessionId}`, + ); + } + + get sessionId(): string { + return this.assignment.sessionId; + } + + get leaseId(): string { + return this.assignment.leaseId; + } + + get tabId(): number { + return this.assignment.tabId; + } + + matchesFence(frame: { + leaseId?: string; + leaseEpoch?: number; + browserEpoch?: string; + }): boolean { + return ( + frame.leaseId === this.assignment.leaseId && + frame.leaseEpoch === this.assignment.leaseEpoch && + frame.browserEpoch === this.assignment.browserEpoch + ); + } + + async attach(): Promise { + const cdp = await CdpSession.create(this.tabId, this.sessionId); + await cdp.attach(); + try { + await cdp.enableDomains(); + await cdp.enableUnattendedContainment(this.assignment.allowedOrigins); + } catch (error) { + await cdp.detach().catch(() => {}); + throw error; + } + this.cdp = cdp; + } + + async reconcileSameEpoch(): Promise { + const cdp = await CdpSession.create(this.tabId, this.sessionId); + await cdp.reconcile(); + await cdp.enableUnattendedContainment(this.assignment.allowedOrigins); + this.cdp = cdp; + } + + connect(ticket: string): void { + if ( + !this.accepting || + this.closing || + this.assignment.cleanupIntent !== undefined || + this.cdp === null + ) { + throw new Error("session runtime is not accepting connections"); + } + this.peer?.stop(); + const url = new URL( + `/agents/session/${encodeURIComponent(this.sessionId)}`, + this.host.serviceOrigin(), + ); + url.protocol = url.protocol === "https:" ? "wss:" : "ws:"; + url.searchParams.set("ticket", ticket); + let peer!: ReconnectingWs; + peer = new ReconnectingWs( + () => url.toString(), + { + onCommand: (raw) => { + if (peer === this.peer) void this.onServerFrame(raw).catch(() => {}); + }, + onOpen: () => { + if (peer === this.peer) void this.onOpen().catch(() => {}); + }, + onClose: () => { + if (peer === this.peer) peer.stop(); + }, + }, + ); + this.peer = peer; + } + + async close(closeTab: boolean): Promise { + this.closing = true; + this.accepting = false; + this.peer?.stop(); + this.peer = null; + const cdp = this.cdp; + this.cdp = null; + await cdp?.detach().catch(() => {}); + if (!closeTab) return true; + if ( + this.assignment.browserEpoch !== this.host.browserEpoch() || + !this.host.isCurrent(this) + ) { + this.closing = false; + return false; + } + try { + await browser.tabs.remove(this.tabId); + return true; + } catch { + try { + await browser.tabs.get(this.tabId); + this.closing = false; + return false; + } catch { + return true; + } + } + } + + beginCleanup(intent: CleanupIntent): void { + this.assignment.cleanupIntent = mergeCleanupIntent( + this.assignment.cleanupIntent, + intent, + ); + this.accepting = false; + this.peer?.stop(); + this.peer = null; + } + + async onCdpEvent(method: string, params: unknown): Promise { + const cdp = this.cdp; + if (cdp === null || !this.host.isCurrent(this)) return; + if (method === "Fetch.requestPaused") { + await cdp.handleFetchRequestPaused(params); + return; + } + if (method === "Target.attachedToTarget") { + await cdp.closePausedRelatedTarget(params); + return; + } + const decision = classifyCdpEvent(method, params, { + currentUrl: cdp.currentUrl, + mainFrameId: cdp.mainFrameId, + }); + if (decision.newMainFrameId !== undefined) cdp.mainFrameId = decision.newMainFrameId; + if (decision.newUrl !== undefined) cdp.currentUrl = decision.newUrl; + if (decision.loadStarted === true) cdp.markLoadStarted(); + if (decision.bumpGeneration === true) await cdp.bumpGeneration(); + if (!this.host.isCurrent(this)) return; + if (decision.pageEvent?.kind === "load") cdp.notifyLoadEventFired(); + if (decision.pageEvent !== undefined) { + this.send({ + type: "page_event", + kind: decision.pageEvent.kind, + tabId: this.tabId, + url: decision.pageEvent.url, + } satisfies Event); + await this.host.onTabChanged(this); + } + if (decision.dialog !== undefined) { + const accept = decision.dialog.accept; + const payload = decision.dialog.event; + const record = { + dialogId: crypto.randomUUID(), + occurredAt: new Date().toISOString(), + tabId: this.tabId, + dialogType: payload?.dialogType ?? "alert", + message: payload?.message ?? "", + url: payload?.url ?? cdp.currentUrl, + ...(payload?.defaultPrompt === undefined + ? {} + : { defaultPrompt: payload.defaultPrompt }), + disposition: payload?.disposition ?? (accept ? "accept" : "dismiss"), + } as const; + await handleDialogWithOutbox( + this.dialogs, + record, + () => cdp.send("Page.handleJavaScriptDialog", { accept }), + (delivery) => { + if (delivery === "ok") this.send({ type: "dialog", ...record }); + else this.send({ type: "health", dialogDelivery: "overflow" }); + }, + ); + } + } + + async onDebuggerDetach(): Promise { + this.cdp = null; + if (this.closing) return; + this.accepting = false; + this.peer?.stop(); + this.peer = null; + await this.host.onFenced(this); + } + + private async onOpen(): Promise { + const tab = await this.tabInfo(); + if (!this.canAccept()) return; + this.send({ + type: "hello", + protocolVersion: PROTOCOL_VERSION, + capabilities: [...PROTOCOL_CAPABILITIES], + browser: navigator.userAgent, + extVersion: browser.runtime.getManifest().version, + browserEpoch: this.assignment.browserEpoch, + leaseId: this.assignment.leaseId, + leaseEpoch: this.assignment.leaseEpoch, + tabs: [tab], + } satisfies Event); + + for (const record of await this.journal.recover()) { + if (!this.canAccept()) return; + if (record.state === "prepared") { + this.send({ + type: "write_ready", + ...this.fence(record.attemptId, Date.now() + 1_000), + commandId: record.commandId, + requestFingerprint: record.requestFingerprint, + }); + } else if (record.state === "started") { + await this.journal.markUnknown(record.attemptId); + this.writesBlocked = true; + } else if (record.state === "completed_unacked" && record.event !== undefined) { + this.send({ + type: "command_result", + attemptId: record.attemptId, + commandId: record.commandId, + ...this.resultFence(), + event: record.event, + }); + } + } + for (const dialog of await this.dialogs.pending()) { + if (!this.canAccept()) return; + this.send({ type: "dialog", ...dialog }); + } + } + + private async onServerFrame(raw: unknown): Promise { + if (!this.canAccept()) return; + const parsed = safeParseSessionServerFrame(raw); + if (!parsed.success) return; + const frame = parsed.data; + switch (frame.type) { + case "command": + if (!this.matchesFence(frame) || deadline(frame.deadlineAt) <= Date.now()) return; + await this.executeRead(frame); + return; + case "write_prepare": + if ( + !this.matchesFence(frame) || + deadline(frame.deadlineAt) <= Date.now() || + this.writesBlocked + ) { + return; + } + await this.journal.prepare({ + attemptId: frame.attemptId, + commandId: frame.commandId, + requestFingerprint: frame.requestFingerprint, + leaseId: frame.leaseId, + leaseEpoch: frame.leaseEpoch, + browserEpoch: frame.browserEpoch, + }); + if (!this.canAccept()) return; + this.send({ + type: "write_ready", + ...this.fence(frame.attemptId, deadline(frame.deadlineAt)), + commandId: frame.commandId, + requestFingerprint: frame.requestFingerprint, + }); + return; + case "write_grant": + await this.executeWrite(frame); + return; + case "attempt_cancel": + await this.journal.cancelPrepared(frame.attemptId); + return; + case "result_ack": + await this.journal.acknowledge(frame.attemptId); + return; + case "dialog_ack": + await this.dialogs.acknowledge(frame.dialogId); + return; + case "writes_blocked": + this.writesBlocked = true; + return; + case "close_session": + await this.close(frame.closeTab); + return; + } + } + + private async executeRead( + frame: Extract, + ): Promise { + const event = await this.executeWithDeadline(frame.command, deadline(frame.deadlineAt)); + if (event !== null && this.canAccept()) { + this.send({ + type: "command_result", + attemptId: frame.attemptId, + commandId: frame.command.commandId, + ...this.resultFence(), + event, + }); + } + } + + private async executeWrite( + frame: Extract, + ): Promise { + if ( + this.writesBlocked || + !this.matchesFence(frame) || + deadline(frame.deadlineAt) <= Date.now() || + !isWriteCommand(frame.command) + ) { + return; + } + const record = await this.journal.get(frame.attemptId); + if (!this.canAccept()) return; + if ( + record === undefined || + record.state !== "prepared" || + record.commandId !== frame.command.commandId + ) { + return; + } + await this.journal.markStarted(frame.attemptId); + if (!this.canAccept()) return; + const event = await this.executeWithDeadline(frame.command, deadline(frame.deadlineAt)); + if (event === null || !this.canAccept()) { + await this.journal.markUnknown(frame.attemptId); + this.writesBlocked = true; + return; + } + await this.journal.markCompleted(frame.attemptId, event); + if (!this.canAccept()) return; + this.send({ + type: "command_result", + attemptId: frame.attemptId, + commandId: frame.command.commandId, + ...this.resultFence(), + event, + }); + } + + private async executeWithDeadline( + command: Command, + deadlineAt: number, + ): Promise { + const cdp = this.cdp; + if (cdp === null) { + return { + type: "action_result", + commandId: command.commandId, + ok: false, + error: "no active CDP session", + }; + } + const remaining = deadlineAt - Date.now(); + if (remaining <= 0) return null; + let timer: ReturnType; + const timedOut = new Promise((resolve) => { + timer = setTimeout(() => resolve(null), remaining); + }); + const execution = routeCommand(command, cdp); + const event = await Promise.race([execution, timedOut]); + clearTimeout(timer!); + if (event !== null) return event; + await cdp.detach().catch(() => {}); + this.cdp = null; + this.accepting = false; + this.peer?.stop(); + this.peer = null; + await this.host.onFenced(this); + return null; + } + + private fence(attemptId: string, deadlineAt: number) { + return { + attemptId, + deadlineAt: new Date(deadlineAt).toISOString(), + leaseId: this.assignment.leaseId, + leaseEpoch: this.assignment.leaseEpoch, + browserEpoch: this.assignment.browserEpoch, + }; + } + + private resultFence() { + return { + leaseId: this.assignment.leaseId, + leaseEpoch: this.assignment.leaseEpoch, + browserEpoch: this.assignment.browserEpoch, + }; + } + + private async tabInfo(): Promise { + const tab = await browser.tabs.get(this.tabId); + return { + tabId: this.tabId, + url: tab.url ?? "about:blank", + title: tab.title ?? "", + active: tab.active, + }; + } + + private canAccept(): boolean { + return ( + this.accepting && + !this.closing && + this.assignment.cleanupIntent === undefined && + this.host.isCurrent(this) + ); + } + + private send(frame: unknown): void { + this.peer?.send(frame); + } +} + +function mergeCleanupIntent( + current: CleanupIntent | undefined, + requested: CleanupIntent, +): CleanupIntent { + if (current === "discard" || requested === "discard") return "discard"; + if (current === "release" || requested === "release") return "release"; + return "recover"; +} + +function deadline(value: string): number { + return Date.parse(value); +} diff --git a/apps/extension/src/core/write-journal.test.ts b/apps/extension/src/core/write-journal.test.ts new file mode 100644 index 0000000..3246557 --- /dev/null +++ b/apps/extension/src/core/write-journal.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it, vi } from "vitest"; +import type { SessionStorageArea } from "./dedupe"; +import { WriteJournal } from "./write-journal"; + +class MemoryStorage implements SessionStorageArea { + readonly values: Record = {}; + readonly set = vi.fn(async (items: Record) => { + Object.assign(this.values, items); + }); + + async get(key: string): Promise> { + return { [key]: this.values[key] }; + } + + async remove(key: string): Promise { + delete this.values[key]; + } +} + +const PREPARED = { + attemptId: "attempt-1", + commandId: "command-1", + requestFingerprint: "a".repeat(64), +}; + +describe("WriteJournal", () => { + it("awaits every pre-effect transition and fails closed on storage failure", async () => { + const storage = new MemoryStorage(); + storage.set.mockRejectedValueOnce(new Error("quota exceeded")); + const journal = new WriteJournal(storage, "journal"); + + await expect(journal.prepare(PREPARED)).rejects.toThrow("quota exceeded"); + expect(storage.values.journal).toBeUndefined(); + await expect(journal.markStarted(PREPARED.attemptId)).rejects.toThrow( + "write journal transition prepared -> started rejected", + ); + }); + + it("persists prepared, started, and completed-unacknowledged before ACK removal", async () => { + const storage = new MemoryStorage(); + const journal = new WriteJournal(storage, "journal"); + + await journal.prepare(PREPARED); + await journal.markStarted(PREPARED.attemptId); + await journal.markCompleted(PREPARED.attemptId, { + type: "action_result", + commandId: PREPARED.commandId, + ok: true, + url: "https://prior-url.example/", + }); + + expect(await journal.recover()).toEqual([ + { + ...PREPARED, + state: "completed_unacked", + event: { + type: "action_result", + commandId: PREPARED.commandId, + ok: true, + }, + }, + ]); + expect(JSON.stringify(storage.values)).not.toContain("prior-url.example"); + + await journal.acknowledge(PREPARED.attemptId); + expect(await journal.recover()).toEqual([]); + }); + + it("never discards unknown tombstones and rejects attempt fingerprint conflicts", async () => { + const storage = new MemoryStorage(); + const journal = new WriteJournal(storage, "journal"); + await journal.prepare(PREPARED); + + await expect( + journal.prepare({ + ...PREPARED, + requestFingerprint: "b".repeat(64), + }), + ).rejects.toThrow("attempt journal conflict"); + + await journal.markStarted(PREPARED.attemptId); + await journal.markUnknown(PREPARED.attemptId); + expect(await journal.recover()).toEqual([{ ...PREPARED, state: "unknown" }]); + await journal.acknowledge(PREPARED.attemptId); + expect(await journal.recover()).toEqual([{ ...PREPARED, state: "unknown" }]); + }); + + it("does not persist action errors that may contain refs or URLs", async () => { + const storage = new MemoryStorage(); + const journal = new WriteJournal(storage, "journal"); + await journal.prepare(PREPARED); + await journal.markStarted(PREPARED.attemptId); + await journal.markCompleted(PREPARED.attemptId, { + type: "action_result", + commandId: PREPARED.commandId, + ok: false, + error: "stale ref secret-ref at https://prior-url.example/", + }); + + expect(await journal.recover()).toMatchObject([ + { + event: { + error: "browser action failed", + }, + }, + ]); + expect(JSON.stringify(storage.values)).not.toContain("secret-ref"); + expect(JSON.stringify(storage.values)).not.toContain("prior-url.example"); + }); +}); diff --git a/apps/extension/src/core/write-journal.ts b/apps/extension/src/core/write-journal.ts new file mode 100644 index 0000000..f1d14b7 --- /dev/null +++ b/apps/extension/src/core/write-journal.ts @@ -0,0 +1,158 @@ +import type { Event } from "@understudy/protocol"; +import type { SessionStorageArea } from "./dedupe"; + +export type WriteJournalState = + | "prepared" + | "started" + | "completed_unacked" + | "unknown"; + +export interface WriteJournalRecord { + attemptId: string; + commandId: string; + requestFingerprint: string; + state: WriteJournalState; + leaseId?: string; + leaseEpoch?: number; + browserEpoch?: string; + event?: Event; +} + +export class WriteJournal { + private records: WriteJournalRecord[] | null = null; + + constructor( + private readonly storage: SessionStorageArea, + private readonly storageKey: string, + ) {} + + async prepare(record: Omit): Promise { + await this.hydrate(); + const existing = this.find(record.attemptId); + if (existing !== undefined) { + if ( + existing.commandId !== record.commandId || + existing.requestFingerprint !== record.requestFingerprint + ) { + throw new Error("attempt journal conflict"); + } + return; + } + await this.persist([...(this.records ?? []), { ...record, state: "prepared" }]); + } + + async markStarted(attemptId: string): Promise { + await this.transition(attemptId, "prepared", "started"); + } + + async markCompleted(attemptId: string, event: Event): Promise { + await this.hydrate(); + const records = this.records ?? []; + const current = records.find((record) => record.attemptId === attemptId); + if (current === undefined || current.state !== "started") { + throw new Error("write was not durably started"); + } + await this.persist( + records.map((record) => + record.attemptId === attemptId + ? { + ...record, + state: "completed_unacked" as const, + event: journalSafeEvent(event), + } + : record, + ), + ); + } + + async markUnknown(attemptId: string): Promise { + await this.hydrate(); + const records = this.records ?? []; + const current = records.find((record) => record.attemptId === attemptId); + if (current === undefined || current.state === "completed_unacked") return; + await this.persist( + records.map((record) => + record.attemptId === attemptId + ? { ...record, state: "unknown" as const, event: undefined } + : record, + ), + ); + } + + async cancelPrepared(attemptId: string): Promise { + await this.hydrate(); + const records = this.records ?? []; + const current = records.find((record) => record.attemptId === attemptId); + if (current?.state !== "prepared") return; + await this.persist(records.filter((record) => record.attemptId !== attemptId)); + } + + async acknowledge(attemptId: string): Promise { + await this.hydrate(); + const records = this.records ?? []; + const current = records.find((record) => record.attemptId === attemptId); + if (current?.state !== "completed_unacked") return; + await this.persist(records.filter((record) => record.attemptId !== attemptId)); + } + + async get(attemptId: string): Promise { + await this.hydrate(); + return this.find(attemptId); + } + + async recover(): Promise { + await this.hydrate(); + return [...(this.records ?? [])]; + } + + async clear(): Promise { + await this.persist([]); + } + + private async transition( + attemptId: string, + expected: WriteJournalState, + next: WriteJournalState, + ): Promise { + await this.hydrate(); + const records = this.records ?? []; + const current = records.find((record) => record.attemptId === attemptId); + if (current === undefined || current.state !== expected) { + throw new Error(`write journal transition ${expected} -> ${next} rejected`); + } + await this.persist( + records.map((record) => + record.attemptId === attemptId ? { ...record, state: next } : record, + ), + ); + } + + private find(attemptId: string): WriteJournalRecord | undefined { + return (this.records ?? []).find((record) => record.attemptId === attemptId); + } + + private async hydrate(): Promise { + if (this.records !== null) return; + const stored = await this.storage.get(this.storageKey); + const value = stored[this.storageKey]; + this.records = Array.isArray(value) ? (value as WriteJournalRecord[]) : []; + } + + private async persist(records: WriteJournalRecord[]): Promise { + await this.storage.set({ [this.storageKey]: records }); + this.records = records; + } +} + +function journalSafeEvent(event: Event): Event { + if (event.type !== "action_result") { + throw new Error("write journal accepts action results only"); + } + return { + type: "action_result", + commandId: event.commandId, + ok: event.ok, + ...(event.error === undefined ? {} : { error: "browser action failed" }), + ...(event.simulated === undefined ? {} : { simulated: event.simulated }), + }; +} diff --git a/apps/extension/src/core/ws-client.test.ts b/apps/extension/src/core/ws-client.test.ts index 5703300..dc78731 100644 --- a/apps/extension/src/core/ws-client.test.ts +++ b/apps/extension/src/core/ws-client.test.ts @@ -1,4 +1,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + WS_CLOSE_REPLACED, + WS_CLOSE_SESSION_TERMINAL, +} from "@understudy/protocol"; import { ReconnectingWs } from "./ws-client"; type Listener = (event: Event & { code?: number; data?: unknown }) => void; @@ -44,20 +48,28 @@ describe("ReconnectingWs", () => { vi.unstubAllGlobals(); }); - it("does not reconnect after the backend replaces this extension with close code 4001", () => { - const onClose = vi.fn(); - new ReconnectingWs(() => "ws://example.test/session", { - onCommand: vi.fn(), - onOpen: vi.fn(), - onClose, - }); - - FakeWebSocket.instances[0]?.emit("close", { code: 4001 }); - vi.advanceTimersByTime(60_000); - - expect(onClose).toHaveBeenCalledOnce(); - expect(FakeWebSocket.instances).toHaveLength(1); - }); + it.each([WS_CLOSE_REPLACED, WS_CLOSE_SESSION_TERMINAL])( + "classifies terminal close code %i before the owner callback and does not reconnect", + (code) => { + const onClose = vi.fn(); + let peer!: ReconnectingWs; + peer = new ReconnectingWs(() => "ws://example.test/session", { + onCommand: vi.fn(), + onOpen: vi.fn(), + onClose: (event) => { + onClose(event); + peer.send({ type: "must-not-send" }); + }, + }); + + FakeWebSocket.instances[0]?.emit("close", { code }); + vi.advanceTimersByTime(60_000); + + expect(onClose).toHaveBeenCalledOnce(); + expect(onClose.mock.calls[0]?.[0]).toMatchObject({ code }); + expect(FakeWebSocket.instances).toHaveLength(1); + }, + ); it("reconnects an ordinary close with backoff", () => { new ReconnectingWs(() => "ws://example.test/session", { diff --git a/apps/extension/src/core/ws-client.ts b/apps/extension/src/core/ws-client.ts index 742b80d..a89119f 100644 --- a/apps/extension/src/core/ws-client.ts +++ b/apps/extension/src/core/ws-client.ts @@ -1,15 +1,18 @@ -import type { Event } from "@understudy/protocol"; +import { + WS_CLOSE_REPLACED, + WS_CLOSE_SESSION_TERMINAL, +} from "@understudy/protocol"; interface WsHandlers { onCommand: (cmd: unknown) => void; onOpen: () => void; - onClose?: () => void; + onClose?: (event: CloseEvent) => void; onConnecting?: () => void; + heartbeatFrame?: () => unknown | null; } const BACKOFF_BASE_MS = 500; const BACKOFF_CAP_MS = 30_000; -const REPLACED_BY_NEW_EXTENSION_CODE = 4001; // The browser WS API exposes no protocol ping frame to JS, so an app-level pong // is the only lever; sending one under the MV3 SW's ~30s idle timeout keeps the // worker alive as long as the socket stays open (chrome.alarms is the backstop @@ -26,21 +29,28 @@ export class ReconnectingWs { constructor( private readonly getUrl: () => string, private readonly handlers: WsHandlers, + private readonly maxInboundBytes = 16 * 1024 * 1024, ) { this.connect(); } - send(ev: Event): void { + send(frame: unknown): boolean { const socket = this.socket; if (socket !== null && socket.readyState === WebSocket.OPEN) { - socket.send(JSON.stringify(ev)); + socket.send(JSON.stringify(frame)); + return true; } + return false; } startHeartbeat(): void { this.clearHeartbeat(); this.heartbeatTimer = setInterval(() => { - this.send({ type: "pong" }); + const frame = + this.handlers.heartbeatFrame === undefined + ? { type: "pong" } + : this.handlers.heartbeatFrame(); + if (frame !== null) this.send(frame); }, HEARTBEAT_MS); } @@ -78,9 +88,14 @@ export class ReconnectingWs { }); socket.addEventListener("message", (ev) => { + const text = typeof ev.data === "string" ? ev.data : String(ev.data); + if (new TextEncoder().encode(text).byteLength > this.maxInboundBytes) { + socket.close(1009, "frame too large"); + return; + } let parsed: unknown; try { - parsed = JSON.parse(typeof ev.data === "string" ? ev.data : String(ev.data)); + parsed = JSON.parse(text) as unknown; } catch { return; } @@ -91,15 +106,15 @@ export class ReconnectingWs { this.clearHeartbeat(); if (this.socket === socket) this.socket = null; if (this.stopped) return; - this.handlers.onClose?.(); - if (event.code === REPLACED_BY_NEW_EXTENSION_CODE) { - // The backend has selected another extension connection for this - // session. Reconnecting would make the two extensions evict each other. + const terminal = + event.code === WS_CLOSE_REPLACED || + event.code === WS_CLOSE_SESSION_TERMINAL; + if (terminal) { this.stopped = true; this.clearReconnect(); - return; } - this.scheduleReconnect(); + this.handlers.onClose?.(event); + if (!terminal) this.scheduleReconnect(); }); socket.addEventListener("error", () => { diff --git a/apps/extension/src/driver/a11y.test.ts b/apps/extension/src/driver/a11y.test.ts index a9adeae..30d1150 100644 --- a/apps/extension/src/driver/a11y.test.ts +++ b/apps/extension/src/driver/a11y.test.ts @@ -207,4 +207,58 @@ describe("buildA11ySnapshot", () => { expect(tree).toEqual([]); expect(refMap.size).toBe(0); }); + + it("rejects more than 5,000 reportable nodes instead of truncating", () => { + const children = Array.from({ length: 5_001 }, (_, index) => `node-${index}`); + const nodes: Protocol.Accessibility.AXNode[] = [ + { + nodeId: "root", + ignored: false, + role: { type: "role", value: "RootWebArea" }, + childIds: children, + }, + ...children.map((nodeId, index) => ({ + nodeId, + ignored: false, + role: { type: "role" as const, value: "button" }, + backendDOMNodeId: index + 1, + })), + ]; + + expect(() => + buildA11ySnapshot(nodes, { scopeId: "limit", generation: 1 }), + ).toThrow("a11y snapshot exceeds 5000 nodes"); + }); + + it("rejects reportable depth above 64 and 4 KiB names", () => { + const nested: Protocol.Accessibility.AXNode[] = [ + { + nodeId: "root", + ignored: false, + role: { type: "role", value: "RootWebArea" }, + childIds: ["button-0"], + }, + ]; + for (let index = 0; index < 65; index += 1) { + nested.push({ + nodeId: `button-${index}`, + ignored: false, + role: { type: "role", value: "button" }, + backendDOMNodeId: index + 1, + ...(index === 64 ? {} : { childIds: [`button-${index + 1}`] }), + }); + } + expect(() => + buildA11ySnapshot(nested, { scopeId: "depth", generation: 1 }), + ).toThrow("a11y snapshot exceeds depth 64"); + + const oversizedName = structuredClone(FIXTURE); + oversizedName[1]!.name = { + type: "computedString", + value: "x".repeat(4 * 1024 + 1), + }; + expect(() => + buildA11ySnapshot(oversizedName, { scopeId: "name", generation: 1 }), + ).toThrow("a11y name exceeds 4096 bytes"); + }); }); diff --git a/apps/extension/src/driver/a11y.ts b/apps/extension/src/driver/a11y.ts index fe20a18..82ffd02 100644 --- a/apps/extension/src/driver/a11y.ts +++ b/apps/extension/src/driver/a11y.ts @@ -1,4 +1,9 @@ -import type { A11yNode } from "@understudy/protocol"; +import { + MAX_A11Y_DEPTH, + MAX_A11Y_NODES, + utf8ByteLength, + type A11yNode, +} from "@understudy/protocol"; import type { Protocol } from "devtools-protocol"; // AX roles surfaced to the backend. Tunable (seeded from the M0 spike): widen as @@ -46,13 +51,14 @@ export function buildA11ySnapshot( const seen = new Set(); const refPrefix = a11yRefPrefix(scope); let seq = 0; + let keptCount = 0; // DFS pre-order. Returns the kept forest rooted at `nodeId`: a kept node comes // back as a single-element list carrying its kept descendants; a dropped node // returns its descendants' forest, so they re-parent onto the nearest kept // ancestor (drop-but-descend). The ref is assigned before recursing, so a kept // parent always precedes its kept children. - function walk(nodeId: string): A11yNode[] { + function walk(nodeId: string, keptDepth: number): A11yNode[] { if (seen.has(nodeId)) return []; // guard against a malformed cyclic tree seen.add(nodeId); @@ -70,18 +76,37 @@ export function buildA11ySnapshot( !node.ignored && backendId !== undefined ) { + keptCount += 1; + if (keptCount > MAX_A11Y_NODES) { + throw new Error(`a11y snapshot exceeds ${MAX_A11Y_NODES} nodes`); + } + if (keptDepth > MAX_A11Y_DEPTH) { + throw new Error(`a11y snapshot exceeds depth ${MAX_A11Y_DEPTH}`); + } const ref = `${refPrefix}${seq++}`; refMap.set(ref, backendId); self = { ref, role }; const name = axString(node.name); - if (name !== undefined) self.name = name; + if (name !== undefined) { + if (utf8ByteLength(name) > 4 * 1024) { + throw new Error("a11y name exceeds 4096 bytes"); + } + self.name = name; + } const value = axString(node.value); - if (value !== undefined) self.value = value; + if (value !== undefined) { + if (utf8ByteLength(value) > 4 * 1024) { + throw new Error("a11y value exceeds 4096 bytes"); + } + self.value = value; + } } const childForest: A11yNode[] = []; for (const childId of node.childIds ?? []) { - for (const kept of walk(childId)) childForest.push(kept); + for (const kept of walk(childId, keptDepth + (self === undefined ? 0 : 1))) { + childForest.push(kept); + } } if (self !== undefined) { @@ -92,6 +117,6 @@ export function buildA11ySnapshot( } const root = axNodes.find((n) => n.role?.value === "RootWebArea"); - const tree = root === undefined ? [] : walk(root.nodeId); + const tree = root === undefined ? [] : walk(root.nodeId, 1); return { tree, refMap }; } diff --git a/apps/extension/src/driver/cdp-events.test.ts b/apps/extension/src/driver/cdp-events.test.ts index 2f9be84..e53178d 100644 --- a/apps/extension/src/driver/cdp-events.test.ts +++ b/apps/extension/src/driver/cdp-events.test.ts @@ -1,6 +1,5 @@ -import { describe, it, expect, vi } from "vitest"; -import { applyDialogDecision, classifyCdpEvent, dialogDisposition } from "./cdp-events"; -import type { DialogEventFields } from "./cdp-events"; +import { describe, it, expect } from "vitest"; +import { classifyCdpEvent, dialogDisposition } from "./cdp-events"; const ctx = { currentUrl: "https://example.com/current", mainFrameId: "F1" }; @@ -176,36 +175,3 @@ describe("dialogDisposition", () => { expect(dialogDisposition("prompt")).toBe("dismiss"); }); }); - -describe("applyDialogDecision", () => { - it("answers the dialog BEFORE reporting it (the channel is freed first)", async () => { - const calls: string[] = []; - const answer = vi.fn(async (accept: boolean) => { - calls.push(`answer:${accept}`); - }); - const report = vi.fn((event: DialogEventFields) => { - calls.push(`report:${event.dialogType}`); - }); - - await applyDialogDecision( - { - accept: false, - event: { dialogType: "confirm", message: "?", url: "https://x/", disposition: "dismiss" }, - }, - answer, - report, - ); - - expect(calls).toEqual(["answer:false", "report:confirm"]); - }); - - it("answers an unclassifiable dialog (no event) and reports nothing - channel still freed", async () => { - const answer = vi.fn(async () => {}); - const report = vi.fn(); - - await applyDialogDecision({ accept: false }, answer, report); - - expect(answer).toHaveBeenCalledWith(false); - expect(report).not.toHaveBeenCalled(); - }); -}); diff --git a/apps/extension/src/driver/cdp-events.ts b/apps/extension/src/driver/cdp-events.ts index 170a369..5cbad85 100644 --- a/apps/extension/src/driver/cdp-events.ts +++ b/apps/extension/src/driver/cdp-events.ts @@ -5,7 +5,10 @@ import type { DialogDisposition, DialogRecord, DialogType } from "@understudy/pr // The reportable fields of a `dialog` protocol Event, minus the wire-level // `type`/`tabId` the background worker adds (it owns the tabId). Derived from // the protocol's DialogRecord so it can never drift from the wire shape. -export type DialogEventFields = Omit; +export type DialogEventFields = Omit< + DialogRecord, + "tabId" | "dialogId" | "occurredAt" +>; // The effects the background service worker must apply for a raw CDP event. Every // field is optional so an empty decision ({}) is a valid no-op and the consumer @@ -130,18 +133,3 @@ export function classifyCdpEvent( return {}; } } - -// Answer a dialog, then report it. Extracted from the background worker's -// onCdpEvent so the ordering guarantee is directly testable: `answer` is awaited -// FIRST and unconditionally, so an open dialog can never wedge the single CDP -// channel even when the report path is a no-op (WS down) or there is no event to -// emit (an unclassifiable dialog type). `report` runs only for a classifiable -// dialog and never blocks the answer. -export async function applyDialogDecision( - dialog: NonNullable, - answer: (accept: boolean) => Promise, - report: (event: DialogEventFields) => void, -): Promise { - await answer(dialog.accept); - if (dialog.event !== undefined) report(dialog.event); -} diff --git a/apps/extension/src/driver/cdp.test.ts b/apps/extension/src/driver/cdp.test.ts index 84eb986..3517c1f 100644 --- a/apps/extension/src/driver/cdp.test.ts +++ b/apps/extension/src/driver/cdp.test.ts @@ -763,3 +763,105 @@ describe("CdpSession ref target binding", () => { expect(session.resolveRef(oldRef)).toBeNull(); }); }); + +describe("CdpSession unattended containment", () => { + function containmentSession(): Promise<{ + session: CdpSession; + sendCommand: ReturnType; + }> { + const sendCommand = vi.fn().mockResolvedValue({}); + vi.stubGlobal("browser", { + storage: { + session: { + get: vi.fn().mockResolvedValue({}), + set: vi.fn().mockResolvedValue(undefined), + }, + }, + debugger: { + sendCommand, + }, + }); + return CdpSession.create(7, TEST_SCOPE).then((session) => ({ + session, + sendCommand, + })); + } + + it("prechecks explicit navigation and permits only about:blank or an allowed origin", async () => { + const { session, sendCommand } = await containmentSession(); + await session.enableUnattendedContainment(["https://allowed.example"]); + + expect(session.isAllowedTopLevelUrl("about:blank")).toBe(true); + expect(session.isAllowedTopLevelUrl("https://allowed.example/path")).toBe(true); + expect(session.isAllowedTopLevelUrl("https://blocked.example/")).toBe(false); + await expect( + session.navigate("blocked-nav", "https://blocked.example/"), + ).resolves.toEqual({ + type: "action_result", + commandId: "blocked-nav", + ok: false, + error: "navigation origin is not allowed for this session", + }); + expect( + sendCommand.mock.calls.some((call) => call[1] === "Page.navigate"), + ).toBe(false); + }); + + it("blocks top-level redirects/JavaScript navigation but permits iframes and subresources", async () => { + const { session, sendCommand } = await containmentSession(); + session.mainFrameId = "main-frame"; + await session.enableUnattendedContainment(["https://allowed.example"]); + + await session.handleFetchRequestPaused({ + requestId: "top", + request: { url: "https://blocked.example/redirect" }, + frameId: "main-frame", + resourceType: "Document", + }); + await session.handleFetchRequestPaused({ + requestId: "iframe", + request: { url: "https://blocked.example/frame" }, + frameId: "child-frame", + resourceType: "Document", + }); + await session.handleFetchRequestPaused({ + requestId: "script", + request: { url: "https://blocked.example/app.js" }, + frameId: "main-frame", + resourceType: "Script", + }); + + expect(sendCommand.mock.calls).toContainEqual([ + { tabId: 7 }, + "Fetch.failRequest", + { requestId: "top", errorReason: "BlockedByClient" }, + ]); + expect(sendCommand.mock.calls).toContainEqual([ + { tabId: 7 }, + "Fetch.continueRequest", + { requestId: "iframe" }, + ]); + expect(sendCommand.mock.calls).toContainEqual([ + { tabId: 7 }, + "Fetch.continueRequest", + { requestId: "script" }, + ]); + }); + + it("closes a paused related page target before resuming it", async () => { + const { session, sendCommand } = await containmentSession(); + + await session.closePausedRelatedTarget({ + targetInfo: { type: "page", targetId: "popup-target" }, + }); + + expect(sendCommand).toHaveBeenCalledWith( + { tabId: 7 }, + "Target.closeTarget", + { targetId: "popup-target" }, + ); + expect( + sendCommand.mock.calls.some((call) => call[1] === "Runtime.runIfWaitingForDebugger"), + ).toBe(false); + }); +}); diff --git a/apps/extension/src/driver/cdp.ts b/apps/extension/src/driver/cdp.ts index d93fc02..f8b6778 100644 --- a/apps/extension/src/driver/cdp.ts +++ b/apps/extension/src/driver/cdp.ts @@ -52,6 +52,7 @@ export class CdpSession { // Chained (not fire-and-forget) so concurrent bumpGeneration() calls persist // in order instead of racing to overwrite browser.storage.session. private genPersistChain: Promise = Promise.resolve(); + private allowedOrigins: Set | null = null; private constructor( readonly tabId: number, @@ -135,6 +136,71 @@ export class CdpSession { this.enabled = true; } + async enableUnattendedContainment(allowedOrigins: readonly string[]): Promise { + this.allowedOrigins = new Set(allowedOrigins); + await this.send("Fetch.enable", { + patterns: [ + { + urlPattern: "*", + resourceType: "Document", + requestStage: "Request", + }, + ], + }); + await this.send("Target.setAutoAttach", { + autoAttach: true, + waitForDebuggerOnStart: true, + flatten: true, + filter: [{ type: "page", exclude: false }], + }); + } + + async handleFetchRequestPaused(params: unknown): Promise { + const event = params as { + requestId?: unknown; + request?: { url?: unknown }; + frameId?: unknown; + resourceType?: unknown; + }; + if (typeof event.requestId !== "string") return; + const isMainDocument = + event.resourceType === "Document" && + typeof event.frameId === "string" && + event.frameId === this.mainFrameId; + const url = event.request?.url; + if ( + isMainDocument && + (typeof url !== "string" || !this.isAllowedTopLevelUrl(url)) + ) { + await this.send("Fetch.failRequest", { + requestId: event.requestId, + errorReason: "BlockedByClient", + }); + await this.bumpGeneration(); + return; + } + await this.send("Fetch.continueRequest", { requestId: event.requestId }); + } + + async closePausedRelatedTarget(params: unknown): Promise { + const event = params as { + targetInfo?: { targetId?: unknown; type?: unknown }; + }; + const targetId = event.targetInfo?.targetId; + if (event.targetInfo?.type !== "page" || typeof targetId !== "string") return; + await this.send("Target.closeTarget", { targetId }); + } + + isAllowedTopLevelUrl(value: string): boolean { + if (value === "about:blank") return true; + if (this.allowedOrigins === null) return true; + try { + return this.allowedOrigins.has(new URL(value).origin); + } catch { + return false; + } + } + async reconcile(): Promise { this.enabled = false; await this.enableDomains(); @@ -578,6 +644,9 @@ export class CdpSession { navigate(commandId: string, url: string): Promise { return this.run(commandId, async () => { + if (!this.isAllowedTopLevelUrl(url)) { + return actionError(commandId, "navigation origin is not allowed for this session"); + } await this.bumpGeneration(); this.markLoadStarted(); const res = await this.send("Page.navigate", { url }); diff --git a/apps/extension/src/entrypoints/background.ts b/apps/extension/src/entrypoints/background.ts index c6fadda..6fbea87 100644 --- a/apps/extension/src/entrypoints/background.ts +++ b/apps/extension/src/entrypoints/background.ts @@ -1,14 +1,27 @@ -import { safeParseCommand } from "@understudy/protocol"; +import { + PROTOCOL_CAPABILITIES, + PROTOCOL_VERSION, + WS_CLOSE_SESSION_TERMINAL, + isWriteCommand, + safeParseCommand, + safeParseSessionServerFrame, +} from "@understudy/protocol"; +import type { Command, Event, SessionServerFrame } from "@understudy/protocol"; import type { Browser } from "wxt/browser"; import { CommandIngress, type StartedCommand } from "../core/command-ingress"; import { WriteDedupe } from "../core/dedupe"; +import { + DialogOutbox, + handleDialogWithOutbox, +} from "../core/dialog-outbox"; import { sendIfPeerCurrent } from "../core/peer-binding"; import { routeCommand } from "../core/router"; import { ReconnectingWs } from "../core/ws-client"; +import { ProfileClient } from "../core/profile-client"; +import { WriteJournal } from "../core/write-journal"; import { CdpSession } from "../driver/cdp"; -import { applyDialogDecision, classifyCdpEvent } from "../driver/cdp-events"; +import { classifyCdpEvent } from "../driver/cdp-events"; import { errorMessage } from "../events"; -import { queryTabInfos } from "../tabs"; import type { AttachedTab, LogEntry, @@ -20,7 +33,8 @@ import type { } from "../messaging"; const DEFAULT_WS_URL = "ws://localhost:8787"; -// WXT storage item id 'local:wsUrl' maps to browser.storage.local key 'wsUrl'. +// Attended URLs can contain a legacy extension token. Keep them in +// storage.session so they survive SW eviction but clear on browser restart. const WS_URL_KEY = "wsUrl"; // Persisted across SW eviction so a wake can re-discover the driven tab. const ATTACHED_TAB_KEY = "understudy:attachedTabId"; @@ -56,7 +70,18 @@ let attachedTitle: string | undefined; // Write-replay record (idempotent-retry contract); hydrates lazily from // storage.session, so rebuilding it each wake loses nothing. const dedupe = new WriteDedupe(browser.storage.session); +const attendedJournal = new WriteJournal( + browser.storage.session, + "understudy:attendedJournal", +); +const attendedDialogs = new DialogOutbox( + browser.storage.session, + "understudy:attendedDialogs", +); +let attendedWritesBlocked = false; +let attendedTerminal = false; const commandIngress = new CommandIngress(); +const profileClient = new ProfileClient(() => broadcastState()); const logBuffer: LogEntry[] = []; const ports = new Set(); @@ -69,6 +94,7 @@ export default defineBackground({ browser.alarms.onAlarm.addListener(onAlarm); browser.debugger.onEvent.addListener(onCdpEvent); browser.debugger.onDetach.addListener(onDetach); + browser.tabs.onCreated.addListener(onTabCreated); browser.runtime.onConnect.addListener(onConnect); browser.alarms.create(BACKSTOP_ALARM, { periodInMinutes: 0.5 }).catch((cause: unknown) => { log(`alarm create failed: ${errorMessage(cause)}`, "warn"); @@ -77,6 +103,7 @@ export default defineBackground({ // Kick off the async wake tasks without awaiting (main() must stay non-async). fireAndForget("ensureConnection", ensureConnection); fireAndForget("reconcileAttachment", reconcileAttachment); + fireAndForget("profileClient", () => profileClient.start()); }, }); @@ -90,9 +117,17 @@ function getUrl(): string { async function readWsUrl(): Promise { try { - const stored = await browser.storage.local.get(WS_URL_KEY); + const stored = await browser.storage.session.get(WS_URL_KEY); const value = stored[WS_URL_KEY]; - return typeof value === "string" && value.length > 0 ? value : DEFAULT_WS_URL; + if (typeof value === "string" && value.length > 0) return value; + const legacy = await browser.storage.local.get(WS_URL_KEY); + const legacyValue = legacy[WS_URL_KEY]; + await browser.storage.local.remove(WS_URL_KEY); + if (typeof legacyValue === "string" && legacyValue.length > 0) { + await browser.storage.session.set({ [WS_URL_KEY]: legacyValue }); + return legacyValue; + } + return DEFAULT_WS_URL; } catch (cause) { log(`read wsUrl failed, using default: ${errorMessage(cause)}`, "warn"); return DEFAULT_WS_URL; @@ -126,7 +161,7 @@ function connectWs(): void { peer = new ReconnectingWs(getUrl, { onCommand: (raw) => onCommand(raw, peer), onOpen: () => onOpen(peer), - onClose: () => onClose(peer), + onClose: (event) => onClose(peer, event), onConnecting, }); ws = peer; @@ -135,7 +170,10 @@ function connectWs(): void { // ReconnectingWs starts its own pong heartbeat on open, so we only (re)send hello. function onOpen(peer: ReconnectingWs): void { - if (peer !== ws || wsSwitching) return; + if (peer !== ws || wsSwitching || attendedTerminal) { + peer.stop(); + return; + } wsStatus = "open"; log("ws connected"); fireAndForget("hello", () => sendHello(peer)); @@ -147,37 +185,271 @@ function onConnecting(): void { broadcastState(); } -function onClose(peer: ReconnectingWs): void { +function onClose(peer: ReconnectingWs, event: CloseEvent): void { if (peer !== ws) return; wsStatus = "closed"; + if (event.code === WS_CLOSE_SESSION_TERMINAL) { + attendedTerminal = true; + if (acceptingPeer === peer) acceptingPeer = null; + fireAndForget("terminal session detach", detach); + } broadcastState(); } // A fresh hello on every (re)connect is the resync signal: any commands in flight // when the SW was evicted are abandoned, and the peer tolerates repeated hellos. async function sendHello(peer: ReconnectingWs): Promise { - const tabs = await queryTabInfos(); + const active = session; + if (active === null) { + sendIfPeerCurrent(peer, acceptingPeer, (current) => { + current.send({ + type: "hello", + browser: navigator.userAgent, + extVersion: browser.runtime.getManifest().version, + tabs: [], + }); + }); + return; + } + const tab = await browser.tabs.get(active.tabId); sendIfPeerCurrent(peer, acceptingPeer, (current) => { current.send({ type: "hello", + protocolVersion: PROTOCOL_VERSION, + capabilities: [...PROTOCOL_CAPABILITIES], browser: navigator.userAgent, extVersion: browser.runtime.getManifest().version, - tabs, + tabs: [ + { + tabId: active.tabId, + url: tab.url ?? active.currentUrl, + title: tab.title ?? "", + active: tab.active, + }, + ], }); }); + await replayAttendedState(peer); } function onCommand(raw: unknown, peer: ReconnectingWs): void { - if (peer !== acceptingPeer) return; + if (attendedTerminal || peer !== acceptingPeer) return; + const v2 = safeParseSessionServerFrame(raw); + if (v2.success) { + fireAndForget("v2 command ingress", () => + commandIngress.enqueue(() => startV2Frame(v2.data, peer)), + ); + return; + } fireAndForget("command ingress", () => commandIngress.enqueue(() => startCommand(raw, peer)), ); } +async function startV2Frame( + frame: SessionServerFrame, + peer: ReconnectingWs, +): Promise { + if (attendedTerminal || peer !== acceptingPeer) return undefined; + switch (frame.type) { + case "command": { + if (deadline(frame.deadlineAt) <= Date.now()) return undefined; + const active = session; + const completion = executeAttendedCommand( + frame.command, + frame.attemptId, + frame.deadlineAt, + active, + peer, + ); + fireAndForget("v2 read execution", async () => completion); + return { completion }; + } + case "write_prepare": + if ( + attendedWritesBlocked || + deadline(frame.deadlineAt) <= Date.now() || + frame.leaseId !== undefined || + frame.leaseEpoch !== undefined || + frame.browserEpoch !== undefined + ) { + return undefined; + } + await attendedJournal.prepare({ + attemptId: frame.attemptId, + commandId: frame.commandId, + requestFingerprint: frame.requestFingerprint, + }); + sendIfPeerCurrent(peer, acceptingPeer, (current) => { + current.send({ + type: "write_ready", + attemptId: frame.attemptId, + commandId: frame.commandId, + deadlineAt: frame.deadlineAt, + requestFingerprint: frame.requestFingerprint, + }); + }); + return undefined; + case "write_grant": { + if ( + attendedWritesBlocked || + !isWriteCommand(frame.command) || + deadline(frame.deadlineAt) <= Date.now() || + frame.leaseId !== undefined || + frame.leaseEpoch !== undefined || + frame.browserEpoch !== undefined + ) { + return undefined; + } + const record = await attendedJournal.get(frame.attemptId); + if ( + record?.state !== "prepared" || + record.commandId !== frame.command.commandId + ) { + return undefined; + } + await attendedJournal.markStarted(frame.attemptId); + const active = session; + const completion = executeAttendedWrite( + frame.command, + frame.attemptId, + frame.deadlineAt, + active, + peer, + ); + fireAndForget("v2 write execution", async () => completion); + return { completion }; + } + case "attempt_cancel": + await attendedJournal.cancelPrepared(frame.attemptId); + return undefined; + case "result_ack": + await attendedJournal.acknowledge(frame.attemptId); + return undefined; + case "dialog_ack": + await attendedDialogs.acknowledge(frame.dialogId); + return undefined; + case "writes_blocked": + attendedWritesBlocked = true; + return undefined; + case "close_session": + attendedTerminal = true; + if (acceptingPeer === peer) acceptingPeer = null; + peer.stop(); + await detach(); + return undefined; + } +} + +async function executeAttendedCommand( + command: Command, + attemptId: string, + deadlineAt: string, + active: CdpSession | null, + peer: ReconnectingWs, +): Promise { + const event = await executeAttendedWithDeadline(command, deadlineAt, active); + if (event === null || session !== active) return; + sendAttendedResult(peer, attemptId, command.commandId, event); +} + +async function executeAttendedWrite( + command: Command, + attemptId: string, + deadlineAt: string, + active: CdpSession | null, + peer: ReconnectingWs, +): Promise { + const event = await executeAttendedWithDeadline(command, deadlineAt, active); + if (event === null || session !== active) { + await attendedJournal.markUnknown(attemptId); + attendedWritesBlocked = true; + return; + } + await attendedJournal.markCompleted(attemptId, event); + sendAttendedResult(peer, attemptId, command.commandId, event); +} + +async function executeAttendedWithDeadline( + command: Command, + deadlineAt: string, + active: CdpSession | null, +): Promise { + const remaining = deadline(deadlineAt) - Date.now(); + if (remaining <= 0) return null; + let timer: ReturnType; + const timeout = new Promise((resolve) => { + timer = setTimeout(() => resolve(null), remaining); + }); + const event = await Promise.race([routeCommand(command, active), timeout]); + clearTimeout(timer!); + if (event !== null) return event; + if (session === active && active !== null) { + await active.detach().catch(() => {}); + await clearAttachment(); + } + return null; +} + +function sendAttendedResult( + peer: ReconnectingWs, + attemptId: string, + commandId: string, + event: Event, +): void { + sendIfPeerCurrent(peer, acceptingPeer, (current) => { + current.send({ + type: "command_result", + attemptId, + commandId, + event, + }); + }); +} + +async function replayAttendedState(peer: ReconnectingWs): Promise { + for (const record of await attendedJournal.recover()) { + if (record.state === "prepared") { + sendIfPeerCurrent(peer, acceptingPeer, (current) => { + current.send({ + type: "write_ready", + attemptId: record.attemptId, + commandId: record.commandId, + deadlineAt: new Date(Date.now() + 1_000).toISOString(), + requestFingerprint: record.requestFingerprint, + }); + }); + } else if (record.state === "started") { + await attendedJournal.markUnknown(record.attemptId); + attendedWritesBlocked = true; + } else if ( + record.state === "completed_unacked" && + record.event !== undefined + ) { + sendAttendedResult( + peer, + record.attemptId, + record.commandId, + record.event, + ); + } + } + for (const record of await attendedDialogs.pending()) { + sendIfPeerCurrent(peer, acceptingPeer, (current) => { + current.send({ type: "dialog", ...record }); + }); + } +} + +function deadline(value: string): number { + return Date.parse(value); +} + async function startCommand( raw: unknown, peer: ReconnectingWs, ): Promise { + if (attendedTerminal || peer !== acceptingPeer) return undefined; const parsed = safeParseCommand(raw); if (!parsed.success) { log(`invalid command dropped: ${parsed.error.message}`, "warn"); @@ -191,7 +463,7 @@ async function startCommand( // Idempotent-retry gate for WRITE commands (reads always execute). A retry // under the same commandId either replays a recorded result, or - if the // original is still executing (the service timed out and the consumer - // retried) - is dropped so the write runs exactly once; the running + // retried) - is dropped so the write runs at most once; the running // execution's response resolves the service's parked promise. const decision = await dedupe.claim(parsed.data); if (decision.kind === "replay") { @@ -240,6 +512,7 @@ async function onCdpEvent( method: string, params: unknown, ): Promise { + await profileClient.sessions.onCdpEvent(source, method, params); const active = session; if (active === null || source.tabId !== active.tabId) return; const eventPeer = acceptingPeer; @@ -276,23 +549,41 @@ async function onCdpEvent( }); } if (decision.dialog !== undefined) { - // Answer synchronously so a page dialog cannot wedge the single CDP - // channel, then report it to the consumer fire-and-forget (like - // page_event) - the consumer is never in the response path. The - // answer-before-report ordering lives in applyDialogDecision (tested). - await applyDialogDecision( - decision.dialog, - (accept) => active.send("Page.handleJavaScriptDialog", { accept }), - (event) => { - if (session !== active) return; - sendIfPeerCurrent(eventPeer, acceptingPeer, (current) => { - current.send({ type: "dialog", tabId: active.tabId, ...event }); - }); + const accept = decision.dialog.accept; + const payload = decision.dialog.event; + const record = { + dialogId: crypto.randomUUID(), + occurredAt: new Date().toISOString(), + tabId: active.tabId, + dialogType: payload?.dialogType ?? "alert", + message: payload?.message ?? "", + url: payload?.url ?? active.currentUrl, + ...(payload?.defaultPrompt === undefined + ? {} + : { defaultPrompt: payload.defaultPrompt }), + disposition: + payload?.disposition ?? + (accept ? "accept" : "dismiss"), + } as const; + await handleDialogWithOutbox( + attendedDialogs, + record, + () => active.send("Page.handleJavaScriptDialog", { accept }), + (delivery) => { + if (session === active) { + sendIfPeerCurrent(eventPeer, acceptingPeer, (current) => { + current.send( + delivery === "ok" + ? { type: "dialog", ...record } + : { type: "health", dialogDelivery: "overflow" }, + ); + }); + } }, ); log( `handled ${decision.dialog.event?.dialogType ?? "unknown"} dialog: ${ - decision.dialog.accept ? "accept" : "dismiss" + accept ? "accept" : "dismiss" }`, ); } @@ -302,8 +593,10 @@ async function onCdpEvent( } async function onDetach(source: { tabId?: number }, reason: string): Promise { + await profileClient.sessions.onDebuggerDetach(source); const active = session; if (active === null || source.tabId !== active.tabId) return; + await fenceStartedAttendedWrites(); await clearAttachment(); log(`debugger detached from tab ${active.tabId} (${reason})`); broadcastState(); @@ -344,6 +637,7 @@ async function attach(): Promise { attachedTitle = tab.title; await persistAttachedTabId(tabId); log(`attached to tab ${tabId}`); + if (acceptingPeer !== null) await sendHello(acceptingPeer); broadcastState(); } catch (cause) { log(`attach failed: ${errorMessage(cause)}`, "error"); @@ -377,6 +671,14 @@ async function clearAttachment(): Promise { } } +async function fenceStartedAttendedWrites(): Promise { + for (const record of await attendedJournal.recover()) { + if (record.state !== "started") continue; + await attendedJournal.markUnknown(record.attemptId); + attendedWritesBlocked = true; + } +} + // Runs on every wake. Reads the persisted driven-tab id; if the browser is still // attached to it, rebuilds the session and reconciles WITHOUT re-attaching (which // would throw 'Already attached'); otherwise clears the stale persisted state. @@ -402,6 +704,7 @@ async function reconcileAttachment(): Promise { session = next; attachedTitle = target.title; log(`reconciled attachment to tab ${tabId}`); + if (acceptingPeer !== null) await sendHello(acceptingPeer); } else { await clearAttachment(); log(`attachment to tab ${tabId} no longer present; cleared`); @@ -442,6 +745,10 @@ async function setWsUrl(url: string): Promise { // the old peer before clearing replay state and rotating the ref scope; // this prevents an old snapshot from repopulating refs after invalidation. await dedupe.clear(); + await attendedJournal.clear(); + await attendedDialogs.clear(); + attendedWritesBlocked = false; + attendedTerminal = false; const active = session; if (active !== null) { try { @@ -453,11 +760,11 @@ async function setWsUrl(url: string): Promise { }); currentWsUrl = url; try { - await browser.storage.local.set({ [WS_URL_KEY]: url }); + await browser.storage.session.set({ [WS_URL_KEY]: url }); } catch (cause) { log(`persist wsUrl failed: ${errorMessage(cause)}`, "warn"); } - log(`ws url set to ${url}; reconnecting`); + log("attended session endpoint updated; reconnecting"); }); wsSwitchTail = change.then( () => undefined, @@ -496,11 +803,25 @@ function handlePanelMsg(msg: PanelMsg, port: Browser.runtime.Port): void { fireAndForget("attach", attach); break; case "detach": - fireAndForget("detach", detach); + fireAndForget("detach", () => commandIngress.barrier(detach)); break; case "setWsUrl": fireAndForget("setWsUrl", () => setWsUrl(msg.url)); break; + case "configureProfile": + fireAndForget("configureProfile", () => + profileClient.configure({ + serviceOrigin: msg.serviceOrigin, + unattendedEnabled: msg.enabled, + deviceId: msg.deviceId, + deviceCredential: msg.deviceCredential, + originPolicy: msg.originPolicy, + }), + ); + break; + case "stopAll": + fireAndForget("stopAll", () => profileClient.stopAll()); + break; } } @@ -518,6 +839,9 @@ function buildState(): StateMsg { wsStatus, wsUrl: currentWsUrl, attached: buildAttached(), + profileStatus: profileClient.currentStatus(), + controlledTabs: profileClient.sessions.assignments().length, + profileConfig: profileClient.publicConfig(), logs: [...logBuffer], }; } @@ -555,9 +879,16 @@ function onAlarm(alarm: { name: string }): void { if (alarm.name === BACKSTOP_ALARM) { // Wake-driven reconnect backstop across SW eviction. fireAndForget("ensureConnection", ensureConnection); + fireAndForget("ensureProfileConnection", () => + profileClient.ensureConnection(), + ); } } +function onTabCreated(tab: Browser.tabs.Tab): void { + fireAndForget("popup containment", () => profileClient.sessions.closeRelatedPopup(tab)); +} + // Run an async task detached from the caller, funnelling any rejection to the log // so a background failure can never become an unhandled rejection that kills the SW. function fireAndForget(label: string, task: () => Promise): void { diff --git a/apps/extension/src/entrypoints/sidepanel/App.tsx b/apps/extension/src/entrypoints/sidepanel/App.tsx index 8a78a5d..90e422e 100644 --- a/apps/extension/src/entrypoints/sidepanel/App.tsx +++ b/apps/extension/src/entrypoints/sidepanel/App.tsx @@ -1,10 +1,10 @@ import { useEffect, useRef, useState } from "react"; -import type { ReactElement } from "react"; +import type { FormEvent, ReactElement } from "react"; import type { Browser } from "wxt/browser"; import type { AttachedTab, LogEntry, PanelMsg, SwMsg } from "../../messaging"; const DEFAULT_WS_URL = "ws://localhost:8787"; -const WS_URL_STORAGE_KEY = "local:wsUrl"; +const WS_URL_STORAGE_KEY = "session:wsUrl"; const RECONNECT_DELAY_MS = 500; type StateSnapshot = Extract; @@ -22,29 +22,21 @@ export function App(): ReactElement { } }; - // Seed the wsUrl field from persisted storage so it has a sensible value - // before the first streamed `state` message arrives; state.wsUrl is the - // single source of truth once it does. useEffect(() => { let cancelled = false; - (async () => { - try { - const stored = await storage.getItem(WS_URL_STORAGE_KEY, { - fallback: DEFAULT_WS_URL, - }); + void storage + .getItem(WS_URL_STORAGE_KEY, { fallback: DEFAULT_WS_URL }) + .then((stored) => { if (!cancelled) setSeedWsUrl(stored); - } catch (cause) { + }) + .catch((cause: unknown) => { console.warn("understudy: failed to read stored wsUrl", cause); - } - })(); + }); return () => { cancelled = true; }; }, []); - // Port connection lifecycle (DL-008): connect on mount; on an - // eviction-driven disconnect, reconnect (which wakes the SW) and - // re-request state, without a manual reload. useEffect(() => { let disposed = false; let reconnectTimer: ReturnType | undefined; @@ -58,8 +50,10 @@ export function App(): ReactElement { if (msg.type === "state") { setSwState(msg); } else { - setSwState((prev) => - prev === null ? prev : { ...prev, logs: [...prev.logs, msg.entry] }, + setSwState((previous) => + previous === null + ? previous + : { ...previous, logs: [...previous.logs, msg.entry] }, ); } }); @@ -68,11 +62,10 @@ export function App(): ReactElement { if (disposed) return; reconnectTimer = setTimeout(connect, RECONNECT_DELAY_MS); }); - send({ type: "getState" }); + port.postMessage({ type: "getState" } satisfies PanelMsg); }; connect(); - return () => { disposed = true; clearTimeout(reconnectTimer); @@ -81,10 +74,14 @@ export function App(): ReactElement { }; }, []); - const wsStatus: StateSnapshot["wsStatus"] = swState?.wsStatus ?? "connecting"; - const wsUrl: string = swState?.wsUrl ?? seedWsUrl; - const attached: AttachedTab | null = swState?.attached ?? null; - const logs: LogEntry[] = swState?.logs ?? []; + const wsStatus = swState?.wsStatus ?? "connecting"; + const wsUrl = swState?.wsUrl ?? seedWsUrl; + const attached = swState?.attached ?? null; + const profileStatus = swState?.profileStatus ?? "disabled"; + const controlledTabs = swState?.controlledTabs ?? 0; + const profileConfig = swState?.profileConfig ?? null; + const logs = swState?.logs ?? []; + const formKey = JSON.stringify(profileConfig); const commitWsUrl = (rawUrl: string): void => { const trimmed = rawUrl.trim(); @@ -92,67 +89,209 @@ export function App(): ReactElement { send({ type: "setWsUrl", url: trimmed }); }; + const configureProfile = (event: FormEvent): void => { + event.preventDefault(); + const data = new FormData(event.currentTarget); + const originPolicy = String(data.get("originPolicy") ?? "") + .split(/\r?\n/) + .map((origin) => origin.trim()) + .filter((origin) => origin.length > 0); + send({ + type: "configureProfile", + serviceOrigin: String(data.get("serviceOrigin") ?? "").trim(), + deviceId: String(data.get("deviceId") ?? "").trim(), + deviceCredential: String(data.get("deviceCredential") ?? ""), + originPolicy, + enabled: data.get("enabled") === "on", + }); + const credential = event.currentTarget.elements.namedItem("deviceCredential"); + if (credential instanceof HTMLInputElement) credential.value = ""; + }; + return ( -
-
-

understudy

- {wsStatus} +
+
+
+

Browser control plane

+

Understudy

+
+ + {profileStatus} +
-
- - commitWsUrl(event.currentTarget.value)} - onKeyDown={(event) => { - if (event.key === "Enter") event.currentTarget.blur(); - }} - /> +
+
+ {controlledTabs} + / 2 +
+

controlled tabs

+
-
- {attached === null ? ( - - ) : ( - <> -
+ +
+ + + +