diff --git a/.changeset/dialog-handling.md b/.changeset/dialog-handling.md new file mode 100644 index 0000000..2dd059c --- /dev/null +++ b/.changeset/dialog-handling.md @@ -0,0 +1,11 @@ +--- +"@understudy/protocol": minor +"@understudy/connector": minor +--- + +Add JavaScript-dialog handling breadth. + +- **protocol**: new `dialog` Event (`{ type, tabId, dialogType: alert | confirm | prompt | beforeunload, message, url, defaultPrompt?, disposition: accept | dismiss }`) plus `DialogTypeSchema` / `DialogDispositionSchema` exports. Emitted unsolicited (like `page_event`) after the extension locally handles a page dialog, so a consumer learns what the page said and how it was answered. +- **connector**: `browser.observe` gains a `get_dialogs` read returning the session's recent dialogs (`ObserveOutput.dialogs`), read from `GET /v1/sessions/:id`. + +The extension now applies a type-aware local disposition (alert/beforeunload accept, confirm/prompt dismiss) instead of blindly dismissing every dialog — a `beforeunload` dismiss previously cancelled navigations. Dispositions are decided synchronously extension-side because an open dialog blocks the single CDP channel; the consumer is notified, never in the response path. diff --git a/apps/backend/.dev.vars.example b/apps/backend/.dev.vars.example index 61649cc..44e0f5c 100644 --- a/apps/backend/.dev.vars.example +++ b/apps/backend/.dev.vars.example @@ -22,5 +22,7 @@ EXTENSION_TOKENS={"dev-ext-token":"dev-tenant"} # vault value (src/vault.ts). This dev value decodes to the literal # "dev-vault-master-key-0123456789!". Generate a real one with: # node -e "console.log(require('node:crypto').randomBytes(32).toString('base64url'))" -# Seed dev secrets with: node scripts/vault-put.mjs 'vault://ref' --local +# Seed dev secrets with (key MUST be vault:///; fillSecret +# refuses refs outside the session's own tenant, and the dev tenant is +# "dev-tenant" above): node scripts/vault-put.mjs 'vault://dev-tenant/ref' --local VAULT_MASTER_KEY=ZGV2LXZhdWx0LW1hc3Rlci1rZXktMDEyMzQ1Njc4OSE diff --git a/apps/backend/README.md b/apps/backend/README.md index d68fb19..b11d020 100644 --- a/apps/backend/README.md +++ b/apps/backend/README.md @@ -129,7 +129,16 @@ during that window. Four things close this gap: `{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. + 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, @@ -142,8 +151,11 @@ during that window. Four things close this gap: 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. This - is fail-safe by construction: a governance simulation (called *before* an + 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 @@ -159,8 +171,11 @@ during that window. Four things close this gap: 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 remains a possible - future swap behind the same `VaultBinding.get` seam (`types.ts`). + ("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 @@ -202,6 +217,11 @@ during that window. Four things close this gap: - `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 @@ -214,7 +234,9 @@ during that window. Four things close this gap: - `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 - always returns a simulated `action_result` from a read-only ref check. + 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 @@ -245,8 +267,12 @@ openssl rand 32 | basenc --base64url | tr -d '=' | pnpm exec wrangler secret put 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): -printf '%s' 'the-secret' | VAULT_MASTER_KEY= node scripts/vault-put.mjs 'vault://tenant/ref' +# 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' ``` The extension connects to diff --git a/apps/backend/scripts/stub-consumer.mjs b/apps/backend/scripts/stub-consumer.mjs index a993b5c..67747e8 100644 --- a/apps/backend/scripts/stub-consumer.mjs +++ b/apps/backend/scripts/stub-consumer.mjs @@ -27,10 +27,12 @@ * * `type` and `click` target the first ref found in the snapshot's a11y tree * (a naive "first element" heuristic - fine for a demo/runbook, not real - * ref-targeting logic). `fill_secret` deliberately uses a fake vault:// - * secretRef that no vault will ever resolve, so it is expected to return - * ok:false - demonstrating the scrubbed-error path, not a broken script. A - * stale/unresolvable ref for any command is likewise expected to return + * ref-targeting logic). `fill_secret` deliberately uses a fake, tenant-scoped + * secretRef (`vault://dev-tenant/…`) the vault has no value for, so it is + * expected to return ok:false - demonstrating the scrubbed-error path, not a + * broken script. (fillSecret enforces `vault:///…` scoping: a ref + * outside the caller's own tenant is refused with the same scrubbed ok:false.) + * A stale/unresolvable ref for any command is likewise expected to return * ok:false, not to fail the run. * * Env vars / flags (all optional; flags win, then env vars, then defaults): @@ -139,8 +141,10 @@ async function main() { type: "fill_secret", commandId: "stub-4", ref: targetRef, - // Deliberately fake and unresolvable - see the header comment. - secretRef: "vault://stub-consumer-fake-secret", + // Deliberately fake and unresolvable - see the header comment. Scoped to + // the default dev tenant so it exercises the vault-miss path; a ref outside + // the caller's tenant is refused with the same scrubbed ok:false. + secretRef: "vault://dev-tenant/stub-consumer-fake-secret", }); console.log("\ndone."); diff --git a/apps/backend/src/auth.ts b/apps/backend/src/auth.ts index fbbf1be..750e2cf 100644 --- a/apps/backend/src/auth.ts +++ b/apps/backend/src/auth.ts @@ -53,12 +53,28 @@ export async function authenticate(req: Request, env: Env): Promise/` and SessionAgent.fillSecret isolates + * tenants with a `vault:///` prefix check, so a tenantId that is + * empty or contains `/` would let one tenant's prefix straddle another's + * namespace (tenant "acme" reaching "acme/eu"'s keys). Enforced at mint + * (fail-closed at session creation) and re-checked in tenantOf, so no signed + * id can carry an unsafe tenant into that prefix check. + */ +export function isValidTenantId(tenantId: string): boolean { + return tenantId.length > 0 && !tenantId.includes("/"); +} + /** * Mints a sessionId with the owning tenant embedded and HMAC-signed, so * scopeSession can verify ownership statelessly - no lookup table maps * sessionId -> tenant; the id carries its own proof (DL-008). */ export async function mintSessionId(tenantId: string, env: Env): Promise { + if (!isValidTenantId(tenantId)) { + throw new Error("invalid tenantId: must be non-empty and contain no '/'"); + } const nonce = toHex(crypto.getRandomValues(new Uint8Array(16))); const payloadBytes = new TextEncoder().encode(JSON.stringify({ t: tenantId, n: nonce })); @@ -69,37 +85,53 @@ export async function mintSessionId(tenantId: string, env: Env): Promise } /** - * Verifies sessionId's HMAC signature and that its embedded tenant matches - * tenantId. Every failure path - bad shape, bad signature, wrong tenant, - * decode error - collapses to the same "not-found" the caller surfaces as - * 404, so no response shape distinguishes "malformed id" from "someone - * else's session" (DL-008: no existence oracle). + * Verifies sessionId's HMAC signature and returns the tenant embedded in it, + * or null for any malformed / forged / undecodable id. This is a session's + * AUTHORITATIVE tenant - it comes from the signed id itself, never a + * caller-supplied claim - so a Durable Object can trust `tenantOf(this.name)` + * to scope a resource it owns (e.g. the credential vault) to that session's + * owner. scopeSession is the boolean "does this id belong to tenantId?" + * wrapper over it. */ -export async function scopeSession( - sessionId: string, - tenantId: string, - env: Env, -): Promise<"ok" | "not-found"> { +export async function tenantOf(sessionId: string, env: Env): Promise { try { const parts = sessionId.split("."); - if (parts.length !== 2) return "not-found"; + if (parts.length !== 2) return null; const [payloadB64, sigB64] = parts; - if (!payloadB64 || !sigB64) return "not-found"; + if (!payloadB64 || !sigB64) return null; const payloadBytes = base64urlDecode(payloadB64); const sigBytes = base64urlDecode(sigB64); const key = await importHmacKey(env.AUTH_HMAC_SECRET); const verified = await crypto.subtle.verify("HMAC", key, sigBytes, payloadBytes); - if (!verified) return "not-found"; + if (!verified) return null; const payload = JSON.parse(new TextDecoder().decode(payloadBytes)) as { t?: unknown }; - return payload.t === tenantId ? "ok" : "not-found"; + // Re-check the shape a valid mint enforces: a signed id must never carry an + // empty or slash-bearing tenant into fillSecret's vault-namespace prefix + // check (defense in depth against a token minted before this rule existed). + return typeof payload.t === "string" && isValidTenantId(payload.t) ? payload.t : null; } catch { - return "not-found"; + return null; } } +/** + * Whether sessionId is a valid id minted for tenantId. Every failure path - + * bad shape, bad signature, wrong tenant, decode error - collapses to the + * same "not-found" the caller surfaces as 404, so no response shape + * distinguishes "malformed id" from "someone else's session" (DL-008: no + * existence oracle). + */ +export async function scopeSession( + sessionId: string, + tenantId: string, + env: Env, +): Promise<"ok" | "not-found"> { + return (await tenantOf(sessionId, env)) === tenantId ? "ok" : "not-found"; +} + /** * Verifies the extension's own per-user token (SessionAgent.onConnect), * independent of the caller-auth path above - the browser extension and the diff --git a/apps/backend/src/session.ts b/apps/backend/src/session.ts index 0b5a63e..5a3e482 100644 --- a/apps/backend/src/session.ts +++ b/apps/backend/src/session.ts @@ -2,7 +2,7 @@ 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, verifyExtensionToken } from "./auth"; +import { scopeSession, tenantOf, verifyExtensionToken } from "./auth"; import { COMMAND_TIMED_OUT, DUPLICATE_COMMAND, @@ -21,6 +21,10 @@ type FillSecretCommand = Extract; // while covering far more retries than a consumer's per-case write count. 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; + export class SessionAgent extends Agent { initialState: SessionState = { browser: null, @@ -30,6 +34,7 @@ export class SessionAgent extends Agent { awaitingCommandIds: [], status: "pending", completedWrites: [], + dialogs: [], }; private readonly coordinator: CfSessionCoordinator; @@ -135,6 +140,9 @@ export class SessionAgent extends Agent { case "page_event": this.setState({ ...this.state, currentUrl: ev.url }); return; + case "dialog": + this.rememberDialog(ev); + return; } } @@ -181,12 +189,39 @@ export class SessionAgent extends Agent { async fillSecret(cmd: FillSecretCommand, dryRun?: boolean): Promise { try { if (dryRun === true) { + // A dry-run the real call would refuse for tenant scoping simulates + // that refusal (before the DOM ref probe), so a governance pre-approval + // preview is honest rather than reporting ok:true for a fill that can + // never dispatch. Still zero vault access and no wire traffic: + // secretRefInTenant only reads the signed sessionId (this.name). + if (!(await this.secretRefInTenant(cmd.secretRef))) { + return { + ok: true, + event: this.simulatedResult(cmd.commandId, { + ok: false, + reason: "secret could not be resolved", + }), + }; + } return { ok: true, event: this.simulatedResult(cmd.commandId, await this.checkRefResolves(cmd.ref)), }; } + // Tenant scoping FIRST, before replay/gate/vault: a secretRef resolves + // only within this session's OWN tenant, derived from the HMAC-signed + // sessionId (this.name) - never a caller claim - so tenantB driving its + // own session can never read vault://tenantA/... understudy owns one + // shared vault across tenants, so this check lives here, not in a + // consumer's breakwater. A ref outside the tenant namespace collapses to + // the SAME scrubbed ok:false an absent secret returns: no vault read, no + // dispatch, and no oracle telling "not yours" from "does not exist" + // (DL-008). + if (!(await this.secretRefInTenant(cmd.secretRef))) { + return { ok: true, event: this.unresolvableSecretResult(cmd.commandId) }; + } + // Replay BEFORE the connection gate and the vault: a retry of an // already-performed fill needs neither liveness nor plaintext. const replayed = this.completedWriteEvent(cmd); @@ -207,15 +242,7 @@ export class SessionAgent extends Agent { try { secret = await resolveSecret(createVault(this.env), cmd.secretRef); } catch { - return { - ok: true, - event: { - type: "action_result", - commandId: cmd.commandId, - ok: false, - error: "fill_secret: secret could not be resolved", - }, - }; + return { ok: true, event: this.unresolvableSecretResult(cmd.commandId) }; } const event = await this.coordinator.send({ @@ -232,6 +259,37 @@ export class SessionAgent extends Agent { } } + /** + * Whether `secretRef` lives in this session's own tenant namespace. The + * tenant is the one HMAC-signed into the sessionId (this.name) - the same + * authoritative source onConnect scopes the socket against - so it cannot be + * forged by a caller. Vault keys are canonically `vault:///` + * (README "Design decisions"). tenantOf only returns a `/`-free, non-empty + * tenant (auth.ts::isValidTenantId), so the trailing slash makes the prefix + * exact and unambiguous: tenant "acme" reaches neither "acme-corp"'s nor a + * hypothetical "acme/eu"'s keys. + */ + private async secretRefInTenant(secretRef: string): Promise { + const tenant = await tenantOf(this.name, this.env); + return tenant !== null && secretRef.startsWith(`vault://${tenant}/`); + } + + /** + * The one scrubbed ok:false a fill_secret returns when the secret cannot be + * produced - whether the ref is outside the caller's tenant, absent, or + * undecryptable. Byte-identical across those causes on purpose: the caller + * (and an attacker) learns only "could not be resolved", never which + * (DL-008), and no secret material appears in it (DL-004). + */ + private unresolvableSecretResult(commandId: string): Event { + return { + type: "action_result", + commandId, + ok: false, + error: "fill_secret: secret could not be resolved", + }; + } + /** * Maps the coordinator's prefixed rejections to the typed outcome union * IN-ISOLATE, so no expected failure ever crosses the RPC boundary as a @@ -278,17 +336,35 @@ export class SessionAgent extends Agent { return this.state.completedWrites ?? []; } + /** Records a handled page dialog (capped) for the GET /v1/sessions/:id surface. */ + private rememberDialog(ev: Extract): void { + // 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 next = [...this.dialogs(), record]; + while (next.length > RECENT_DIALOGS_CAP) next.shift(); + this.setState({ ...this.state, dialogs: next }); + } + + // Persisted before this field existed, a session's state can lack it. + private dialogs(): SessionState["dialogs"] { + return this.state.dialogs ?? []; + } + async getStatus(): Promise<{ status: SessionStatus; browser: SessionState["browser"]; tabs: SessionState["tabs"]; currentUrl: string | null; + dialogs: SessionState["dialogs"]; }> { return { status: this.state.status, browser: this.state.browser, tabs: this.state.tabs, currentUrl: this.state.currentUrl, + dialogs: this.dialogs(), }; } diff --git a/apps/backend/src/types.ts b/apps/backend/src/types.ts index f049aa4..5be43ec 100644 --- a/apps/backend/src/types.ts +++ b/apps/backend/src/types.ts @@ -8,7 +8,7 @@ * state have exactly one definition each. */ -import type { Event, TabInfo } from "@understudy/protocol"; +import type { DialogRecord, Event, TabInfo } from "@understudy/protocol"; import type { SessionAgent } from "./session"; /** @@ -18,6 +18,13 @@ import type { SessionAgent } from "./session"; */ type HelloBrowserInfo = Pick, "browser" | "extVersion">; +/** + * A page dialog the extension handled, as recorded in DO state and surfaced via + * GET /v1/sessions/:id. Re-exported from the protocol (DialogRecordSchema) so + * the DO-state shape and the wire `dialog` Event share exactly one definition. + */ +export type { DialogRecord }; + /** * The credential-vault seam that secrets.ts (M-007) resolves fill_secret's * secretRef through. Deliberately narrow (read-by-key only) and backend- @@ -93,6 +100,18 @@ export interface SessionState { * the DL-004 construction (fill_secret results carry ok/error only). */ completedWrites: { commandId: string; event: Event }[]; + /** + * Recent page dialogs the extension handled (alert/confirm/prompt/ + * beforeunload), oldest first, capped in session.ts. Surfaced to the consumer + * via GET /v1/sessions/:id so an agent/governance layer sees what a page said + * and how it was auto-answered. An after-the-fact record, not a response + * channel: dialogs are answered synchronously extension-side (an open dialog + * blocks the CDP channel), never by a consumer round-trip. BEST-EFFORT and + * capped: a report emitted while the WS is momentarily down is not replayed + * (the dialog is still answered; only its notification is lost), so this is an + * observability surface, not a guaranteed audit log. + */ + dialogs: DialogRecord[]; } /** diff --git a/apps/backend/test/auth.test.ts b/apps/backend/test/auth.test.ts index 2dd4d1e..6721b8a 100644 --- a/apps/backend/test/auth.test.ts +++ b/apps/backend/test/auth.test.ts @@ -1,9 +1,12 @@ import { describe, it, expect } from "vitest"; import type { Env } from "../src/types"; +import { base64urlEncode } from "../src/base64url"; import { authenticate, + isValidTenantId, mintSessionId, scopeSession, + tenantOf, verifyExtensionToken, type Actor, } from "../src/auth"; @@ -42,6 +45,26 @@ function tamperSessionId(sessionId: string, part: "payload" | "sig"): string { return part === "payload" ? `${flipChar(payload)}.${sig}` : `${payload}.${flipChar(sig)}`; } +/** + * Mirrors mintSessionId's signing but WITHOUT its isValidTenantId guard, to + * forge a genuinely valid-signature sessionId carrying a tenant mint would now + * refuse. This is the only way to reach tenantOf's shape re-check with a real + * signature (a legacy id from before the rule, or a hypothetical second signer) + * - a tampered id would fail the HMAC check first and never prove the re-check. + */ +async function forgeSessionId(tenantId: string, env: Env): Promise { + const payloadBytes = new TextEncoder().encode(JSON.stringify({ t: tenantId, n: "0".repeat(32) })); + const key = await crypto.subtle.importKey( + "raw", + new TextEncoder().encode(env.AUTH_HMAC_SECRET), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"], + ); + const sig = await crypto.subtle.sign("HMAC", key, payloadBytes); + return `${base64urlEncode(payloadBytes)}.${base64urlEncode(new Uint8Array(sig))}`; +} + describe("mintSessionId / scopeSession", () => { it("scopes a minted sessionId to its own tenant as ok", async () => { const env = makeEnv(); @@ -86,6 +109,69 @@ describe("mintSessionId / scopeSession", () => { expect(await scopeSession(first, "tenantA", env)).toBe("ok"); expect(await scopeSession(second, "tenantA", env)).toBe("ok"); }); + + it.each(["acme/eu", "", "/", "a/b"])( + "refuses to mint a sessionId for an unsafe tenantId %j (empty or slash-bearing would straddle the vault namespace)", + async (badTenant) => { + const env = makeEnv(); + await expect(mintSessionId(badTenant, env)).rejects.toThrow(/invalid tenantId/); + }, + ); +}); + +describe("isValidTenantId", () => { + it.each(["tenantA", "acme-corp", "a", "t_123"])("accepts a flat, non-empty slug %j", (t) => { + expect(isValidTenantId(t)).toBe(true); + }); + + it.each(["", "acme/eu", "/", "a/b/c"])( + "rejects an empty or slash-bearing tenantId %j - it must not straddle a vault:/// prefix", + (t) => { + expect(isValidTenantId(t)).toBe(false); + }, + ); +}); + +describe("tenantOf", () => { + it("returns the tenant a sessionId was minted for (the authoritative source a DO scopes on)", async () => { + const env = makeEnv(); + const sessionId = await mintSessionId("tenantA", env); + expect(await tenantOf(sessionId, env)).toBe("tenantA"); + }); + + it("returns null for a tampered signature - a forged id yields no tenant", async () => { + const env = makeEnv(); + const sessionId = await mintSessionId("tenantA", env); + expect(await tenantOf(tamperSessionId(sessionId, "sig"), env)).toBeNull(); + }); + + it("returns null for a tampered payload", async () => { + const env = makeEnv(); + const sessionId = await mintSessionId("tenantA", env); + expect(await tenantOf(tamperSessionId(sessionId, "payload"), env)).toBeNull(); + }); + + it.each(["", "garbage", "a.b.c"])( + "returns null for a malformed sessionId %j without throwing", + async (malformed) => { + const env = makeEnv(); + await expect(tenantOf(malformed, env)).resolves.toBeNull(); + }, + ); + + it("returns null for a VALIDLY-signed id carrying a slash-bearing tenant (the re-check mint cannot cover)", async () => { + // #given a genuinely HMAC-valid id whose tenant `mintSessionId` would refuse + // (forged around mint - stands in for a legacy id or an alternate signer) + const env = makeEnv(); + const forged = await forgeSessionId("acme/eu", env); + + // #then tenantOf rejects it via the isValidTenantId re-check, NOT an HMAC + // failure - proven by the control below, where the SAME forging path with a + // valid tenant is accepted (so the signature is genuinely good) + expect(await tenantOf(forged, env)).toBeNull(); + expect(await scopeSession(forged, "acme/eu", env)).toBe("not-found"); + expect(await tenantOf(await forgeSessionId("acme", env), env)).toBe("acme"); + }); }); describe("authenticate", () => { diff --git a/apps/backend/test/service.test.ts b/apps/backend/test/service.test.ts index 5df5bcd..d82939f 100644 --- a/apps/backend/test/service.test.ts +++ b/apps/backend/test/service.test.ts @@ -207,6 +207,7 @@ describe("GET /v1/sessions/:sessionId", () => { browser: null, tabs: [], currentUrl: null, + dialogs: [], }); }); @@ -285,7 +286,7 @@ describe("command round-trip via a live extension WebSocket", () => { 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 - await seedVault("vault://pw", "hunter2"); + await seedVault("vault://tenantA/pw", "hunter2"); const sessionId = await openSession(CALLER_TOKEN_A); const socket = await connectFakeExtension(sessionId); @@ -311,7 +312,7 @@ describe("fill_secret", () => { type: "fill_secret", commandId: "c2", ref: "s1e1", - secretRef: "vault://pw", + secretRef: "vault://tenantA/pw", submit: true, }); @@ -378,7 +379,7 @@ describe("fill_secret", () => { type: "fill_secret", commandId: "c3", ref: "s1e1", - secretRef: "vault://does-not-exist", + secretRef: "vault://tenantA/does-not-exist", }); // #then it resolves ok:false with a scrubbed error (no secret material) @@ -402,7 +403,7 @@ describe("fill_secret", () => { // #given a RAW (non-envelope) value written straight to KV, as a legacy // plaintext row or a value sealed under a rotated key would look at rest await (env.VAULT as unknown as { put(key: string, value: string): Promise }).put( - "vault://legacy-raw", + "vault://tenantA/legacy-raw", "legacy-plaintext-not-an-envelope", ); const sessionId = await openSession(CALLER_TOKEN_A); @@ -415,7 +416,7 @@ describe("fill_secret", () => { type: "fill_secret", commandId: "c-legacy", ref: "s1e1", - secretRef: "vault://legacy-raw", + secretRef: "vault://tenantA/legacy-raw", }); // #then EncryptedKvVault refuses to decrypt it -> the DO's catch returns @@ -472,7 +473,7 @@ describe("dryRun (DL-011: fail-safe, never dispatches a mutation or resolves a s it("dryRun fill_secret performs only a ref check, resolving no secret and typing nothing", async () => { // #given a seeded vault secret that must remain untouched, and a connected // fake extension whose ref map resolves nothing - await seedVault("vault://dry-pw", "should-not-be-read"); + await seedVault("vault://tenantA/dry-pw", "should-not-be-read"); const sessionId = await openSession(CALLER_TOKEN_A); const socket = await connectFakeExtension(sessionId); const messages = answerResolveRefsWith(socket, []); @@ -483,7 +484,7 @@ describe("dryRun (DL-011: fail-safe, never dispatches a mutation or resolves a s const res = await postCommand( sessionId, CALLER_TOKEN_A, - { type: "fill_secret", commandId: "c5", ref: "s1e1", secretRef: "vault://dry-pw" }, + { type: "fill_secret", commandId: "c5", ref: "s1e1", secretRef: "vault://tenantA/dry-pw" }, true, ); @@ -499,7 +500,7 @@ describe("dryRun (DL-011: fail-safe, never dispatches a mutation or resolves a s }); // #then the vault was never read for that secretRef, and nothing was ever typed - expect(vaultGetSpy).not.toHaveBeenCalledWith("vault://dry-pw"); + expect(vaultGetSpy).not.toHaveBeenCalledWith("vault://tenantA/dry-pw"); await new Promise((resolve) => setTimeout(resolve, 50)); expect(messages).toEqual([ { type: "resolve_ref", commandId: expect.any(String), ref: "s1e1" }, @@ -755,7 +756,7 @@ describe("extension liveness fail-fast", () => { it("refuses a real fill_secret on a disconnected session WITHOUT touching the vault", async () => { // #given a seeded secret and a session with no extension attached - await seedVault("vault://gated-pw", "must-stay-unread"); + await seedVault("vault://tenantA/gated-pw", "must-stay-unread"); const sessionId = await openSession(CALLER_TOKEN_A); const vaultGetSpy = vi.spyOn(env.VAULT, "get"); @@ -765,7 +766,7 @@ describe("extension liveness fail-fast", () => { type: "fill_secret", commandId: "c-fill-no-ext", ref: "s1e1", - secretRef: "vault://gated-pw", + secretRef: "vault://tenantA/gated-pw", }); // #then it is refused as 503 and the secret was NEVER resolved - no @@ -1113,7 +1114,7 @@ describe("idempotent write replay (stable commandId contract)", () => { it("a retried fill_secret replays the recorded result without touching the vault again", async () => { // #given a fill_secret that completed once - await seedVault("vault://replay-pw", "hunter2-replay"); + await seedVault("vault://tenantA/replay-pw", "hunter2-replay"); const sessionId = await openSession(CALLER_TOKEN_A); const socket = await connectFakeExtension(sessionId); @@ -1123,7 +1124,7 @@ describe("idempotent write replay (stable commandId contract)", () => { type: "fill_secret", commandId: "ik_case1:login:fill", ref: "s1e1", - secretRef: "vault://replay-pw", + secretRef: "vault://tenantA/replay-pw", }); await incoming; socket.send( @@ -1138,7 +1139,7 @@ describe("idempotent write replay (stable commandId contract)", () => { type: "fill_secret", commandId: "ik_case1:login:fill", ref: "s1e1", - secretRef: "vault://replay-pw", + secretRef: "vault://tenantA/replay-pw", }); // #then the recorded result is replayed with zero vault access and @@ -1231,3 +1232,382 @@ describe("idempotent write replay (stable commandId contract)", () => { return resPromise; } }); + +describe("two-tenant vault isolation (cross-tenant secretRef scoping, server-side)", () => { + // The command, status, and WS-upgrade isolation axes are already proven + // above ("refuses a cross-tenant sessionId as 404", the cross-tenant status + // 404, and the WS-gate "cross-tenant upgrade with 404"). This block covers + // the remaining axis: understudy owns ONE shared vault across tenants, so it - + // not a consumer's breakwater - must refuse tenantB resolving tenantA's + // secretRef, even from a session and extension that are legitimately tenantB's. + + it("refuses a cross-tenant secretRef: no vault read, no plaintext on the wire", async () => { + // #given tenantA's secret seeded, and tenantB driving its OWN session with + // its OWN connected extension - every step legitimate except the ref + await seedVault("vault://tenantA/okta-pw", "tenantA-super-secret"); + const sessionId = await openSession(CALLER_TOKEN_B); + const socket = await connectFakeExtension(sessionId, EXTENSION_TOKEN_B); + const received = collectCommands(socket); + const rawFrames: string[] = []; + socket.addEventListener("message", (event: MessageEvent) => rawFrames.push(event.data as string)); + const vaultGetSpy = vi.spyOn(env.VAULT, "get"); + + try { + // #when tenantB fill_secrets tenantA's ref into a field on its own tab + const res = await postCommand(sessionId, CALLER_TOKEN_B, { + type: "fill_secret", + commandId: "x-tenant", + ref: "s1e1", + secretRef: "vault://tenantA/okta-pw", + }); + + // #then it collapses to the SAME scrubbed ok:false an absent secret gets - + // tenantB cannot tell "not yours" from "does not exist" (DL-008) + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ + type: "action_result", + commandId: "x-tenant", + ok: false, + error: "fill_secret: secret could not be resolved", + }); + + // #then the vault was NEVER read - the tenant guard fires before + // resolution, so tenantA's plaintext never materializes (DL-004) + expect(vaultGetSpy).not.toHaveBeenCalled(); + + // #then nothing was ever dispatched to tenantB's extension: no `type` + // command carrying tenantA's secret reached the wire + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(received).toEqual([]); + expect(rawFrames.some((frame) => frame.includes("tenantA-super-secret"))).toBe(false); + } finally { + vaultGetSpy.mockRestore(); + socket.close(1000, "done"); + } + }); + + it("still resolves a session's OWN-tenant secretRef - the guard scopes, it does not block", async () => { + // #given tenantB's own secret seeded and tenantB's session + extension + await seedVault("vault://tenantB/okta-pw", "tenantB-own-secret"); + const sessionId = await openSession(CALLER_TOKEN_B); + const socket = await connectFakeExtension(sessionId, EXTENSION_TOKEN_B); + + try { + const incoming = waitForCommand(socket); + + // #when tenantB fill_secrets its OWN ref + const commandRes = postCommand(sessionId, CALLER_TOKEN_B, { + type: "fill_secret", + commandId: "own-tenant", + ref: "s1e1", + secretRef: "vault://tenantB/okta-pw", + submit: true, + }); + + // #then the resolved secret is typed via tenantB's extension under the + // same commandId - own-tenant resolution is unaffected by the guard + expect(await incoming).toEqual({ + type: "type", + commandId: "own-tenant", + ref: "s1e1", + text: "tenantB-own-secret", + submit: true, + }); + socket.send(JSON.stringify({ type: "action_result", commandId: "own-tenant", ok: true })); + + const res = await commandRes; + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ type: "action_result", commandId: "own-tenant", ok: true }); + } finally { + socket.close(1000, "done"); + } + }); + + it("refuses an unscoped (tenant-less) secretRef even for the owning tenant - scoping is mandatory", async () => { + // #given a bare, tenant-less ref seeded (the sloppy vault:// shape + // the fix outlaws), referenced by its own tenant + await seedVault("vault://legacy-unscoped", "would-have-leaked"); + const sessionId = await openSession(CALLER_TOKEN_A); + const socket = await connectFakeExtension(sessionId, EXTENSION_TOKEN_A); + const received = collectCommands(socket); + const vaultGetSpy = vi.spyOn(env.VAULT, "get"); + + try { + // #when the owning tenant references it WITHOUT the vault:/// prefix + const res = await postCommand(sessionId, CALLER_TOKEN_A, { + type: "fill_secret", + commandId: "unscoped", + ref: "s1e1", + secretRef: "vault://legacy-unscoped", + }); + + // #then it is refused (scrubbed) with no vault read and nothing typed: + // tenant scoping is enforced, not merely conventional + expect(await res.json()).toEqual({ + type: "action_result", + commandId: "unscoped", + ok: false, + error: "fill_secret: secret could not be resolved", + }); + expect(vaultGetSpy).not.toHaveBeenCalled(); + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(received).toEqual([]); + } finally { + vaultGetSpy.mockRestore(); + socket.close(1000, "done"); + } + }); + + it("refuses a cross-tenant secretRef before replay - a reused commandId cannot serve a cached own-tenant result", async () => { + // #given tenantB completed a legitimate OWN-tenant fill under a commandId + // (caching an ok:true write result), and tenantA's secret is also seeded + await seedVault("vault://tenantB/own-pw", "tenantB-own"); + await seedVault("vault://tenantA/okta-pw", "tenantA-super-secret"); + const sessionId = await openSession(CALLER_TOKEN_B); + const socket = await connectFakeExtension(sessionId, EXTENSION_TOKEN_B); + + try { + const incoming = waitForCommand(socket); + const firstRes = postCommand(sessionId, CALLER_TOKEN_B, { + type: "fill_secret", + commandId: "ik_shared:fill", + ref: "s1e1", + secretRef: "vault://tenantB/own-pw", + }); + await incoming; + socket.send(JSON.stringify({ type: "action_result", commandId: "ik_shared:fill", ok: true })); + expect(await (await firstRes).json()).toEqual({ + type: "action_result", + commandId: "ik_shared:fill", + ok: true, + }); + + // #when the SAME commandId is retried with tenantA's cross-tenant ref + const vaultGetSpy = vi.spyOn(env.VAULT, "get"); + try { + const res = await postCommand(sessionId, CALLER_TOKEN_B, { + type: "fill_secret", + commandId: "ik_shared:fill", + ref: "s1e1", + secretRef: "vault://tenantA/okta-pw", + }); + + // #then the guard (which runs BEFORE replay) refuses it: the cached + // ok:true is NOT served, and tenantA's vault is never read + expect(await res.json()).toEqual({ + type: "action_result", + commandId: "ik_shared:fill", + ok: false, + error: "fill_secret: secret could not be resolved", + }); + expect(vaultGetSpy).not.toHaveBeenCalled(); + } finally { + vaultGetSpy.mockRestore(); + } + } finally { + socket.close(1000, "done"); + } + }); + + it("refuses confusable/edge-shape refs before any vault read - the trailing slash makes the prefix exact", async () => { + // #given a tenantA session + extension (own tenant is "tenantA") + const sessionId = await openSession(CALLER_TOKEN_A); + const socket = await connectFakeExtension(sessionId, EXTENSION_TOKEN_A); + const received = collectCommands(socket); + const vaultGetSpy = vi.spyOn(env.VAULT, "get"); + + try { + // #when refs that look tenant-adjacent but escape the `vault://tenantA/` + // prefix are posted: no trailing slash, and a longer confusable tenant + for (const secretRef of ["vault://tenantA", "vault://tenantAB/pw"]) { + const res = await postCommand(sessionId, CALLER_TOKEN_A, { + type: "fill_secret", + commandId: `edge-${secretRef}`, + ref: "s1e1", + secretRef, + }); + + // #then each is refused (scrubbed) and never reaches the vault + expect(await res.json()).toEqual({ + type: "action_result", + commandId: `edge-${secretRef}`, + ok: false, + error: "fill_secret: secret could not be resolved", + }); + } + + expect(vaultGetSpy).not.toHaveBeenCalled(); + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(received).toEqual([]); + } finally { + vaultGetSpy.mockRestore(); + socket.close(1000, "done"); + } + }); + + it("refuses a cross-tenant secretRef even with NO extension connected - the guard precedes the liveness gate", async () => { + // #given tenantB's session with NO extension attached (would 503 at the gate) + await seedVault("vault://tenantA/okta-pw", "tenantA-super-secret"); + const sessionId = await openSession(CALLER_TOKEN_B); + const vaultGetSpy = vi.spyOn(env.VAULT, "get"); + + try { + // #when tenantB posts a cross-tenant fill on the disconnected session + const res = await postCommand(sessionId, CALLER_TOKEN_B, { + type: "fill_secret", + commandId: "x-tenant-no-ext", + ref: "s1e1", + secretRef: "vault://tenantA/okta-pw", + }); + + // #then the tenant guard answers first: a scrubbed 200 ok:false, NOT the + // 503 the connection gate would give - and no vault read. The refusal is + // a pure function of (own tenant, ref), independent of liveness, so the + // 200-vs-503 status leaks no cross-tenant existence signal. + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ + type: "action_result", + commandId: "x-tenant-no-ext", + ok: false, + error: "fill_secret: secret could not be resolved", + }); + expect(vaultGetSpy).not.toHaveBeenCalled(); + } finally { + vaultGetSpy.mockRestore(); + } + }); + + it("dryRun previews the cross-tenant refusal - simulated ok:false, no probe, no vault read", async () => { + // #given tenantB's session + extension, with tenantA's secret seeded + await seedVault("vault://tenantA/okta-pw", "tenantA-super-secret"); + const sessionId = await openSession(CALLER_TOKEN_B); + const socket = await connectFakeExtension(sessionId, EXTENSION_TOKEN_B); + const received = collectCommands(socket); + const vaultGetSpy = vi.spyOn(env.VAULT, "get"); + + try { + // #when tenantB DRY-RUNs a cross-tenant fill_secret (governance preview) + const res = await postCommand( + sessionId, + CALLER_TOKEN_B, + { type: "fill_secret", commandId: "x-dry", ref: "s1e1", secretRef: "vault://tenantA/okta-pw" }, + true, + ); + + // #then the simulation honestly previews the refusal the real call would + // give (simulated ok:false), sends NO resolve_ref probe to the extension, + // and never reads the vault - dryRun and real agree on the tenant axis + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ + type: "action_result", + commandId: "x-dry", + ok: false, + error: "dry-run: secret could not be resolved", + simulated: true, + }); + expect(vaultGetSpy).not.toHaveBeenCalled(); + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(received).toEqual([]); + } finally { + vaultGetSpy.mockRestore(); + socket.close(1000, "done"); + } + }); +}); + +describe("dialog surfacing (extension → DO state → GET /v1/sessions/:id)", () => { + /** Bounded poll until the session has recorded at least `n` dialogs. */ + async function waitForDialogs(sessionId: string, n: number): Promise { + const stub = await getSessionStub(sessionId); + for (let i = 0; i < 100; i++) { + if ((await stub.getStatus()).dialogs.length >= n) return; + await new Promise((resolve) => setTimeout(resolve, 20)); + } + throw new Error(`session never accumulated ${n} dialog(s)`); + } + + it("records a handled dialog and returns it verbatim in the session status", async () => { + // #given a connected fake extension + const sessionId = await openSession(CALLER_TOKEN_A); + const socket = await connectFakeExtension(sessionId, EXTENSION_TOKEN_A); + + try { + // #when the extension reports a dialog it handled locally (as the real + // one does right after answering it) + socket.send( + JSON.stringify({ + type: "dialog", + tabId: 3, + dialogType: "confirm", + message: "Delete this item?", + url: "https://portal.example/items/1", + disposition: "dismiss", + }), + ); + + // #then it surfaces verbatim in the owning tenant's session status + await waitForDialogs(sessionId, 1); + const res = await exports.default.fetch( + authedRequest(`/v1/sessions/${sessionId}`, CALLER_TOKEN_A), + ); + const status = (await res.json()) as { dialogs: unknown[] }; + expect(status.dialogs).toEqual([ + { + tabId: 3, + dialogType: "confirm", + message: "Delete this item?", + url: "https://portal.example/items/1", + disposition: "dismiss", + }, + ]); + } finally { + socket.close(1000, "done"); + } + }); + + it("keeps a prompt's defaultPrompt and preserves arrival order across dialogs", async () => { + // #given a connected fake extension + const sessionId = await openSession(CALLER_TOKEN_A); + const socket = await connectFakeExtension(sessionId, EXTENSION_TOKEN_A); + + try { + // #when two dialogs are reported in order (an alert then a prompt) + socket.send( + JSON.stringify({ + type: "dialog", + tabId: 1, + dialogType: "alert", + message: "Saved", + url: "https://x/", + disposition: "accept", + }), + ); + socket.send( + JSON.stringify({ + type: "dialog", + tabId: 1, + dialogType: "prompt", + message: "Name?", + url: "https://x/", + defaultPrompt: "guest", + disposition: "dismiss", + }), + ); + + // #then both surface oldest-first; the prompt keeps its defaultPrompt and + // the alert (which had none) omits the field entirely + await waitForDialogs(sessionId, 2); + const res = await exports.default.fetch( + authedRequest(`/v1/sessions/${sessionId}`, CALLER_TOKEN_A), + ); + const status = (await res.json()) as { + dialogs: Array<{ dialogType: string; defaultPrompt?: string }>; + }; + expect(status.dialogs.map((d) => d.dialogType)).toEqual(["alert", "prompt"]); + expect(status.dialogs[1]).toMatchObject({ dialogType: "prompt", defaultPrompt: "guest" }); + expect(status.dialogs[0]).not.toHaveProperty("defaultPrompt"); + } finally { + socket.close(1000, "done"); + } + }); +}); diff --git a/apps/backend/test/session.test.ts b/apps/backend/test/session.test.ts index f648ed2..ec565a7 100644 --- a/apps/backend/test/session.test.ts +++ b/apps/backend/test/session.test.ts @@ -282,3 +282,48 @@ describe("hello resync", () => { }); }); }); + +describe("dialog recording (onMessage → SessionState.dialogs)", () => { + function dialogEvent(message: string): string { + return JSON.stringify({ + type: "dialog", + tabId: 1, + dialogType: "alert", + message, + url: "https://x/", + disposition: "accept", + }); + } + + it("caps recent dialogs at 50, evicting oldest-first", async () => { + // #given a session that handled 51 dialogs + const sessionId = crypto.randomUUID(); + const stub = await getSessionStub(sessionId); + await runInDurableObject(stub, async (instance: SessionAgent) => { + for (let i = 0; i < 51; i++) { + await instance.onMessage(FAKE_CONNECTION, dialogEvent(`d${i}`)); + } + }); + + // #then only the most recent 50 remain, oldest (d0) evicted, order preserved + const status = await stub.getStatus(); + expect(status.dialogs).toHaveLength(50); + expect(status.dialogs[0]?.message).toBe("d1"); + expect(status.dialogs[49]?.message).toBe("d50"); + }); + + it("ignores a dialog event from an unauthorized connection", async () => { + // #given a fresh session and a connection that never passed onConnect's auth + const sessionId = crypto.randomUUID(); + const stub = await getSessionStub(sessionId); + const { connection: unauthorized } = fakeConnection(null); + + // #when that connection sends a dialog event + await runInDurableObject(stub, async (instance: SessionAgent) => { + await instance.onMessage(unauthorized, dialogEvent("spoofed")); + }); + + // #then nothing is recorded - onMessage's auth gate drops it before the switch + expect((await stub.getStatus()).dialogs).toEqual([]); + }); +}); diff --git a/apps/extension/CLAUDE.md b/apps/extension/CLAUDE.md index 418aa8f..6e1d1a6 100644 --- a/apps/extension/CLAUDE.md +++ b/apps/extension/CLAUDE.md @@ -22,7 +22,7 @@ WXT + React MV3 extension driving protocol Commands into a real Chromium tab via | `src/driver/keymap.ts` | `parseKeys` — key-spec string → CDP `Input.dispatchKeyEvent` fields | Adding a named key or modifier alias | | `src/driver/keymap.test.ts` | Unit tests for `parseKeys` (modifiers, named keys, printable chars) | Verifying key-spec parsing | | `src/driver/cdp-events.ts` | `classifyCdpEvent` — raw CDP event → effects decision (`CdpDecision`) | Handling a new CDP event type, changing navigation/dialog handling | -| `src/driver/cdp-events.test.ts` | Unit tests for `classifyCdpEvent` (main-frame filter, load URL, generation bumps, dialog dismiss) | Verifying CDP event classification | +| `src/driver/cdp-events.test.ts` | Unit tests for `classifyCdpEvent` + `dialogDisposition` (main-frame filter, load URL, generation bumps, per-type dialog disposition) | Verifying CDP event classification | | `src/driver/cdp.ts` | `CdpSession` — FIFO-queued `chrome.debugger` channel; executors for every protocol Command (`snapshotA11y`, `screenshot`, `click`, `type`, `key`, `scroll`, `wait`, `navigate`, `resolveRefCheck`) | Adding/changing a command executor, debugging a CDP call | | `src/driver/cdp.test.ts` | Unit tests for `resolveRefCheck` — the dry-run probe's no-snapshot/no-generation-bump invariant | Changing resolveRefCheck or the ref/generation model | | `src/core/ws-client.ts` | `ReconnectingWs` — WebSocket with backoff reconnect and self-driven pong heartbeat | Changing reconnect/backoff/heartbeat behavior | diff --git a/apps/extension/src/driver/cdp-events.test.ts b/apps/extension/src/driver/cdp-events.test.ts index 47e14bf..f9657ff 100644 --- a/apps/extension/src/driver/cdp-events.test.ts +++ b/apps/extension/src/driver/cdp-events.test.ts @@ -1,5 +1,6 @@ -import { describe, it, expect } from "vitest"; -import { classifyCdpEvent } from "./cdp-events"; +import { describe, it, expect, vi } from "vitest"; +import { applyDialogDecision, classifyCdpEvent, dialogDisposition } from "./cdp-events"; +import type { DialogEventFields } from "./cdp-events"; const ctx = { currentUrl: "https://example.com/current" }; @@ -35,16 +36,135 @@ describe("classifyCdpEvent", () => { expect(classifyCdpEvent("DOM.documentUpdated", {}, ctx)).toEqual({ bumpGeneration: true }); }); - it("dismisses a javascript dialog", () => { + it("accepts an alert dialog and reports it (single OK button - just close the info box)", () => { const decision = classifyCdpEvent( "Page.javascriptDialogOpening", - { url: "https://example.com", message: "hi", type: "alert" }, + { url: "https://example.com/", message: "Saved", type: "alert" }, ctx, ); - expect(decision).toEqual({ dismissDialog: true }); + expect(decision).toEqual({ + dialog: { + accept: true, + event: { + dialogType: "alert", + message: "Saved", + url: "https://example.com/", + disposition: "accept", + }, + }, + }); + }); + + it("dismisses a confirm dialog and reports it (never auto-confirm a possibly-destructive prompt)", () => { + const decision = classifyCdpEvent( + "Page.javascriptDialogOpening", + { url: "https://example.com/", message: "Delete this item?", type: "confirm" }, + ctx, + ); + expect(decision).toEqual({ + dialog: { + accept: false, + event: { + dialogType: "confirm", + message: "Delete this item?", + url: "https://example.com/", + disposition: "dismiss", + }, + }, + }); + }); + + it("dismisses a prompt dialog, carrying its defaultPrompt through to the consumer", () => { + const decision = classifyCdpEvent( + "Page.javascriptDialogOpening", + { url: "https://example.com/", message: "Your name?", type: "prompt", defaultPrompt: "guest" }, + ctx, + ); + expect(decision).toEqual({ + dialog: { + accept: false, + event: { + dialogType: "prompt", + message: "Your name?", + url: "https://example.com/", + defaultPrompt: "guest", + disposition: "dismiss", + }, + }, + }); + }); + + it("accepts a beforeunload dialog so navigation proceeds (a dismiss would cancel it)", () => { + const decision = classifyCdpEvent( + "Page.javascriptDialogOpening", + { url: "https://example.com/", message: "", type: "beforeunload" }, + ctx, + ); + expect(decision).toEqual({ + dialog: { + accept: true, + event: { + dialogType: "beforeunload", + message: "", + url: "https://example.com/", + disposition: "accept", + }, + }, + }); + }); + + it("dismisses an unclassifiable dialog type without emitting an event (the channel must still be freed)", () => { + const decision = classifyCdpEvent( + "Page.javascriptDialogOpening", + { url: "https://example.com/", message: "?", type: "not-a-real-type" }, + ctx, + ); + expect(decision).toEqual({ dialog: { accept: false } }); }); it("returns an empty decision for an unrelated method", () => { expect(classifyCdpEvent("Runtime.consoleAPICalled", { type: "log" }, ctx)).toEqual({}); }); }); + +describe("dialogDisposition", () => { + it("accepts informational/navigational dialogs, dismisses intent-carrying ones", () => { + expect(dialogDisposition("alert")).toBe("accept"); + expect(dialogDisposition("beforeunload")).toBe("accept"); + expect(dialogDisposition("confirm")).toBe("dismiss"); + expect(dialogDisposition("prompt")).toBe("dismiss"); + }); +}); + +describe("applyDialogDecision", () => { + it("answers the dialog BEFORE reporting it (the channel is freed first)", async () => { + const calls: string[] = []; + const answer = vi.fn(async (accept: boolean) => { + calls.push(`answer:${accept}`); + }); + const report = vi.fn((event: DialogEventFields) => { + calls.push(`report:${event.dialogType}`); + }); + + await applyDialogDecision( + { + accept: false, + event: { dialogType: "confirm", message: "?", url: "https://x/", disposition: "dismiss" }, + }, + answer, + report, + ); + + expect(calls).toEqual(["answer:false", "report:confirm"]); + }); + + it("answers an unclassifiable dialog (no event) and reports nothing - channel still freed", async () => { + const answer = vi.fn(async () => {}); + const report = vi.fn(); + + await applyDialogDecision({ accept: false }, answer, report); + + expect(answer).toHaveBeenCalledWith(false); + expect(report).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/extension/src/driver/cdp-events.ts b/apps/extension/src/driver/cdp-events.ts index 11ca54b..d9dad5a 100644 --- a/apps/extension/src/driver/cdp-events.ts +++ b/apps/extension/src/driver/cdp-events.ts @@ -1,4 +1,11 @@ import type { Protocol } from "devtools-protocol"; +import { DialogTypeSchema } from "@understudy/protocol"; +import type { DialogDisposition, DialogRecord, DialogType } from "@understudy/protocol"; + +// 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; // 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 @@ -7,7 +14,31 @@ export interface CdpDecision { bumpGeneration?: boolean; pageEvent?: { kind: "navigated" | "load"; url: string }; newUrl?: string; - dismissDialog?: boolean; + // A page dialog to answer locally, and optionally report. `accept` is how to + // answer Page.handleJavaScriptDialog and is ALWAYS set when present, so an + // open dialog never wedges the single CDP channel. `event` is the protocol + // payload forwarded to the consumer, omitted only for a dialog whose type we + // could not classify (not expected - CDP's DialogType is a closed enum). + dialog?: { accept: boolean; event?: DialogEventFields }; +} + +// Type-aware default disposition, applied synchronously (an open dialog blocks +// the single CDP channel, so we cannot wait for the consumer to decide): +// - alert: accept (a single OK button; just close the info box) +// - beforeunload: accept (proceed with navigation - a dismiss would CANCEL +// the navigation the automation issued, wedging it) +// - confirm: dismiss (Cancel - never auto-confirm a possibly-destructive +// "Are you sure?") +// - prompt: dismiss (Cancel - never inject text into a page field) +export function dialogDisposition(dialogType: DialogType): DialogDisposition { + switch (dialogType) { + case "alert": + case "beforeunload": + return "accept"; + case "confirm": + case "prompt": + return "dismiss"; + } } // Narrow a raw CDP payload to Page.frameNavigated, or null when it is not shaped @@ -20,6 +51,26 @@ function asFrameNavigated(params: unknown): Protocol.Page.FrameNavigatedEvent | return params as Protocol.Page.FrameNavigatedEvent; } +// Narrow a raw Page.javascriptDialogOpening payload to a KNOWN dialog type, or +// null otherwise. DialogTypeSchema is the protocol's source of truth for the +// four types we model; anything else (not expected from chrome.debugger) is +// unclassifiable, and the caller still dismisses it so the channel is freed. +function asDialogOpening(params: unknown): Omit | null { + if (typeof params !== "object" || params === null) return null; + const p = params as { type?: unknown; message?: unknown; url?: unknown; defaultPrompt?: unknown }; + const parsed = DialogTypeSchema.safeParse(p.type); + if (!parsed.success) return null; + return { + dialogType: parsed.data, + message: typeof p.message === "string" ? p.message : "", + url: typeof p.url === "string" ? p.url : "", + defaultPrompt: + typeof p.defaultPrompt === "string" && p.defaultPrompt.length > 0 + ? p.defaultPrompt + : undefined, + }; +} + export function classifyCdpEvent( method: string, params: unknown, @@ -39,9 +90,32 @@ export function classifyCdpEvent( case "DOM.documentUpdated": // A SPA DOM swap invalidates every outstanding ref, so bump the generation. return { bumpGeneration: true }; - case "Page.javascriptDialogOpening": - return { dismissDialog: true }; + case "Page.javascriptDialogOpening": { + const opening = asDialogOpening(params); + // Unclassifiable dialog (not expected - DialogType is a closed CDP enum): + // still dismiss it so it cannot wedge the CDP channel, but emit no event. + if (opening === null) return { dialog: { accept: false } }; + const disposition = dialogDisposition(opening.dialogType); + return { + dialog: { accept: disposition === "accept", event: { ...opening, disposition } }, + }; + } default: return {}; } } + +// Answer a dialog, then report it. Extracted from the background worker's +// onCdpEvent so the ordering guarantee is directly testable: `answer` is awaited +// FIRST and unconditionally, so an open dialog can never wedge the single CDP +// channel even when the report path is a no-op (WS down) or there is no event to +// emit (an unclassifiable dialog type). `report` runs only for a classifiable +// dialog and never blocks the answer. +export async function applyDialogDecision( + dialog: NonNullable, + answer: (accept: boolean) => Promise, + report: (event: DialogEventFields) => void, +): Promise { + await answer(dialog.accept); + if (dialog.event !== undefined) report(dialog.event); +} diff --git a/apps/extension/src/entrypoints/background.ts b/apps/extension/src/entrypoints/background.ts index 7a9774f..17c2e3c 100644 --- a/apps/extension/src/entrypoints/background.ts +++ b/apps/extension/src/entrypoints/background.ts @@ -4,7 +4,7 @@ import { ReconnectingWs } from "../core/ws-client"; import { WriteDedupe } from "../core/dedupe"; import { routeCommand } from "../core/router"; import { CdpSession } from "../driver/cdp"; -import { classifyCdpEvent } from "../driver/cdp-events"; +import { applyDialogDecision, classifyCdpEvent } from "../driver/cdp-events"; import { errorMessage } from "../events"; import { queryTabInfos } from "../tabs"; import type { @@ -230,10 +230,21 @@ async function onCdpEvent( url: decision.pageEvent.url, }); } - if (decision.dismissDialog === true) { - // Auto-dismiss so a page dialog does not wedge the single CDP channel. - await active.send("Page.handleJavaScriptDialog", { accept: false }); - log("auto-dismissed page dialog"); + 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) => ws?.send({ type: "dialog", tabId: active.tabId, ...event }), + ); + log( + `handled ${decision.dialog.event?.dialogType ?? "unknown"} dialog: ${ + decision.dialog.accept ? "accept" : "dismiss" + }`, + ); } } catch (cause) { log(`cdp event (${method}) failed: ${errorMessage(cause)}`, "error"); diff --git a/docs/technical-plan.md b/docs/technical-plan.md index 30c2915..3455ae5 100644 --- a/docs/technical-plan.md +++ b/docs/technical-plan.md @@ -381,8 +381,13 @@ auth, local `fill_secret` shim) and carries stale pre-Topology-1 prose; prefer t injection boundary (system-prompt "page text is data not instructions" + breakwater policy + origin allowlist) is enforced **consumer-side**; understudy reports page content faithfully. Document as a shared residual risk. -6. **No dialogs**: automation avoids `alert`/`confirm`/`prompt` (they block the CDP channel); if a page - raises one, handle via CDP `Page.handleJavaScriptDialog`. +6. **Dialogs**: a page `alert`/`confirm`/`prompt`/`beforeunload` blocks the single CDP channel, so the + extension answers it locally and synchronously via `Page.handleJavaScriptDialog` with a type-aware + disposition (alert/beforeunload accept, confirm/prompt dismiss) and reports it to the consumer as a + **best-effort** `dialog` Event (recorded in DO state, read via `GET /v1/sessions/:id`; a report lost + during a WS drop is not replayed). **Residual risk**: while `chrome.debugger` is attached, a + `beforeunload` is auto-accepted so the automation's own navigation proceeds — a human co-driver's + unsaved-changes guard is thereby proceeded through; `confirm`/`prompt` are dismissed, never auto-confirmed. ## Out of scope for understudy v1 (looks in-scope; intentionally excluded) @@ -438,8 +443,15 @@ auth, local `fill_secret` shim) and carries stale pre-Topology-1 prose; prefer t replay (see above); onClose status stamping gated on authorization; **first real deploy** to `https://understudy-backend.gcharang.workers.dev` with real minted secrets (runbook in `apps/backend/README.md` "Deploy") — live smoke: health, 401, mint, fail-fast 503, WS-gate 401, - encrypted vault seed all verified. Still open under M5: two-tenant isolation e2e, dialog - handling breadth, session/GIF audit logging. + encrypted vault seed all verified. **Two-tenant isolation e2e LANDED + (2026-07-17):** closing it surfaced a real cross-tenant vault-read gap — + `fillSecret` resolved any caller-supplied `secretRef` with no tenant scoping, so + tenantB (driving its own session) could exfiltrate `vault://tenantA/…` plaintext. + Fixed server-side (`auth.ts::tenantOf` + a `vault:///…` namespace guard + in `fillSecret`, before any vault read; scrubbed `ok:false`, no existence oracle); + proven by `test/service.test.ts` "two-tenant vault isolation" (session/status/WS + axes were already covered). Still open under M5: dialog handling breadth, + session/GIF audit logging. - **M6 — Ops.** Rate/quotas at the service edge, observability, unattended-session seam scoping. ## Verification diff --git a/packages/connector/src/index.test.ts b/packages/connector/src/index.test.ts index 6ab6cb3..09caf50 100644 --- a/packages/connector/src/index.test.ts +++ b/packages/connector/src/index.test.ts @@ -205,6 +205,43 @@ describe("observe (read - no grant, no idempotency)", () => { observeRead({ type: "get_tabs" }, { type: "action_result", commandId: "c1", ok: true }), ).rejects.toThrow(/unexpected event 'action_result' for read 'get_tabs'/); }); + + it("get_dialogs returns the recent dialogs from the session status", async () => { + const dialogs = [ + { + tabId: 3, + dialogType: "confirm", + message: "Delete this item?", + url: "https://portal.example/items/1", + disposition: "dismiss", + }, + ]; + // get_dialogs reads a status object from GET /v1/sessions/:id, not a command Event + const out = await observeRead( + { type: "get_dialogs" }, + { status: "connected", browser: null, tabs: [], currentUrl: null, dialogs }, + ); + expect(out).toEqual({ dialogs }); + }); + + it("get_dialogs issues a bearer-authed GET to /v1/sessions/:id with no command body", async () => { + const fetchSpy = vi + .fn() + .mockResolvedValue(eventResponse({ status: "pending", tabs: [], dialogs: [] })); + vi.stubGlobal("fetch", fetchSpy); + const { observe } = createBrowserConnectors(ENV, stores()); + + await callConnector(observe, { sessionId: "s-1", read: { type: "get_dialogs" } }, undefined); + + const [url, init] = fetchSpy.mock.calls[0] as [ + string, + { method?: string; headers: Record; body?: unknown }, + ]; + expect(url).toBe("https://understudy.example.com/v1/sessions/s-1"); + expect(init.method).toBe("GET"); + expect(init.headers.authorization).toBe("Bearer caller-token-1"); + expect(init.body).toBeUndefined(); + }); }); describe("fill_credential (vaulted write)", () => { @@ -287,6 +324,29 @@ describe("service bridge hardening", () => { ).rejects.toThrow(); }); + it("get_dialogs throws status-only on an HTTP error - the status body is never echoed", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue(eventResponse({ error: "unauthorized", detail: "S3CRET-DETAIL" }, 401)), + ); + const { observe } = createBrowserConnectors(ENV, stores()); + + const failure = callConnector(observe, { sessionId: "s-1", read: { type: "get_dialogs" } }, undefined); + await expect(failure).rejects.toThrow(/understudy service 401 for session s-1/); + await expect(failure).rejects.not.toThrow(/S3CRET-DETAIL/); + }); + + it("get_dialogs rejects a status payload missing the dialogs array (older deploy) - fails loud", async () => { + // A 200 status without `dialogs` fails the schema rather than silently + // returning undefined, mirroring the command path's parseEvent discipline. + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(eventResponse({ status: "connected", tabs: [] }))); + const { observe } = createBrowserConnectors(ENV, stores()); + + await expect( + callConnector(observe, { sessionId: "s-1", read: { type: "get_dialogs" } }, undefined), + ).rejects.toThrow(/unparseable status for session s-1/); + }); + it("denies the 61st act execution in a fixed rate window", async () => { vi.useFakeTimers(); vi.setSystemTime(60_000); // window start - all calls land in one fixed window diff --git a/packages/connector/src/index.ts b/packages/connector/src/index.ts index 18b890a..484bef1 100644 --- a/packages/connector/src/index.ts +++ b/packages/connector/src/index.ts @@ -35,6 +35,8 @@ import { import { A11yNodeSchema, type Command, + type DialogRecord, + DialogRecordSchema, type Event, parseCommand, parseEvent, @@ -122,6 +124,10 @@ export const observeInput = z.object({ for: z.enum(["load", "idle", "ms"]), value: z.number().optional(), }), + // Recent page dialogs the browser auto-handled - read from the session + // status (GET), not a command: dialogs are answered extension-side, this + // just reports what happened. + z.object({ type: z.literal("get_dialogs") }), ]), }); export type ObserveInput = z.infer; @@ -132,6 +138,8 @@ export const observeOutput = z.object({ /** Vision fallback (canvas / visual layout) - an evidence artifact. */ screenshot: z.object({ mime: z.string(), b64: z.string() }).optional(), tabs: z.array(TabInfoSchema).optional(), + /** Recent page dialogs (get_dialogs), oldest first. */ + dialogs: z.array(DialogRecordSchema).optional(), /** wait outcome. */ ok: z.boolean().optional(), error: z.string().optional(), @@ -227,6 +235,31 @@ async function callUnderstudy( return parseEvent(await res.json()); } +/** + * GET the session status through the same egress-guarded runtime.fetch and + * return the recent dialogs the service recorded. A pure read - no command, no + * idempotency key, no approval. + */ +async function getSessionStatus( + runtime: ConnectorRuntime, + env: BrowserConnectorEnv, + sessionId: string, +): Promise<{ dialogs: DialogRecord[] }> { + const base = env.UNDERSTUDY_URL.replace(/\/$/, ""); + const res = await runtime.fetch(`${base}/v1/sessions/${encodeURIComponent(sessionId)}`, { + method: "GET", + headers: { authorization: `Bearer ${env.UNDERSTUDY_TOKEN}` }, + }); + if (!res.ok) { + throw new Error(`understudy service ${res.status} for session ${sessionId}`); + } + const parsed = z.object({ dialogs: z.array(DialogRecordSchema) }).safeParse(await res.json()); + if (!parsed.success) { + throw new Error(`understudy service returned an unparseable status for session ${sessionId}`); + } + return { dialogs: parsed.data.dialogs }; +} + // Writes pass a stable commandId derived from the breakwater idempotency // key; everything else gets a random UUID. breakwater replays a COMPLETED // key without re-running execute(), but a failed attempt (response lost or @@ -285,7 +318,7 @@ export function createBrowserConnectors( const observe = createConnector({ id: BROWSER_OBSERVE_CONNECTOR, description: - "Observe the browser session: a11y-tree/DOM/screenshot snapshot of the page (target elements by the returned refs), list open tabs, or wait for load/idle/ms. Read-only.", + "Observe the browser session: a11y-tree/DOM/screenshot snapshot of the page (target elements by the returned refs), list open tabs, read recent page dialogs the browser auto-handled, or wait for load/idle/ms. Read-only.", inputSchema: observeInput, outputSchema: observeOutput, permissions: { sideEffect: "read", egress }, @@ -293,6 +326,10 @@ export function createBrowserConnectors( execute: async (input, _ctx, runtime): Promise => { const read = input.read; + // Dialogs come from the session status (GET), not a dispatched command. + if (read.type === "get_dialogs") { + return await getSessionStatus(runtime, env, input.sessionId); + } const ev = await callUnderstudy(runtime, env, input.sessionId, toCommand(read)); switch (read.type) { case "snapshot": diff --git a/packages/protocol/src/index.test.ts b/packages/protocol/src/index.test.ts index 9ea8395..8708b02 100644 --- a/packages/protocol/src/index.test.ts +++ b/packages/protocol/src/index.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect } from "vitest"; import { A11yNodeSchema, CommandSchema, + DialogRecordSchema, EventSchema, isWriteCommand, parseCommand, @@ -155,6 +156,77 @@ describe("EventSchema", () => { it("rejects a tabs_result missing tabs", () => { expect(safeParseEvent({ type: "tabs_result", commandId: "c1" }).success).toBe(false); }); + + it("round-trips a dialog event with a defaultPrompt", () => { + const ev = { + type: "dialog", + tabId: 7, + dialogType: "prompt", + message: "Enter your name", + url: "https://example.com/", + defaultPrompt: "guest", + disposition: "dismiss", + }; + expect(EventSchema.parse(ev)).toEqual(ev); + }); + + it("round-trips a dialog event without a defaultPrompt (optional)", () => { + const ev = { + type: "dialog", + tabId: 7, + dialogType: "beforeunload", + message: "", + url: "https://example.com/", + disposition: "accept", + }; + expect(EventSchema.parse(ev)).toEqual(ev); + }); + + it("rejects a dialog with an unknown dialogType", () => { + expect( + safeParseEvent({ + type: "dialog", + tabId: 1, + dialogType: "notification", + message: "hi", + url: "https://x/", + disposition: "accept", + }).success, + ).toBe(false); + }); + + it("rejects a dialog with an invalid disposition", () => { + expect( + safeParseEvent({ + type: "dialog", + tabId: 1, + dialogType: "alert", + message: "hi", + url: "https://x/", + disposition: "ignore", + }).success, + ).toBe(false); + }); +}); + +describe("DialogRecordSchema", () => { + it("is the dialog Event payload minus its `type`, pinned to the event member", () => { + const record = { + tabId: 2, + dialogType: "confirm", + message: "Sure?", + url: "https://x/", + disposition: "dismiss", + }; + // Parses standalone (the DO-state record / connector validator shape)... + expect(DialogRecordSchema.parse(record)).toEqual(record); + // ...and adding the discriminator yields a valid dialog Event, so the record + // and the wire event cannot drift apart (they share one definition). + expect(EventSchema.parse({ type: "dialog", ...record })).toEqual({ + type: "dialog", + ...record, + }); + }); }); describe("A11yNodeSchema", () => { diff --git a/packages/protocol/src/index.ts b/packages/protocol/src/index.ts index 4c97e1b..6408b9f 100644 --- a/packages/protocol/src/index.ts +++ b/packages/protocol/src/index.ts @@ -37,6 +37,34 @@ export type TabInfo = z.infer; export const SnapshotModeSchema = z.enum(["a11y", "dom", "screenshot"]); export type SnapshotMode = z.infer; +// A JavaScript dialog the page raised (alert/confirm/prompt) or a +// navigation-guard prompt (beforeunload). The extension handles each locally +// and synchronously - an open dialog blocks the single CDP channel, so there +// is no time to round-trip to the consumer for a decision - then reports what +// happened via the `dialog` Event below. +export const DialogTypeSchema = z.enum(["alert", "confirm", "prompt", "beforeunload"]); +export type DialogType = z.infer; + +// How the extension answered the dialog: accept (OK / proceed) or dismiss +// (Cancel / stay). The default policy is type-aware (see the extension's +// dialogDisposition): alert/beforeunload accept, confirm/prompt dismiss. +export const DialogDispositionSchema = z.enum(["accept", "dismiss"]); +export type DialogDisposition = z.infer; + +// The reportable payload of a handled dialog, minus the wire `type` +// discriminator. Single-sourced here so the `dialog` Event member below, the +// backend's DO-state record, and the connector's runtime validator all derive +// from ONE definition - no hand-copied field lists to drift apart. +export const DialogRecordSchema = z.object({ + tabId: z.number(), + dialogType: DialogTypeSchema, + message: z.string(), + url: z.string(), + defaultPrompt: z.string().optional(), + disposition: DialogDispositionSchema, +}); +export type DialogRecord = z.infer; + // ── Commands: backend → extension ──────────────────────────────────────────── // Every command carries a `commandId` used to correlate the async round-trip // (the coordinator parks a promise keyed by it; the matching event resolves it). @@ -166,6 +194,13 @@ export const EventSchema = z.discriminatedUnion("type", [ tabId: z.number(), url: z.string(), }), + // Unsolicited (like page_event): the extension emits one after it locally + // handles a page dialog, so the consumer learns what the page said and how it + // was answered. `defaultPrompt` is present only for `prompt` dialogs. This is + // BEST-EFFORT (like page_event): a report emitted while the WS is momentarily + // down (e.g. an MV3 service-worker reconnect) is not replayed - the dialog is + // still answered, only its notification is lost. + z.object({ type: z.literal("dialog"), ...DialogRecordSchema.shape }), z.object({ type: z.literal("pong") }), ]); export type Event = z.infer;