From 37d1f952c8f7b495c21db0aeaaef400bf5b1b01f Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Sun, 26 Jul 2026 14:31:39 +0400 Subject: [PATCH 1/6] feat: add durable unattended browser sessions --- README.md | 125 +- apps/backend/.dev.vars.example | 7 + apps/backend/CHANGELOG.md | 8 + apps/backend/README.md | 495 +++---- apps/backend/package.json | 2 +- apps/backend/src/auth.ts | 242 +++ apps/backend/src/coordinator-cf.ts | 27 +- apps/backend/src/coordinator.ts | 2 + apps/backend/src/device.ts | 509 +++++++ apps/backend/src/index.ts | 714 +++++++-- apps/backend/src/quota.ts | 45 + apps/backend/src/session.ts | 1301 ++++++++++++++++- apps/backend/src/telemetry.ts | 70 + apps/backend/src/tenant-coordinator.ts | 1023 +++++++++++++ apps/backend/src/types.ts | 74 +- apps/backend/src/validation.ts | 167 +++ apps/backend/test/auth.test.ts | 114 ++ apps/backend/test/coordinator.test.ts | 60 +- apps/backend/test/env.d.ts | 2 +- apps/backend/test/service.test.ts | 435 +++++- apps/backend/test/session.test.ts | 2 + apps/backend/test/tenant-coordinator.test.ts | 205 +++ apps/backend/test/validation.test.ts | 75 + apps/backend/vitest.config.ts | 10 + apps/backend/wrangler.jsonc | 37 +- apps/extension/CHANGELOG.md | 8 + apps/extension/README.md | 156 +- apps/extension/RUNBOOK.md | 283 ++-- apps/extension/package.json | 2 +- apps/extension/src/core/dedupe.test.ts | 21 + apps/extension/src/core/dedupe.ts | 15 +- apps/extension/src/core/dialog-outbox.test.ts | 79 + apps/extension/src/core/dialog-outbox.ts | 61 + apps/extension/src/core/profile-client.ts | 360 +++++ apps/extension/src/core/router.test.ts | 75 +- apps/extension/src/core/router.ts | 57 +- apps/extension/src/core/session-manager.ts | 253 ++++ .../src/core/session-runtime.test.ts | 65 + apps/extension/src/core/session-runtime.ts | 435 ++++++ apps/extension/src/core/write-journal.test.ts | 110 ++ apps/extension/src/core/write-journal.ts | 158 ++ apps/extension/src/core/ws-client.ts | 21 +- apps/extension/src/driver/a11y.test.ts | 54 + apps/extension/src/driver/a11y.ts | 37 +- apps/extension/src/driver/cdp-events.ts | 5 +- apps/extension/src/driver/cdp.test.ts | 102 ++ apps/extension/src/driver/cdp.ts | 69 + apps/extension/src/entrypoints/background.ts | 356 ++++- .../src/entrypoints/sidepanel/App.tsx | 283 +++- .../src/entrypoints/sidepanel/style.css | 401 +++-- apps/extension/src/messaging.ts | 29 +- apps/extension/wxt.config.ts | 2 +- docs/technical-plan.md | 857 ++++------- packages/connector/CHANGELOG.md | 14 + packages/connector/README.md | 329 ++--- packages/connector/package.json | 2 +- packages/connector/src/index.test.ts | 87 +- packages/connector/src/index.ts | 181 ++- packages/protocol/CHANGELOG.md | 8 + packages/protocol/README.md | 266 ++-- packages/protocol/package.json | 2 +- packages/protocol/src/index.test.ts | 192 +++ packages/protocol/src/index.ts | 602 ++++++-- 63 files changed, 9691 insertions(+), 2097 deletions(-) create mode 100644 apps/backend/src/device.ts create mode 100644 apps/backend/src/quota.ts create mode 100644 apps/backend/src/telemetry.ts create mode 100644 apps/backend/src/tenant-coordinator.ts create mode 100644 apps/backend/src/validation.ts create mode 100644 apps/backend/test/tenant-coordinator.test.ts create mode 100644 apps/backend/test/validation.test.ts create mode 100644 apps/extension/src/core/dialog-outbox.test.ts create mode 100644 apps/extension/src/core/dialog-outbox.ts create mode 100644 apps/extension/src/core/profile-client.ts create mode 100644 apps/extension/src/core/session-manager.ts create mode 100644 apps/extension/src/core/session-runtime.test.ts create mode 100644 apps/extension/src/core/session-runtime.ts create mode 100644 apps/extension/src/core/write-journal.test.ts create mode 100644 apps/extension/src/core/write-journal.ts 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..7c60a81 100644 --- a/apps/backend/README.md +++ b/apps/backend/README.md @@ -1,329 +1,170 @@ -# 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` | Detach attended or close unattended | +| `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 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`. + +## 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 credential 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. + +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 ``` -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`). +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..ace43e7 100644 --- a/apps/backend/src/auth.ts +++ b/apps/backend/src/auth.ts @@ -19,6 +19,26 @@ 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; + sessionId?: string; + leaseId?: string; + leaseEpoch: number; + browserEpoch: string; + agentName: string; + exp: number; +} + export interface TokenVerifier { verify(token: string): Promise; } @@ -182,6 +202,214 @@ 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", + "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.aud === "device-control" && + (claims.sessionId !== undefined || + claims.leaseId !== undefined || + claims.leaseEpoch !== 0)) || + (claims.aud === "session" && + (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); +} + async function importHmacKey(secret: string): Promise { return crypto.subtle.importKey( "raw", @@ -192,6 +420,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..dfbf476 100644 --- a/apps/backend/src/coordinator-cf.ts +++ b/apps/backend/src/coordinator-cf.ts @@ -9,7 +9,12 @@ */ 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"; @@ -39,6 +44,8 @@ export interface CoordinatorHost { persistAwaitingCommandIds(ids: string[]): 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(event: Event): void; } export class CfSessionCoordinator implements SessionCoordinator { @@ -95,24 +102,24 @@ export class CfSessionCoordinator implements SessionCoordinator { new Error(`${DUPLICATE_COMMAND}: ${cmd.commandId} is already awaiting its event`), ); } + if (this.pending.size > 0 || this.host.getAwaitingCommandIds().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 }; 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 }); - try { this.host.sendToExtension(JSON.stringify(cmd)); } catch { @@ -146,6 +153,7 @@ export class CfSessionCoordinator implements SessionCoordinator { const pendingCommand = this.pending.get(commandId); if (pendingCommand) { clearTimeout(pendingCommand.timer); + if (pendingCommand.timedOut) this.host.persistLateResult(ev); pendingCommand.resolve(ev); this.pending.delete(commandId); this.dropAwaiting(commandId); @@ -154,6 +162,7 @@ export class CfSessionCoordinator implements SessionCoordinator { if (this.host.getAwaitingCommandIds().includes(commandId)) { // Orphaned/late result: reconcile the marker, resolve nothing. + this.host.persistLateResult(ev); this.dropAwaiting(commandId); return; } diff --git a/apps/backend/src/coordinator.ts b/apps/backend/src/coordinator.ts index b561c93..ec33912 100644 --- a/apps/backend/src/coordinator.ts +++ b/apps/backend/src/coordinator.ts @@ -26,12 +26,14 @@ 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"; /** One outstanding `send(cmd)` call, awaiting its correlated Event. */ export interface PendingCommand { resolve: (ev: Event) => void; reject: (err: Error) => void; timer: ReturnType; + timedOut: boolean; } /** Outstanding commands awaiting their correlated Event, keyed by commandId. */ diff --git a/apps/backend/src/device.ts b/apps/backend/src/device.ts new file mode 100644 index 0000000..86eb173 --- /dev/null +++ b/apps/backend/src/device.ts @@ -0,0 +1,509 @@ +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; +} + +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; + } + 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, + ); + const authority = this.authority(); + if ( + claims === null || + authority === undefined || + claims.deviceId !== this.name || + claims.tenantId !== authority.tenant_id || + !(await this.consumeTicket(claims)) + ) { + connection.close(1008, "invalid or replayed device ticket"); + return; + } + + connection.setState({ authorized: true, claims } satisfies AuthorizedConnectionState); + this.setState({ + ...this.state, + activeConnectionId: connection.id, + tenantId: claims.tenantId, + browserEpoch: claims.browserEpoch, + }); + await emitTelemetry(this.env, { + event: "device_connect", + outcome: "authorized", + tenantId: claims.tenantId, + deviceId: claims.deviceId, + }); + 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. + } + } + } + + 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 state = connection.state as AuthorizedConnectionState; + const authority = this.authority(); + if (authority === undefined) { + connection.close(1008, "device authority missing"); + return; + } + + switch (frame.type) { + case "device_hello": { + if ( + frame.deviceId !== this.name || + frame.browserEpoch !== state.claims.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(state.claims.tenantId); + const registration = await coordinator.registerDevice({ + deviceId: this.name, + browser: frame.browser, + extVersion: frame.extVersion, + browserEpoch: frame.browserEpoch, + credentialDigest: authority.credential_digest, + credentialVersion: authority.credential_version, + allowedOrigins, + capabilities: frame.capabilities, + }); + if (registration.epochChanged) { + await emitTelemetry(this.env, { + event: "device_epoch_change", + outcome: "recovering", + tenantId: state.claims.tenantId, + deviceId: this.name, + }); + } + return; + } + case "heartbeat": { + if ( + frame.deviceId !== this.name || + frame.browserEpoch !== state.claims.browserEpoch || + !(await deviceCredentialExists( + authority.credential_digest, + { + tenantId: authority.tenant_id, + deviceId: authority.device_id, + credentialVersion: authority.credential_version, + }, + this.env, + )) + ) { + await this.coordinator(state.claims.tenantId).revokeDevice(this.name); + await emitTelemetry(this.env, { + event: "device_offline", + outcome: "credential_revoked", + tenantId: state.claims.tenantId, + deviceId: this.name, + }); + this.send(connection, { type: "credential_revoked" }); + connection.setState(null); + connection.close(1008, "device credential revoked"); + return; + } + const heartbeat = await this.coordinator(state.claims.tenantId).heartbeat( + this.name, + frame.browserEpoch, + frame.leaseIds, + ); + 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); + await session.beginRecovery(lease); + await this.sendProvision(lease); + await emitTelemetry(this.env, { + event: "recovery", + outcome: "provision_sent", + tenantId: state.claims.tenantId, + deviceId: this.name, + sessionId: lease.sessionId, + }); + } + for (const lease of heartbeat.assignments) { + const session = await getAgentByName(this.env.SESSION, lease.sessionId); + if (await session.needsSessionTicket()) { + await this.sendSessionTicket(lease); + } + } + for (const lease of heartbeat.closures) { + await this.requestClose(lease); + } + return; + } + case "provisioned": { + const result = await this.coordinator(state.claims.tenantId).markProvisioned(frame); + if (!result.accepted) { + await emitTelemetry(this.env, { + event: "provisioning", + outcome: "fenced", + tenantId: state.claims.tenantId, + deviceId: this.name, + sessionId: frame.sessionId, + }); + 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: state.claims.tenantId, + deviceId: this.name, + sessionId: frame.sessionId, + }); + return; + } + case "provision_failed": + await this.coordinator(state.claims.tenantId).markProvisionFailed(frame); + await emitTelemetry(this.env, { + event: "provisioning", + outcome: "failed", + tenantId: state.claims.tenantId, + deviceId: this.name, + sessionId: frame.sessionId, + }); + return; + case "closed": { + const terminal = await this.coordinator(state.claims.tenantId).confirmClosed(frame); + if (terminal !== null) { + const session = await getAgentByName(this.env.SESSION, frame.sessionId); + await session.markLifecycle(terminal, false); + await emitTelemetry(this.env, { + event: "release", + outcome: terminal, + tenantId: state.claims.tenantId, + deviceId: this.name, + sessionId: frame.sessionId, + }); + } + 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): Promise { + const connection = this.authoritativeConnection(); + if ( + connection === undefined || + this.state.tenantId === null || + lease.deviceId !== this.name || + lease.browserEpoch !== this.state.browserEpoch + ) { + return false; + } + const sessionTicket = await mintWsTicket( + { + aud: "session", + tenantId: this.state.tenantId, + deviceId: this.name, + sessionId: lease.sessionId, + leaseId: lease.leaseId, + leaseEpoch: lease.leaseEpoch, + browserEpoch: lease.browserEpoch, + agentName: lease.sessionId, + }, + this.env, + ); + 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): Promise { + const connection = this.authoritativeConnection(); + if ( + connection === undefined || + this.state.tenantId === null || + lease.deviceId !== this.name || + lease.browserEpoch !== this.state.browserEpoch + ) { + return false; + } + const sessionTicket = await mintWsTicket( + { + aud: "session", + tenantId: this.state.tenantId, + deviceId: this.name, + sessionId: lease.sessionId, + leaseId: lease.leaseId, + leaseEpoch: lease.leaseEpoch, + browserEpoch: lease.browserEpoch, + agentName: lease.sessionId, + }, + this.env, + ); + 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.isAuthorized(connection) && this.state.activeConnectionId === connection.id; + } + + private authoritativeConnection(): Connection | undefined { + if (this.state.activeConnectionId === null) return undefined; + return [...this.getConnections()].find( + (connection) => + connection.id === this.state.activeConnectionId && this.isAuthorized(connection), + ); + } + + private send(connection: Connection, frame: DeviceControlServerFrame): void { + connection.send(JSON.stringify(frame)); + } +} + +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..1cf072f 100644 --- a/apps/backend/src/index.ts +++ b/apps/backend/src/index.ts @@ -1,169 +1,691 @@ -/** - * 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, + 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, + 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 status = await (await getSessionStub(c.env, sessionId)).getStatus(); + if ( + "mode" in status && + status.mode === "unattended" && + (status.status === "closed" || status.status === "expired" || status.status === "lost") + ) { + 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); + 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); } }); -// 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, + 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 approximateRateAllowed(request, 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 approximateRateAllowed(request, 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 }; +} + +async function approximateRateAllowed(request: Request, env: Env): Promise { + if (env.RATE_LIMITER === undefined) return true; + const credential = request.headers.get("authorization") ?? ""; + const key = await telemetryPseudonym("rate-limit-credential", credential, env); + return (await env.RATE_LIMITER.limit({ key })).success; +} + +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..cdee1d9 100644 --- a/apps/backend/src/session.ts +++ b/apps/backend/src/session.ts @@ -1,18 +1,53 @@ 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, + isWriteCommand, + safeParseEvent, + safeParseSessionClientFrame, +} 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, } 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, + DispatchOutcome, + Env, + 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; @@ -24,6 +59,40 @@ const COMPLETED_WRITES_CAP = 100; // 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 = { @@ -36,9 +105,14 @@ export class SessionAgent extends Agent { 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); @@ -54,17 +128,82 @@ export class SessionAgent extends Agent { getAwaitingCommandIds: () => this.state.awaitingCommandIds, persistAwaitingCommandIds: (ids) => this.setState({ ...this.state, awaitingCommandIds: ids }), persistStatus: (status) => this.setState({ ...this.state, status }), + persistLateResult: (event) => this.rememberLegacyLateResult(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) { + 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, + ); + 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 || + !(await this.consumeSessionTicket(claims)) + ) { + 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 (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 (scope !== "ok") { connection.close(1008, "tenant mismatch"); return; @@ -72,8 +211,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, @@ -123,17 +268,48 @@ export class SessionAgent extends Agent { async onMessage(connection: Connection, message: WSMessage): Promise { 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 +321,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 +341,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,11 +493,30 @@ 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 { @@ -281,6 +616,805 @@ export class SessionAgent extends Agent { } } + async dispatchV2( + command: Command, + dryRun: boolean, + actorPseudonym: string, + statusUrl: string, + ): Promise { + const startedAt = Date.now(); + const fingerprint = await requestFingerprint(command, dryRun); + 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.isTerminalSession()) { + return { kind: "terminal_session", commandId: command.commandId }; + } + 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, + }); + 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); + 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, + }); + if (!admitted) { + this.markAttempt(attemptId, "not_started"); + return { kind: "busy", commandId: command.commandId }; + } + } + + if (dryRun && isWriteCommand(command)) { + if (command.type === "fill_secret" && !(await this.secretRefInTenant(command.secretRef))) { + 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 }, + ); + 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); + } 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 (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") { + if (!(await this.secretRefInTenant(command.secretRef))) { + 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, + ); + 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); + await this.schedule( + new Date(executionDeadlineAt), + "expireAttempt", + { attemptId }, + { idempotent: true }, + ); + 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.terminalizeGrantedAttempts(); + 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; + 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' + `; + const connection = this.authoritativeConnection(); + if (connection === undefined) { + this.setState({ ...this.state, status: "detached", activeConnectionId: null }); + return true; + } + connection.send( + JSON.stringify({ type: "close_session", closeTab: false } satisfies SessionServerFrame), + ); + return false; + } + + async waitForProtocolV2Connection(timeoutMs: number): Promise { + 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 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 }, + ); + 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 (row.state === "granted" || row.state === "ready" || row.state === "preparing") { + return this.pendingOutcome(commandId, statusUrl); + } + return this.outcomeForRow(row, 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 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 { + 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 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,6 +1467,9 @@ 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 }; + } throw err; } @@ -352,6 +1489,16 @@ export class SessionAgent extends Agent { this.setState({ ...this.state, completedWrites: next }); } + private rememberLegacyLateResult(event: Event): void { + if (!("commandId" in event)) return; + const next = [ + ...this.completedWrites().filter((entry) => entry.commandId !== event.commandId), + { commandId: event.commandId, event }, + ]; + while (next.length > COMPLETED_WRITES_CAP) next.shift(); + this.setState({ ...this.state, completedWrites: next }); + } + // Persisted before this field existed, a session's state can lack it; // initialState only seeds brand-new DOs. private completedWrites(): SessionState["completedWrites"] { @@ -359,14 +1506,39 @@ export class SessionAgent extends Agent { } /** 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 +1546,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, @@ -501,3 +1721,40 @@ export class SessionAgent extends Agent { ).activeConnectionId; } } + +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..f88332f --- /dev/null +++ b/apps/backend/src/tenant-coordinator.ts @@ -0,0 +1,1023 @@ +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 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 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 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 registerDevice(input: RegisterDeviceInput): Promise<{ epochChanged: boolean }> { + 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 { 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; + now?: number; + }): Promise<{ accepted: boolean; close: boolean }> { + const now = input.now ?? Date.now(); + const changed = this.ctx.storage.sql.exec( + `UPDATE lease + SET status = 'connected', needs_reconciliation = 0 + WHERE session_id = ? AND lease_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 > ?`, + input.sessionId, + input.leaseId, + input.leaseEpoch, + input.browserEpoch, + now, + now, + ).rowsWritten; + await this.scheduleNextAlarm(); + return { accepted: changed === 1, close: changed !== 1 }; + } + + async markProvisionFailed(input: { + sessionId: string; + leaseId: string; + leaseEpoch: number; + browserEpoch: string; + }): Promise { + this.ctx.storage.sql.exec( + `UPDATE lease SET status = 'closing', needs_reconciliation = 1 + WHERE session_id = ? AND lease_id = ? AND lease_epoch = ? AND browser_epoch = ? + AND status IN ('provisioning','recovering') AND release_at IS NULL`, + input.sessionId, + input.leaseId, + 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( + `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`, + now + DEVICE_LOST_MS, + input.sessionId, + input.leaseId, + input.leaseEpoch, + input.browserEpoch, + ).rowsWritten; + await this.scheduleNextAlarm(); + return changed === 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; + now?: number; + }): Promise { + const now = input.now ?? Date.now(); + const before = this.lease(input.sessionId); + if ( + before === undefined || + before.lease_id !== input.leaseId || + before.lease_epoch !== input.leaseEpoch || + before.browser_epoch !== input.browserEpoch || + before.release_at !== null || + (before.status !== "closing" && before.status !== "expired") + ) { + return null; + } + const terminalStatus: UnattendedSessionLifecycle = + before.status === "expired" ? "expired" : "closed"; + const changed = this.ctx.storage.sql.exec( + `UPDATE lease SET status = ?, release_at = ?, needs_reconciliation = 0 + WHERE session_id = ? AND lease_id = ? AND lease_epoch = ? AND browser_epoch = ? + AND release_at IS NULL AND status = ?`, + terminalStatus, + now, + input.sessionId, + input.leaseId, + input.leaseEpoch, + input.browserEpoch, + before.status, + ).rowsWritten; + await this.scheduleNextAlarm(); + return changed === 1 ? terminalStatus : 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, now = Date.now()): Promise { + 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. + } + } + } + + 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 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..8b3cc5a 100644 --- a/apps/backend/src/types.ts +++ b/apps/backend/src/types.ts @@ -8,8 +8,19 @@ * state have exactly one definition each. */ -import type { DialogRecord, Event, TabInfo } from "@understudy/protocol"; +import type { + 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 +59,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 +74,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 @@ -115,12 +135,30 @@ export interface SessionState { * 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 +172,30 @@ 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"; 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..814a4d1 100644 --- a/apps/backend/test/auth.test.ts +++ b/apps/backend/test/auth.test.ts @@ -3,11 +3,15 @@ import type { Env } from "../src/types"; import { base64urlEncode } from "../src/base64url"; import { authenticate, + authenticateDevice, + deviceCredentialExists, isValidTenantId, mintSessionId, + mintWsTicket, scopeSession, tenantOf, verifyExtensionToken, + verifyWsTicket, type Actor, } from "../src/auth"; @@ -24,10 +28,17 @@ 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, }; @@ -257,3 +268,106 @@ 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(); + }); +}); diff --git a/apps/backend/test/coordinator.test.ts b/apps/backend/test/coordinator.test.ts index a3a5d2b..fa1d10d 100644 --- a/apps/backend/test/coordinator.test.ts +++ b/apps/backend/test/coordinator.test.ts @@ -15,6 +15,7 @@ function createFakeHost(connected = true): CoordinatorHost & { sent: string[] } awaiting = ids; }, persistStatus: () => {}, + persistLateResult: () => {}, sent, }; } @@ -44,7 +45,7 @@ describe("CfSessionCoordinator", () => { expect(host.getAwaitingCommandIds()).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 { @@ -58,10 +59,14 @@ describe("CfSessionCoordinator", () => { 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(["c2"]); + + coordinator.resolvePending({ type: "action_result", commandId: "c2", ok: true }); expect(host.getAwaitingCommandIds()).toEqual([]); } finally { vi.useRealTimers(); @@ -165,7 +170,7 @@ describe("CfSessionCoordinator", () => { } }); - 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,47 +188,54 @@ 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 = coordinator.send(fillSecret); + coordinator.resolvePending({ type: "action_result", commandId: "c4", ok: true }); + await first; + second = coordinator.send(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"]); + expect(host.getAwaitingCommandIds()).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([]); }); + it("refuses a distinct command while another command owns the session slot", async () => { + const host = createFakeHost(); + const coordinator = new CfSessionCoordinator(host); + const first = coordinator.send({ type: "get_tabs", commandId: "c-busy-a" }); + + await expect( + coordinator.send({ 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 () => { // #given a command parked and awaiting its event const host = createFakeHost(); 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..82e7f1a 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" }), @@ -84,6 +108,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 @@ -298,6 +364,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 +450,311 @@ describe("command round-trip via a live extension WebSocket", () => { }); }); +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 +764,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 +1417,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 +1532,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)); @@ -1296,7 +1705,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); @@ -1625,6 +2034,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 +2052,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 +2076,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 +2088,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..9c50533 100644 --- a/apps/backend/test/session.test.ts +++ b/apps/backend/test/session.test.ts @@ -518,6 +518,8 @@ 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..cc110b0 --- /dev/null +++ b/apps/backend/test/tenant-coordinator.test.ts @@ -0,0 +1,205 @@ +import { describe, expect, it } from "vitest"; +import { env } from "cloudflare:workers"; +import type { 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("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, + leaseEpoch: first.lease.leaseEpoch, + browserEpoch: first.lease.browserEpoch, + now: 1_000_100, + }); + expect(await stub.createLease(input)).toEqual({ kind: "terminal", status: "closed" }); + }); + + 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, + 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("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, + 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..00f27a3 100644 --- a/apps/extension/README.md +++ b/apps/extension/README.md @@ -1,64 +1,110 @@ -# 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 only: + +- Service origin +- Enabled state +- Device ID +- Raw device credential +- Local origin policy + +The extension restricts local-storage access to trusted extension contexts. + +`chrome.storage.session` contains browser epoch, lease assignments, tab IDs, ref generations, write journal entries, and dialog outbox records. It never contains command bodies, typed text, secret plaintext, secret references, screenshots, accessibility trees, prior URLs, or restoration tasks. + +Browser restart clears execution authority. The extension creates fresh blank tabs for live recovering leases and never restores old URLs. + +## 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`. -## Manifest +The outbox holds at most 256 records and 256 KiB. Overflow still answers the browser dialog and reports content-free health. -`wxt.config.ts` declares: +## Stop automation -- `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`). +**Stop all** closes only tabs proven to belong to current leases and disables unattended hosting. It does not close unrelated or restored tabs. -## 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..d36c8cd --- /dev/null +++ b/apps/extension/src/core/dialog-outbox.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it, vi } from "vitest"; +import type { DialogRecord } from "@understudy/protocol"; +import type { SessionStorageArea } from "./dedupe"; +import { DialogOutbox } 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([]); + }); +}); diff --git a/apps/extension/src/core/dialog-outbox.ts b/apps/extension/src/core/dialog-outbox.ts new file mode 100644 index 0000000..7f480e8 --- /dev/null +++ b/apps/extension/src/core/dialog-outbox.ts @@ -0,0 +1,61 @@ +import { DialogRecordSchema, type DialogRecord } from "@understudy/protocol"; +import type { SessionStorageArea } from "./dedupe"; + +const MAX_RECORDS = 256; +const MAX_BYTES = 256 * 1024; + +export class DialogOutbox { + private records: DialogRecord[] | null = null; + + constructor( + private readonly storage: SessionStorageArea, + private readonly storageKey: string, + ) {} + + async add(record: DialogRecord): Promise<"ok" | "overflow"> { + 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"; + } + } + + async acknowledge(dialogId: string): Promise { + await this.hydrate(); + await this.persist((this.records ?? []).filter((record) => record.dialogId !== dialogId)); + } + + async pending(): Promise { + await this.hydrate(); + return [...(this.records ?? [])]; + } + + async clear(): Promise { + await this.persist([]); + } + + 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.ts b/apps/extension/src/core/profile-client.ts new file mode 100644 index 0000000..41d7bfd --- /dev/null +++ b/apps/extension/src/core/profile-client.ts @@ -0,0 +1,360 @@ +import { + DEVICE_CONTROL_FRAME_MAX_BYTES, + PROTOCOL_CAPABILITIES, + PROTOCOL_VERSION, + safeParseDeviceControlServerFrame, + type DeviceControlClientFrame, +} from "@understudy/protocol"; +import { ReconnectingWs } from "./ws-client"; +import { SessionManager } from "./session-manager"; + +const BROWSER_EPOCH_KEY = "understudy:browserEpoch"; +const CONFIG_KEYS = [ + "serviceOrigin", + "unattendedEnabled", + "deviceId", + "deviceCredential", + "originPolicy", +] as const; + +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 epoch = ""; + private control: ReconnectingWs | null = null; + private status: ProfileStatus = "disabled"; + private controlFrameTail: Promise = Promise.resolve(); + + constructor(private readonly onStatus?: (status: ProfileStatus) => void) { + this.sessions = new SessionManager( + () => this.requiredConfig().serviceOrigin, + () => this.epoch, + ); + } + + async start(): Promise { + await this.restrictLocalStorage(); + this.epoch = await this.loadBrowserEpoch(); + this.config = await this.loadConfig(); + await this.sessions.restoreSameEpoch(); + if (this.config?.unattendedEnabled === true) { + await this.connectControl(); + } else { + this.setStatus("disabled"); + } + } + + 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], + }; + } + + async configure(config: ProfileConfig): Promise { + const normalized = normalizeProfileConfig(config); + await browser.storage.local.set(normalized); + this.config = normalized; + if (!normalized.unattendedEnabled) { + await this.closeAllAndAcknowledge(); + this.control?.stop(); + this.control = null; + this.setStatus("disabled"); + return; + } + this.control?.stop(); + this.control = null; + await this.connectControl(); + } + + async stopAll(): Promise { + await this.closeAllAndAcknowledge(); + this.control?.stop(); + this.control = null; + if (this.config !== null) { + this.config = { ...this.config, unattendedEnabled: false }; + await browser.storage.local.set({ unattendedEnabled: false }); + } + this.setStatus("disabled"); + } + + private async closeAllAndAcknowledge(): Promise { + const assignments = this.sessions.assignments(); + for (const assignment of assignments) { + const closed = await this.sessions.closeLease(assignment); + if (!closed) continue; + this.sendControl({ + type: "closed", + sessionId: assignment.sessionId, + leaseId: assignment.leaseId, + leaseEpoch: assignment.leaseEpoch, + browserEpoch: assignment.browserEpoch, + }); + } + } + + private async connectControl(): Promise { + const config = this.requiredConfig(); + this.setStatus("connecting"); + let ticket: { ticket: string; websocketPath: string }; + try { + const 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 }), + }, + ); + if (!response.ok) throw new Error(`device ticket request failed with ${response.status}`); + const value = (await response.json()) as Partial; + if ( + typeof value.ticket !== "string" || + typeof value.websocketPath !== "string" + ) { + throw new Error("device ticket response was malformed"); + } + ticket = { ticket: value.ticket, websocketPath: value.websocketPath }; + } catch { + this.setStatus("error"); + return; + } + + const url = new URL(ticket.websocketPath, config.serviceOrigin); + url.protocol = url.protocol === "https:" ? "wss:" : "ws:"; + url.searchParams.set("ticket", ticket.ticket); + let peer!: ReconnectingWs; + peer = new ReconnectingWs( + () => url.toString(), + { + onCommand: (raw) => { + if (peer !== this.control) return; + const handleIfCurrent = async () => { + if (peer === this.control) await this.onControlFrame(raw); + }; + const handling = this.controlFrameTail.then( + handleIfCurrent, + handleIfCurrent, + ); + this.controlFrameTail = handling.catch(() => {}); + }, + onOpen: () => { + if (peer !== this.control) return; + this.setStatus("connected"); + this.sendControl({ + 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, + }); + }, + onClose: () => { + if (peer !== this.control) return; + peer.stop(); + this.control = null; + this.setStatus("connecting"); + setTimeout(() => void this.connectControl(), 500); + }, + heartbeatFrame: () => ({ + type: "heartbeat", + deviceId: config.deviceId, + browserEpoch: this.epoch, + leaseIds: this.sessions.assignments().map((assignment) => assignment.leaseId), + }), + }, + DEVICE_CONTROL_FRAME_MAX_BYTES, + ); + this.control = peer; + } + + private async onControlFrame(raw: unknown): Promise { + const parsed = safeParseDeviceControlServerFrame(raw); + if (!parsed.success) return; + const frame = parsed.data; + switch (frame.type) { + case "provision": + try { + const tab = await this.sessions.provision(frame); + this.sendControl({ + type: "provisioned", + sessionId: frame.sessionId, + leaseId: frame.leaseId, + leaseEpoch: frame.leaseEpoch, + browserEpoch: frame.browserEpoch, + tab, + }); + } catch { + this.sendControl({ + type: "provision_failed", + sessionId: frame.sessionId, + leaseId: frame.leaseId, + leaseEpoch: frame.leaseEpoch, + browserEpoch: frame.browserEpoch, + reason: "local provisioning failed", + }); + } + return; + case "close_lease": + if (await this.sessions.closeLease(frame)) { + this.sendControl({ + type: "closed", + sessionId: frame.sessionId, + leaseId: frame.leaseId, + leaseEpoch: frame.leaseEpoch, + browserEpoch: frame.browserEpoch, + }); + } + return; + case "session_ticket": + this.sessions.connectSessionTicket(frame); + return; + case "credential_revoked": + this.control?.stop(); + this.control = null; + await this.sessions.stopAll(); + this.setStatus("error"); + return; + } + } + + private sendControl(frame: DeviceControlClientFrame): void { + this.control?.send(frame); + } + + 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 loadConfig(): Promise { + const stored = await browser.storage.local.get([...CONFIG_KEYS]); + const candidate = { + serviceOrigin: stored.serviceOrigin, + unattendedEnabled: stored.unattendedEnabled, + deviceId: stored.deviceId, + deviceCredential: stored.deviceCredential, + originPolicy: stored.originPolicy, + }; + try { + return normalizeProfileConfig(candidate); + } catch { + return null; + } + } + + 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 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; +} 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.ts b/apps/extension/src/core/session-manager.ts new file mode 100644 index 0000000..f3c29e0 --- /dev/null +++ b/apps/extension/src/core/session-manager.ts @@ -0,0 +1,253 @@ +import type { TabInfo } from "@understudy/protocol"; +import type { Browser } from "wxt/browser"; +import { + SessionRuntime, + type RuntimeAssignment, + type RuntimeHost, +} from "./session-runtime"; + +const ASSIGNMENTS_KEY = "understudy:assignments"; +const CAPACITY = 2; + +export interface ProvisionInput { + sessionId: string; + leaseId: string; + leaseEpoch: number; + browserEpoch: string; + allowedOrigins: string[]; + sessionTicket: string; +} + +export class SessionManager implements RuntimeHost { + private readonly bySession = new Map(); + private readonly byLease = new Map(); + private readonly byTab = new Map(); + + 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): Promise { + 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"); + } + existing.connect(input.sessionTicket); + return this.tabInfo(existing.tabId); + } + 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: RuntimeAssignment = { + 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(); + try { + await runtime.attach(); + runtime.connect(input.sessionTicket); + return this.tabInfo(runtime.tabId); + } catch (error) { + await this.remove(runtime, true); + 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 + ) { + return false; + } + runtime.connect(input.sessionTicket); + return true; + } + + async closeLease(input: { + sessionId: string; + leaseId: string; + leaseEpoch: number; + browserEpoch: string; + }): 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 + ) { + return false; + } + return this.remove(runtime, true); + } + + async restoreSameEpoch(): Promise { + const stored = await browser.storage.session.get(ASSIGNMENTS_KEY); + const value = stored[ASSIGNMENTS_KEY]; + if (!Array.isArray(value)) return; + const targets = await browser.debugger.getTargets(); + for (const raw of value) { + if (!isAssignment(raw) || raw.browserEpoch !== this.browserEpoch()) continue; + const target = targets.find((candidate) => candidate.tabId === raw.tabId); + if (target?.attached !== true) continue; + const runtime = new SessionRuntime(raw, this); + this.install(runtime); + try { + await runtime.reconcileSameEpoch(); + } catch { + this.uninstall(runtime); + } + } + await this.persist(); + } + + 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(): Promise { + for (const runtime of [...this.byLease.values()]) { + await this.remove(runtime, true); + } + } + + assignments(): RuntimeAssignment[] { + return [...this.byLease.values()].map((runtime) => runtime.assignment); + } + + async onFenced(runtime: SessionRuntime): Promise { + if (!this.isCurrent(runtime)) return; + this.uninstall(runtime); + await this.persist(); + } + + 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 remove(runtime: SessionRuntime, closeTab: boolean): Promise { + if (!(await runtime.close(closeTab))) return false; + this.uninstall(runtime); + await this.persist().catch(() => {}); + return true; + } + + private async persist(): Promise { + await browser.storage.session.set({ + [ASSIGNMENTS_KEY]: this.assignments(), + }); + } + + 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, + }; + } +} + +function isAssignment(value: unknown): value is RuntimeAssignment { + 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" + ); +} 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..c52ad9b --- /dev/null +++ b/apps/extension/src/core/session-runtime.test.ts @@ -0,0 +1,65 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +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()): void { + vi.stubGlobal("browser", { + storage: { session: {} }, + tabs: { remove: vi.fn(remove), get }, + }); +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("SessionRuntime close fencing", () => { + 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); + }); +}); diff --git a/apps/extension/src/core/session-runtime.ts b/apps/extension/src/core/session-runtime.ts new file mode 100644 index 0000000..4e8685a --- /dev/null +++ b/apps/extension/src/core/session-runtime.ts @@ -0,0 +1,435 @@ +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 } 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 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 = true; + private writesBlocked = false; + private closing = false; + + constructor( + readonly assignment: RuntimeAssignment, + private readonly host: RuntimeHost, + ) { + 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 { + 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; + } + } + } + + 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 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 ?? (decision.dialog.accept ? "accept" : "dismiss"), + } as const; + const delivery = await this.dialogs.add(record); + try { + await cdp.send("Page.handleJavaScriptDialog", { accept: decision.dialog.accept }); + } finally { + 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.host.isCurrent(this)) 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 (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()) { + this.send({ type: "dialog", ...dialog }); + } + } + + private async onServerFrame(raw: unknown): Promise { + if (!this.accepting || !this.host.isCurrent(this)) 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, + }); + 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.host.isCurrent(this)) { + 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 ( + record === undefined || + record.state !== "prepared" || + record.commandId !== frame.command.commandId + ) { + return; + } + await this.journal.markStarted(frame.attemptId); + const event = await this.executeWithDeadline(frame.command, deadline(frame.deadlineAt)); + if (event === null || !this.host.isCurrent(this)) { + await this.journal.markUnknown(frame.attemptId); + this.writesBlocked = true; + return; + } + await this.journal.markCompleted(frame.attemptId, event); + 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 send(frame: unknown): void { + this.peer?.send(frame); + } +} + +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.ts b/apps/extension/src/core/ws-client.ts index 742b80d..382bf4d 100644 --- a/apps/extension/src/core/ws-client.ts +++ b/apps/extension/src/core/ws-client.ts @@ -1,10 +1,9 @@ -import type { Event } from "@understudy/protocol"; - interface WsHandlers { onCommand: (cmd: unknown) => void; onOpen: () => void; onClose?: () => void; onConnecting?: () => void; + heartbeatFrame?: () => unknown | null; } const BACKOFF_BASE_MS = 500; @@ -26,21 +25,26 @@ 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): void { const socket = this.socket; if (socket !== null && socket.readyState === WebSocket.OPEN) { - socket.send(JSON.stringify(ev)); + socket.send(JSON.stringify(frame)); } } 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 +82,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; } 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.ts b/apps/extension/src/driver/cdp-events.ts index 170a369..c34d5c8 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 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..f277807 100644 --- a/apps/extension/src/entrypoints/background.ts +++ b/apps/extension/src/entrypoints/background.ts @@ -1,14 +1,23 @@ -import { safeParseCommand } from "@understudy/protocol"; +import { + PROTOCOL_CAPABILITIES, + PROTOCOL_VERSION, + 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 } 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 +29,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 +66,17 @@ 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; const commandIngress = new CommandIngress(); +const profileClient = new ProfileClient(() => broadcastState()); const logBuffer: LogEntry[] = []; const ports = new Set(); @@ -69,6 +89,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 +98,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 +112,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; @@ -156,24 +186,248 @@ function onClose(peer: ReconnectingWs): void { // 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; + 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 { + 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": + 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, @@ -191,7 +445,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 +494,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,20 +531,37 @@ 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; + 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 ?? + (decision.dialog.accept ? "accept" : "dismiss"), + } as const; + const delivery = await attendedDialogs.add(record); + try { + await active.send("Page.handleJavaScriptDialog", { + accept: decision.dialog.accept, + }); + } finally { + if (session === active) { sendIfPeerCurrent(eventPeer, acceptingPeer, (current) => { - current.send({ type: "dialog", tabId: active.tabId, ...event }); + current.send( + delivery === "ok" + ? { type: "dialog", ...record } + : { type: "health", dialogDelivery: "overflow" }, + ); }); - }, - ); + } + } log( `handled ${decision.dialog.event?.dialogType ?? "unknown"} dialog: ${ decision.dialog.accept ? "accept" : "dismiss" @@ -302,8 +574,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 +618,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 +652,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 +685,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 +726,9 @@ 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; const active = session; if (active !== null) { try { @@ -453,11 +740,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 +783,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 +819,9 @@ function buildState(): StateMsg { wsStatus, wsUrl: currentWsUrl, attached: buildAttached(), + profileStatus: profileClient.currentStatus(), + controlledTabs: profileClient.sessions.assignments().length, + profileConfig: profileClient.publicConfig(), logs: [...logBuffer], }; } @@ -558,6 +862,10 @@ function onAlarm(alarm: { name: string }): void { } } +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 ? ( - - ) : ( - <> -
+ +
+ + + +