diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c7de45d..40e05a3 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -51,6 +51,16 @@ jobs: echo "image=secure-browser-mcp:pr-${SHA}" >> "$GITHUB_OUTPUT" fi + # Kargo's NewestBuild strategy orders images by the + # org.opencontainers.image.created label when it is set. The base + # image sets it, so without our own value every image on the same + # base has the same time, and Kargo cannot see that a build is newer. + - name: Use the commit time as the image created time + id: created + run: echo "time=$(git log -1 --format=%cd --date=format-local:%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT" + env: + TZ: UTC + - name: Build and push uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 with: @@ -59,3 +69,4 @@ jobs: provenance: false push: ${{ github.event_name == 'push' }} tags: ${{ steps.tag.outputs.image }} + labels: org.opencontainers.image.created=${{ steps.created.outputs.time }} diff --git a/.gitignore b/.gitignore index 8425891..ca230ed 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ -node_modules/ +node_modules dist/ *.log .env diff --git a/Dockerfile b/Dockerfile index 62768f3..35a93e9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,7 +4,8 @@ # Chrome. Build with --build-arg LOCAL_CHROME=true to run Chrome in the # container instead; that needs a seccomp profile that allows user # namespaces (docs/HOSTED.md, "Local Chrome in a container"). -FROM oven/bun:1-debian +# Pinned so a rebuild keeps a tested Bun. Bun 1.3.x fires no close on a client abort. +FROM oven/bun:1.4.3-debian ARG LOCAL_CHROME=false RUN if [ "$LOCAL_CHROME" = "true" ]; then \ @@ -19,7 +20,11 @@ RUN bun install --frozen-lockfile --production COPY src ./src COPY tsconfig.json ./ -ENV SBM_BROWSER=browserbase \ +# Tenants and their OAuth issuers come from a mounted file (docs/HOSTED.md, +# "Tenants"); SBM_PUBLIC_URL must also be set at run time. The development +# shared token (SBM_DEV_SHARED_TOKEN) is off in this image. +ENV SBM_TENANTS=/etc/sbm/tenants.json \ + SBM_BROWSER=browserbase \ SBM_CHROME_PATH=/usr/bin/chromium \ SBM_CHROME_ARGS=--disable-dev-shm-usage \ SBM_HEADLESS=true \ diff --git a/README.md b/README.md index f759923..b1e07f8 100644 --- a/README.md +++ b/README.md @@ -86,11 +86,21 @@ For a real-world walkthrough — an agent paying a Stripe test checkout with a c ## Development -| Path | What it holds | -| ---------------- | ------------------------------------------------------------------------------------------------- | -| `src/broker/` | Secret refs, the `SecretsClient` interface, Turnkey and mock backends, destination-binding policy | -| `src/browser/` | Browser ownership and CDP secret injection | -| `src/redaction/` | The scrub layer every tool result passes through | -| `src/tools/` | One file per MCP tool | -| `test/fixtures/` | The demo storefront (login, checkout, receipt) | -| `docs/` | Design, threat model, hosted broker, Stripe demo walkthrough | +| Path | What it holds | +| ----------------- | ------------------------------------------------------------------------------------- | +| `src/broker/` | Secret refs, the `SecretsClient` interface, Turnkey and mock backends, pending fills | +| `src/browser/` | Browser ownership and CDP secret injection | +| `src/tools/` | One file per MCP tool | +| `src/browsers.ts` | Browser handles: browsers named by `browser_id`, bound to the caller that opened them | +| `src/auth/` | Per-tenant OAuth: tenant config, token checks, scopes, the 401 challenge | +| `test/fixtures/` | The demo storefront (login, checkout, receipt) | +| `docs/` | Design, threat model, hosted broker, Stripe demo walkthrough | + +Destination-binding checks and the redaction layer that every tool result passes through come from [`@turnkey/browser-secrets`](https://www.npmjs.com/package/@turnkey/browser-secrets). + +## Experimental isolated Lightpanda + +See [the isolated worker design and offline test](docs/LIGHTPANDA.md). It uses +one externally provisioned worker per broker, a private Unix socket, and separate +network namespaces. The default example has no network access and uses synthetic +secrets only. diff --git a/bun.lock b/bun.lock index b572381..834cc85 100644 --- a/bun.lock +++ b/bun.lock @@ -6,14 +6,16 @@ "name": "secure-browser-mcp", "dependencies": { "@modelcontextprotocol/node": "2.1.0", - "@modelcontextprotocol/server": "2.2.0", + "@modelcontextprotocol/server": "2.3.1", + "@turnkey/browser-secrets": "0.0.1-alpha.0", "@turnkey/crypto": "^2.12.0", "@turnkey/sdk-server": "^8.3.0", + "jose": "^6", "puppeteer-core": "^24.0.0", "zod": "4.6.5", }, "devDependencies": { - "@modelcontextprotocol/client": "2.2.0", + "@modelcontextprotocol/client": "2.3.1", "@types/bun": "^1.2.0", "@types/node": "^22.0.0", "prettier": "^3.0.0", @@ -29,13 +31,13 @@ "@hono/node-server": ["@hono/node-server@1.19.17", "", { "peerDependencies": { "hono": "^4" } }, "sha512-dSneS5qhiauZWGDCeK4o695Xd9nUNjviSZCMQrj10eetr8Uln1ucn6bbphOM6UynAMMtNIzZNSpL9vnASJwrPQ=="], - "@modelcontextprotocol/client": ["@modelcontextprotocol/client@2.2.0", "", { "dependencies": { "@modelcontextprotocol/core": "2.2.0", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "jose": "^6.1.3", "pkce-challenge": "^5.0.0", "zod": "^4.2.0" } }, "sha512-LxCou/CSYQ6dwEnjhLZY0KnEuc8V4UJ3IEQCl/uR2yHobSQIEdJKUlKLRYRF5i5FqRGHy1eK7une3aAWDHLtig=="], + "@modelcontextprotocol/client": ["@modelcontextprotocol/client@2.3.1", "", { "dependencies": { "@modelcontextprotocol/core": "2.3.1", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.8", "jose": "^6.1.3", "pkce-challenge": "^5.0.0", "zod": "^4.2.0" } }, "sha512-mIGZXpHsjnZ6lD+gD/WCMpR5k8yVQQ8nNFH1N0Srf7AvnwTUMYD6pvTY8ng2+He5FfV7YMZLmrjL7cLa/cc3dQ=="], - "@modelcontextprotocol/core": ["@modelcontextprotocol/core@2.2.0", "", { "dependencies": { "zod": "^4.2.0" } }, "sha512-iLhmprRmWI8EcosOA3wVvww22z02NkgqhV4fBH6f/odQBsi7xnJc0HGmi21yWO+/iKw2yzqVE127Zhna5/+JXw=="], + "@modelcontextprotocol/core": ["@modelcontextprotocol/core@2.3.1", "", { "dependencies": { "zod": "^4.2.0" } }, "sha512-laVmIhPGpWi7rG9q+0UigblRN0Hws/Yjlb6qLFtVqryXVLkc4h+yN8z731POD2LnAGQlFe7/tXhUm7Bi0YB91g=="], "@modelcontextprotocol/node": ["@modelcontextprotocol/node@2.1.0", "", { "dependencies": { "@hono/node-server": "^1.19.9" }, "peerDependencies": { "@modelcontextprotocol/server": "^2.1.0", "hono": "^4.11.4" }, "optionalPeers": ["hono"] }, "sha512-6xg3iWVcOfiL8Y7rI6xUqXD0lI2AY5q3djsa/cOi1IJUSwx06t0gvhSwU/6mIIohrFlt40/ANUG56DQ0HLhILQ=="], - "@modelcontextprotocol/server": ["@modelcontextprotocol/server@2.2.0", "", { "dependencies": { "@modelcontextprotocol/core": "2.2.0", "zod": "^4.2.0" } }, "sha512-qFnltjus6Gk8Lx6J1w2RXmr3YPN22MNT6qD6zjAkaiWIlFIFxWClz0rXHNuJF/KSUyn5YGhkSvXkWDbQHVLFEQ=="], + "@modelcontextprotocol/server": ["@modelcontextprotocol/server@2.3.1", "", { "dependencies": { "@modelcontextprotocol/core": "2.3.1", "zod": "^4.2.0" } }, "sha512-e59MfWuSssj6DoQ96JCTqdW1AXT5/XtnTl0WWd6MU1Ka434fFUGGosCLJbGNySDhHGvxQWoRWK137FXYFkYNWw=="], "@noble/ciphers": ["@noble/ciphers@1.3.0", "", {}, "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw=="], @@ -79,6 +81,8 @@ "@turnkey/api-key-stamper": ["@turnkey/api-key-stamper@0.6.13", "", { "dependencies": { "@noble/curves": "^1.3.0", "@turnkey/crypto": "2.12.0", "@turnkey/encoding": "0.6.0", "sha256-uint8array": "^0.10.7" } }, "sha512-0KHdrNwUYoMuYQKATQhefZeYoml9QdDqf2GvTLpy1wfjquegyn4LT2mKzshTDcDvHDeMlzxvaVg7oi2YA9WpIA=="], + "@turnkey/browser-secrets": ["@turnkey/browser-secrets@0.0.1-alpha.0", "", { "dependencies": { "urlpattern-polyfill": "10.1.0" } }, "sha512-vtktDG3fbj+9NTkXejRY/30Uxrvvicji0HPwZDkXjpQYJUCnC0vNbQCfQ6sTaXcY52WQVSeW5M2G8Fxwn4fMIQ=="], + "@turnkey/crypto": ["@turnkey/crypto@2.12.0", "", { "dependencies": { "@noble/ciphers": "1.3.0", "@noble/curves": "1.9.0", "@noble/hashes": "1.8.0", "@peculiar/x509": "1.12.3", "@turnkey/encoding": "0.6.0", "@turnkey/sdk-types": "1.6.0", "borsh": "2.0.0", "cbor-js": "0.1.0" } }, "sha512-9myhmHnAgkuOLu2ClATMqwNow6a0GHIiKsKCjwO6/fpB0SiLsCtgm3FGwZpBDOlzuB+UmnaIQlfh4aPrHnS0HQ=="], "@turnkey/encoding": ["@turnkey/encoding@0.6.0", "", { "dependencies": { "bs58": "6.0.0", "bs58check": "4.0.0" } }, "sha512-IC8qXvy36+iGAeiaVIuJvB35uU2Ld/RAWI/DRTKS+ttBej0GXhOn48Ouu5mlca4jt8ZEuwXmDVv74A8uBQclsA=="], @@ -197,7 +201,6 @@ "get-uri": ["get-uri@6.0.5", "", { "dependencies": { "basic-ftp": "^5.0.2", "data-uri-to-buffer": "^6.0.2", "debug": "^4.3.4" } }, "sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg=="], - "http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="], "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], @@ -300,6 +303,8 @@ "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + "urlpattern-polyfill": ["urlpattern-polyfill@10.1.0", "", {}, "sha512-IGjKp/o0NL3Bso1PymYURCJxMPNAf/ILOpendP9f5B6e1rTJgdgiOvgfoT8VxCAdY+Wisb9uhGaJJf3yZ2V9nw=="], + "viem": ["viem@2.56.0", "", { "dependencies": { "@noble/curves": "1.9.1", "@noble/hashes": "1.8.0", "@scure/bip32": "1.7.0", "@scure/bip39": "1.6.0", "abitype": "1.2.3", "isows": "1.0.7", "ox": "0.14.34", "ws": "8.21.0" }, "peerDependencies": { "typescript": ">=5.0.4" }, "optionalPeers": ["typescript"] }, "sha512-JmkgIk4jN+im4oguLxwPv19pwSaGH9kcGSq25Ilm55106ULBBMNR3eWKKA0wZoeyLPSHIiOwZ4sGceEYEIq7LA=="], "webdriver-bidi-protocol": ["webdriver-bidi-protocol@0.4.1", "", {}, "sha512-ARrjNjtWRRs2w4Tk7nqrf2gBI0QXWuOmMCx2hU+1jUt6d00MjMxURrhxhGbrsoiZKJrhTSTzbIrc554iKI10qw=="], diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 8879d26..9c3d1ef 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -11,20 +11,23 @@ Turnkey's export flow fits this exactly. `exportSecret` re-encrypts the secret t `fill_secret(secret_id, element_uid)`: 1. Resolve the ref and the target element. -2. `BindingPolicy.assertAllowed(ref, target)` — see below. +2. Observe the target on the live page (`src/browser/observe.ts`) and decide with `authorize(ref, request, observation)` from `@turnkey/browser-secrets` — see below. 3. Optional human confirmation (see "MCP spec hooks"). 4. `SecretsClient.exportSecret(ref)` — plaintext lands in broker memory. -5. Register the value and the field with the `RedactionRegistry` **before** injection. -6. Inject over CDP `Input.insertText`. Never through `Runtime.evaluate`. +5. Register the value with the `RedactionRegistry` **before** injection. +6. For each target: observe and authorize again, require the same destination (`isSameDestination`), register the target, then inject over CDP `Input.insertText`. Never through `Runtime.evaluate`. 7. Drop the value. Return outcome only: `{ filled: true }`. ## Destination binding -Static properties are bound immutably to a secret at import and are policy-visible. We reserve three keys (`src/broker/types.ts`): +Static properties are bound immutably to a secret at import and are policy-visible. `@turnkey/browser-secrets` parses them and makes the decision; its README lists every key. The main ones: - `sbm:origin` — exact origin, required for a secret to be fillable - `sbm:url-pattern` — optional URLPattern narrowing within the origin - `sbm:selector` — optional CSS selector the target field must match +- `sbm:fields` — optional JSON map from payload key to selector, for a JSON secret with several parts + +`authorize` trusts the observation it is given. The broker reads every fact in it from the browser where page script cannot change it: frame, document, URL and origin from `Page.getFrameTree`, and selector matches and shadow-root mode in an isolated world. This is the prompt-injection defense. A malicious page can talk the agent into _requesting_ a fill. The fill still fails unless the live page matches the binding chosen by the human at import time. @@ -32,12 +35,12 @@ Enforcement today is broker-side. The end state moves it into Turnkey's policy e ## Redaction -One choke point: `server.ts` pipes every tool result and every error through `scrub()`. Handlers cannot write to the transport. +One choke point: `server.ts` pipes every tool result and every error through the session's `RedactionRegistry` (`@turnkey/browser-secrets`). Handlers cannot write to the transport. Two mechanisms: -- **Structural** (primary): fields that received a secret are tagged by element uid. Snapshot serializers elide their values. The network tool withholds request bodies for origins with live secrets. Screenshots mask tagged fields. -- **Value-scan** (backstop): every exported plaintext goes into a scrub-set. Any string in any output that contains a live value is replaced with `[REDACTED:]`. +- **Structural** (primary): fields that received a secret are tagged by browser session, tab, frame, document and element uid. Snapshot serializers elide their values. The network tool withholds request bodies for origins with live secrets. Screenshots mask tagged fields. +- **Value-scan** (backstop): every exported plaintext goes into a scrub-set. Any string in any output that contains a live value is replaced with `[REDACTED:]`. Values shorter than four characters are not scanned; their fields stay redacted by the tag. Registration happens before the value touches the page, so no read can race ahead of redaction. diff --git a/docs/HOSTED.md b/docs/HOSTED.md index 6b950dd..21a93be 100644 --- a/docs/HOSTED.md +++ b/docs/HOSTED.md @@ -4,61 +4,178 @@ The hosted broker serves the same MCP tools over Streamable HTTP, so an agent pl This is a Turnkey-operated fill service. The broker runs on our infrastructure and decrypts each secret before it sends the value to the browser. By default the browsers run on Browserbase, so secret plaintext exists in the broker process and in the Browserbase browser during a fill. Read the [hosted section of the threat model](THREAT-MODEL.md#hosted-broker) before you describe this service to a customer. +The broker serves one MCP endpoint per tenant, `/t/{tenant}/mcp`, and is an OAuth 2.1 resource server for each, as MCP authorization describes. Each tenant has its own authorization server, Turnkey organization and API key, and key for parked fills. + ## Run it locally ```sh -export SBM_HTTP_TOKEN="$(openssl rand -hex 32)" +export SBM_TENANTS="$PWD/tenants.json" # see "Tenants" below +export SBM_PUBLIC_URL="http://127.0.0.1:8080" export SBM_STATE_DIR="$HOME/.sbm-state" export SBM_STATE_KEY="$(openssl rand -hex 32)" -bun run serve # http://127.0.0.1:8080/mcp, browsers run as local Chrome +bun run serve # http://127.0.0.1:8080/t//mcp, browsers run as local Chrome ``` To run the session browsers on Browserbase, as the hosted deployment does, also set `SBM_BROWSER=browserbase` and `BROWSERBASE_API_KEY`. Keep `SBM_STATE_KEY` stable across restarts. A different key cannot read the parked fills, and the broker skips them. +### Development: one shared token + +Without an authorization server, set `SBM_DEV_SHARED_TOKEN=1` and `SBM_HTTP_TOKEN`. The broker then also serves `/mcp` to any client that sends that bearer token, with every scope and the `TURNKEY_*` key (or the mock backend). `SBM_HTTP_TOKEN` without the flag stops the broker at startup. Do not use this in production. The image does not set the flag. + +```sh +SBM_DEV_SHARED_TOKEN=1 SBM_HTTP_TOKEN="$(openssl rand -hex 32)" bun run serve +``` + ## Configuration -| Variable | Default | Purpose | -| --------------------------- | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `SBM_HTTP_TOKEN` | (required) | Clients send `Authorization: Bearer `. The broker refuses to start without it. | -| `SBM_HTTP_HOST` | `127.0.0.1` | Listen address. The container image sets `0.0.0.0`. | -| `SBM_HTTP_PORT` | `8080` | Listen port. | -| `SBM_STATE_DIR` | (none) | Directory for parked fills. Without it, a restart strands every pending approval. | -| `SBM_STATE_KEY` | (none) | 32-byte key, 64 hex characters or base64. Required with `SBM_STATE_DIR`. | -| `TURNKEY_SIGNER_PUBLIC_KEY` | (SDK default) | Enclave quorum key that signs secret bundles. Required when `TURNKEY_API_BASE_URL` points at a Turnkey environment with its own enclaves; production needs nothing. | -| `SBM_BROWSER` | `local` | Where session browsers run: `local` (Chrome on this host) or `browserbase`. The image sets `browserbase`. | -| `BROWSERBASE_API_KEY` | (none) | Required with `SBM_BROWSER=browserbase`. | -| `BROWSERBASE_PROJECT_ID` | (first project) | Browserbase project for sessions. | -| `SBM_BROWSERBASE_TIMEOUT_S` | (project default) | Maximum Browserbase session length, in seconds. | -| `SBM_MAX_SESSIONS` | `8` | Concurrent agent sessions. Each session gets its own browser. On Browserbase, keep this at or below the plan's concurrency limit. | -| `SBM_SESSION_IDLE_S` | `1800` | The broker closes a session, and its browser, after this many idle seconds. | -| `SBM_CHROME_ARGS` | (none) | Local Chrome only. Extra Chrome flags, separated by spaces. | -| `SBM_ALLOWED_ORIGINS` | (none) | Browser origins that may call `/mcp`, separated by commas. Any other `Origin` header gets `403`. See [Origin check](#origin-check). | - -The `TURNKEY_*` variables and the other `SBM_*` variables work as in the stdio broker. Step 1 uses one Turnkey API key for every session. - -`GET /healthz` returns `{"ok":true,"backend":"..."}` without authentication. +| Variable | Default | Purpose | +| -------------------------------- | ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `SBM_TENANTS` | (none) | Path to the tenant file. The image sets `/etc/sbm/tenants.json`; mount the file there. | +| `SBM_PUBLIC_URL` | (required) | The origin clients reach the broker at, such as `https://sbm.example.com`. Token audiences are built from it. | +| `SBM_DEV_SHARED_TOKEN` | (off) | `1` serves `/mcp` with the shared token `SBM_HTTP_TOKEN`, and allows tenants with `backend: "mock"`. Development only. | +| `SBM_HTTP_HOST` | `127.0.0.1` | Listen address. The container image sets `0.0.0.0`. | +| `SBM_HTTP_PORT` | `8080` | Listen port. | +| `SBM_STATE_DIR` | (none) | Directory for parked fills. Without it, a restart strands every pending approval. | +| `SBM_STATE_KEY` | (none) | 32-byte key, 64 hex characters or base64. Required with `SBM_STATE_DIR`. Each tenant's key is derived from it. | +| `TURNKEY_SIGNER_PUBLIC_KEY` | (SDK default) | Enclave quorum key that signs secret bundles. Required when `TURNKEY_API_BASE_URL` points at a Turnkey environment with its own enclaves; production needs nothing. Applies to every tenant. | +| `SBM_BROWSER` | `local` | Where session browsers run: `local` (Chrome on this host) or `browserbase`. The image sets `browserbase`. | +| `BROWSERBASE_API_KEY` | (none) | Required with `SBM_BROWSER=browserbase`. | +| `BROWSERBASE_PROJECT_ID` | (first project) | Browserbase project for sessions. | +| `SBM_BROWSERBASE_TIMEOUT_S` | (project default) | Maximum Browserbase session length, in seconds. | +| `SBM_MAX_SESSIONS` | `8` | Concurrent agent sessions. Each session gets its own browser. The same limit applies again to browser handles. On Browserbase, keep twice this at or below the plan's concurrency limit. | +| `SBM_MAX_SESSIONS_PER_TENANT` | (equal share) | Concurrent sessions per tenant, and browser handles per tenant. The default is `SBM_MAX_SESSIONS` divided by the number of tenants (the dev `/mcp` endpoint counts as one). | +| `SBM_MAX_SESSIONS_PER_PRINCIPAL` | `4` | Concurrent sessions per principal (`iss` and `sub`) in one tenant, and browser handles per principal. The default is never more than the per-tenant limit. Does not apply to the dev `/mcp` endpoint. | +| `SBM_SESSION_IDLE_S` | `1800` | The broker closes a session, and its browser, after this many idle seconds. A browser handle closes after this many seconds without a tool call. | +| `SBM_CHROME_ARGS` | (none) | Local Chrome only. Extra Chrome flags, separated by spaces. | +| `SBM_ALLOWED_ORIGINS` | (none) | Browser origins that may call the MCP endpoints, separated by commas. Any other `Origin` header gets `403`. See [Origin check](#origin-check). | + +`TURNKEY_API_BASE_URL` and the browser `SBM_*` variables work as in the stdio broker. Only the dev `/mcp` endpoint uses `TURNKEY_API_*` and `TURNKEY_ORGANIZATION_ID`. + +`GET /healthz` returns `{"ok":true,"tenants":}` without authentication. + +## Tenants + +`SBM_TENANTS` names a JSON file of deployment configuration. Keep it out of this repository and out of the state volume. It holds references to keys, never keys. + +```json +{ + "tenants": [ + { + "id": "acme", + "organizationId": "", + "apiKey": { + "publicKeyEnv": "ACME_TURNKEY_API_PUBLIC_KEY", + "privateKeyEnv": "ACME_TURNKEY_API_PRIVATE_KEY" + }, + "issuers": [ + { + "issuer": "https://login.acme.com", + "jwksUri": "https://login.acme.com/.well-known/jwks.json" + } + ] + } + ] +} +``` + +| Field | Meaning | +| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `id` | Lowercase letters, digits, and dashes. Used in the endpoint URL and the state directory. | +| `organizationId` | The tenant's Turnkey organization. Every secret the tenant's sessions see comes from it. | +| `apiKey` | The tenant's Turnkey API key: `publicKeyEnv` and `privateKeyEnv` (environment variable names), or `path` to a JSON file `{"publicKey": "...", "privateKey": "..."}`. | +| `issuers` | The authorization servers whose tokens the tenant accepts: the exact `issuer` and its `jwksUri`. HTTPS only (HTTP for `localhost`). `"requireTyp": true` also requires the `typ: at+jwt` header. It is on by default when the tenant sets `audience` to something other than the resource URI. | +| `audience` | Optional. The exact `aud` of the tenant's tokens, when the server cannot use the resource URI (Entra ID's `api://`). The default is the resource URI. Two tenants cannot share an audience. | +| `allowedClients` | Optional. Client ids (`azp`, else `client_id`) whose tokens the tenant accepts. With it, a token must name its client. Without it, the tenant accepts every client its authorization servers issue tokens to, including dynamically registered clients and tokens that name no client (see [Sessions](#sessions)). | +| `authorizationServers` | Optional. What the Protected Resource Metadata advertises. The default is the `issuer` values. | +| `backend` | Optional. `"turnkey"` (default) or `"mock"`: in-memory `mockSecrets`, whose values are written in the file, for development and tests only. The broker refuses to start with a mock tenant unless `SBM_DEV_SHARED_TOKEN=1`. | + +Give each tenant its own API key, scoped by Turnkey policy to its own organization. The broker never uses one tenant's key for another; Turnkey policy stops a key from reaching further. Approvals stay in Turnkey policy. + +The broker reads the file and resolves every API key at startup. One missing key stops the broker. Restart it to add a tenant or rotate a key. + +### Set up the tenant's authorization server + +The server must issue JWT access tokens whose `aud` is the tenant's resource URI (or `audience`) and nothing else. Many servers add other audiences by default, and the broker refuses a token with more than one. + +**Keycloak.** Access tokens include the `account` audience by default. For the agent's client: + +1. Add an **Audience** mapper to the client or one of its client scopes. Set **Included Custom Audience** to the resource URI, such as `https://sbm.example.com/t/acme/mcp`, and turn on **Add to access token**. +2. Remove the `account` audience: remove the `roles` client scope from the client, or its **audience resolve** mapper, or the `account` client roles from the realm's default roles. Otherwise `aud` is `["account", ""]`. +3. Add the client scopes `sbm:browse`, `sbm:fill`, and `sbm:refs`, and assign them to the client. +4. Decode a token. `aud` must be the resource URI alone. + +The issuer is `https:///realms/`. The JWKS URI is `https:///realms//protocol/openid-connect/certs`. + +**Entra ID.** Entra ID cannot put the resource URI in `aud`: set the tenant's `audience` to the app's `api://`. Its access tokens have `typ: JWT`, so set `"requireTyp": false` on the issuer. With an `audience` override, the broker otherwise requires `typ: at+jwt`, because an ID token whose `aud` is the override would pass every other check. Never set `audience` to a client id (the app id without `api://`): that is the `aud` of the app's ID tokens. Expose the `sbm:*` scopes as delegated scopes; they arrive in `scp`. The broker does not read app roles (`roles`). + +## Authorization + +For a tenant `acme` behind `SBM_PUBLIC_URL=https://sbm.example.com`: + +| What | Value | +| ----------------------------------------- | ------------------------------------------------------------------------- | +| MCP endpoint, and the resource (RFC 8707) | `https://sbm.example.com/t/acme/mcp` | +| Protected Resource Metadata (RFC 9728) | `https://sbm.example.com/.well-known/oauth-protected-resource/t/acme/mcp` | + +The metadata needs no authentication. + +**Tokens.** Each request carries `Authorization: Bearer `. The broker accepts a signed JWT access token only when: + +- `iss` is exactly one of the tenant's `issuers`, +- the signature verifies against that issuer's JWKS with RS256, PS256, ES256, ES384, or EdDSA, +- `aud` is exactly the tenant's resource URI (or `audience`), with no other audience, +- the header has `typ: at+jwt`, if the issuer has `requireTyp` (on by default when `audience` is not the resource URI), +- `exp` is present and not past, and `nbf`, if present, is not in the future, with 30 seconds of clock skew, +- `exp` is at most 24 hours after now, and at most 24 hours after `iat` if present, +- there is no `nonce`, `at_hash`, or `c_hash` claim (these mark an OpenID Connect ID token), +- `sub` is present, +- the token names a client (`azp`, else `client_id`) that is in `allowedClients`, if the tenant has that list. + +Scopes come from `scope` (space-separated) and `scp` (a string or an array, as Okta issues), merged. A client-credentials token works if it meets the same rules; its `sub` is usually the client id. The broker does not accept opaque tokens or refresh tokens. + +The broker caches each JWKS for 10 minutes and refetches it, at most every 30 seconds, for an unknown key id. If the JWKS cannot be fetched, the token is refused. Tokens are not sent to Turnkey or Browserbase and are not logged. + +**Scopes.** Each tool needs one scope: + +| Scope | Tools | +| ------------ | ------------------------------------------------------------------------------------------------------ | +| `sbm:browse` | `open_browser`, `close_browser`, `navigate`, `snapshot`, `click`, `type_text`, `list_network_requests` | +| `sbm:fill` | `fill_secret`, `await_fill` | +| `sbm:refs` | `list_secret_refs` | + +The scopes apply the same way to both protocol versions. Opening a session needs at least one of these scopes; without one, the broker answers `403 insufficient_scope` with `scope="sbm:browse"`. Listing tools needs nothing more. An agent that fills secrets needs all three. The broker checks scopes on every request against that request's token, so a client that steps up its token gets the new scopes in the same session. + +**Errors.** + +- No token, or an invalid one: `401` with `WWW-Authenticate: Bearer error="invalid_token", scope="sbm:browse sbm:fill sbm:refs", resource_metadata=""`. There is no `error` when the request has no `Bearer` credentials. MCP clients follow `resource_metadata` to the authorization server. +- A malformed `Bearer` header: `400` with `error="invalid_request"`. +- A tool call without its scope: `403` with `error="insufficient_scope"` and the missing scopes. A batch names every missing scope in one challenge. +- A JSON-RPC id already in flight in the session, or one id twice in a batch: `409` with JSON-RPC error `-32600`. +- An unknown tenant: `404`. +- The broker, the tenant, or the principal at its session limit: `503`. At its limit, a principal's own idle sessions that hold no browser are closed first (see [Sessions](#sessions)). +- A 2026-07-28 request whose `Content-Type` is not `application/json`: `415`. ## Connect Hermes -Add a `url` entry to the Hermes `config.yaml`: +Point Hermes at the tenant's endpoint: ```yaml mcp_servers: secure-browser: - url: "https:///mcp" - headers: - Authorization: "Bearer " + url: "https:///t//mcp" ``` +A client that implements MCP authorization discovers the authorization server from the `401` and signs in. A client that only sends static headers needs an access token obtained elsewhere, in `headers: { Authorization: "Bearer " }`, and must replace it before it expires. + Check the connection with `hermes mcp test secure-browser`. It must list the same tools as the stdio broker. Install the skill as usual (`bun run skill:install -- --hermes`); the skill does not change. The hosted browser runs on the broker host. `localhost` in a URL refers to that host, not to the agent's machine, so the local demo fixture works only when the fixture runs beside the broker. ## Origin check -The MCP Streamable HTTP spec requires the server to check the `Origin` header. This stops a web page in a user's browser from calling the broker, for example through DNS rebinding. +The MCP Streamable HTTP spec requires the server to check the `Origin` header. This stops a web page in a user's browser from calling the broker, for example through DNS rebinding. The check applies to every MCP endpoint. - A request without an `Origin` header passes. Non-browser MCP clients (Hermes, Claude Code, the SDK clients) do not send one. - A request with an `Origin` header passes only if the value is in `SBM_ALLOWED_ORIGINS`. The match is exact: scheme, host, and port. The browser's opaque origin `null` never matches. @@ -67,61 +184,66 @@ The MCP Streamable HTTP spec requires the server to check the `Origin` header. T Each entry must be a bare origin (`scheme://host[:port]`, no path). The broker does not start if an entry is not an origin. -An allowed origin does not replace the bearer token. The broker does not send CORS headers yet, so a browser page on an allowed origin still cannot read responses. +An allowed origin does not replace the bearer token. The broker does not send CORS headers on the MCP endpoints yet, so a browser page on an allowed origin still cannot read responses. -## Protocol versions +The Protected Resource Metadata is not checked. It is public, and browser-based clients read it from other origins, so the broker serves it with `Access-Control-Allow-Origin: *`. -The broker serves the 2025 session protocol, up to MCP revision `2025-11-25`: the client sends `initialize` and then sends the `Mcp-Session-Id` header on each request. This is the default for current MCP clients. +## Protocol versions -The broker does not serve revision `2026-07-28` yet. That revision has no sessions, and the broker keeps one browser per session. A 2026-07-28 request (for example `server/discover`, with the protocol version in the request's `_meta`) gets HTTP `400` with the JSON-RPC error `-32022` (unsupported protocol version): +The broker serves both protocol eras on the same endpoint: -```json -{ - "jsonrpc": "2.0", - "error": { - "code": -32022, - "message": "Unsupported protocol version: 2026-07-28. This broker serves the 2025-era session protocol only; connect with initialize (protocol version 2025-11-25).", - "data": { - "supported": [ - "2025-11-25", - "2025-06-18", - "2025-03-26", - "2024-11-05", - "2024-10-07" - ], - "requested": "2026-07-28" - } - }, - "id": 7 -} -``` +- The 2025 session protocol, up to MCP revision `2025-11-25`. The client sends `initialize` and then sends the `Mcp-Session-Id` header on each request. Each session has a browser of its own. +- Revision `2026-07-28`. There is no session: each request carries the protocol version in its `_meta`, and the broker answers `server/discover`. A request has no browser of its own, so every browser tool needs a `browser_id` (see [Browser handles](#browser-handles)). -A client that negotiates the version (MCP TypeScript SDK v2 `versionNegotiation: { mode: "auto" }`) reads this error and connects with `initialize`. A client that requires 2026-07-28 fails to connect with this error. +The broker picks the era for each request with the MCP TypeScript SDK's own classifier (`isLegacyRequest`). A client that negotiates the version (SDK v2 `versionNegotiation: { mode: "auto" }`) gets 2026-07-28. A client that requires 2025 or 2026-07-28 gets that version. ## Sessions - Each MCP session gets its own browser (a local Chrome process with a fresh profile, or a new Browserbase session), its own redaction registry, and its own element uids. One session cannot see another session's pages, cookies, or snapshots. +- A session belongs to the tenant, subject (`iss` and `sub`), and OAuth client (`azp`, else `client_id`) that opened it. Anyone else gets `404 Session not found`. +- If the token that opened a session has no `azp` or `client_id`, the broker cannot tell clients apart. The session then belongs to the tenant and subject only: any token of that subject without a client claim can use it, and a token with a client claim cannot. To bind every session to a client, set `allowedClients`. The broker then refuses tokens without a client claim. - Browserbase sessions start with recording and session logs off, and with `keepAlive: false`. The broker releases the Browserbase session when the MCP session closes. -- A session sees only the pending fills it parked. `await_fill` with a fill id that belongs to another live session returns `Unknown fill_id`. +- A `GET` stream (server-to-client events) outlives its request, so the broker ends it when the token that opened it expires. The client reopens it with a fresh token. A stream the client drops is released at once, so the client can open another. +- Two requests in flight in one session cannot share a JSON-RPC id. The second would get the first one's response, even without the first one's scope. The broker answers `409`. An id is free again once the server has answered it or the client has cancelled it, even if its client has disconnected by then; a dropped connection alone does not free it, because the server can still answer it. Do not reuse an id across JSON-RPC batches: an answered id is free while its batch stream is still open, and a reuse in a new batch can make the SDK end the new stream early, so that answer is lost. MCP removed batching in the 2025-06-18 spec. +- A session sees only the pending fills it parked. `await_fill` with a fill id that belongs to another live session, another subject, or another tenant returns `Unknown fill_id`. - The broker closes a session's browser when the client sends `DELETE`, when the session is idle for `SBM_SESSION_IDLE_S`, and at shutdown. +- When a principal opens a session at a session limit, the broker first closes that principal's least recently used session that has no running browser and no request in flight. Some clients open a session for each tool call and never close it; they keep their browser in a [browser handle](#browser-handles). The client of a closed session gets `404` and opens a new one. The broker never closes another principal's session to make room. + +## Browser handles + +A browser handle is a browser that does not belong to one connection. `open_browser` returns a `browser_id`. Every browser tool (`navigate`, `snapshot`, `click`, `type_text`, `fill_secret`, `await_fill`, `list_network_requests`) takes an optional `browser_id` and then acts in that browser. `close_browser` with the `browser_id` closes it. + +- Use handles with a 2026-07-28 client: it has no session, so a browser tool without a `browser_id` fails. Use them also with a 2025 client that opens a new session for each tool call (the Arcade gateway). A browser tied to such a session is gone before the next call. +- On a 2025 session, a call without `browser_id` uses the session's own browser, as before. The session's browser and its handles are separate browsers. +- A handle has its own browser, redaction registry, element uids, and pending fills, as a session does. `fill_secret` parks a fill on the handle, and `await_fill` with that handle's `browser_id` redeems it from any connection. +- A handle belongs to the tenant, subject (`iss` and `sub`), and OAuth client that opened it, the same rule as a session. For anyone else, a `browser_id` gives the same `Unknown browser_id` error as an id that was never issued. The id holds 192 random bits. +- The broker closes a handle on `close_browser`, after `SBM_SESSION_IDLE_S` seconds without a tool call, and at shutdown. A call in progress keeps the handle open. Pending fills of a closed handle stay in the store: another browser of the same tenant and subject can claim them by `fill_id`. +- Handles have their own copy of the session limits: `SBM_MAX_SESSIONS` in total, `SBM_MAX_SESSIONS_PER_TENANT` per tenant, and `SBM_MAX_SESSIONS_PER_PRINCIPAL` per principal. At a limit, `open_browser` fails and asks the agent to close a browser. A principal at its handle limit can still open a session to call `close_browser`. +- The browser starts with the first tool call that needs it, not with `open_browser`. ## Restarts and pending fills -A parked fill holds the export's decryption key. With `SBM_STATE_DIR` set, the broker writes each parked fill to one AES-256-GCM file, encrypted with `SBM_STATE_KEY`, and loads the files at startup. +A parked fill holds the export's decryption key. With `SBM_STATE_DIR` set, the broker writes each parked fill to one AES-256-GCM file under `SBM_STATE_DIR/tenants//`, encrypted with a key derived from `SBM_STATE_KEY` and the tenant id (HKDF-SHA256), and loads the files at startup. -After a restart, the old MCP session and its browser are gone. The client starts a new session (the broker answers the old session id with 404, as the MCP spec requires). The new session can claim a parked fill by presenting its `fill_id`: the broker gave that random id only to the original session. The old element uids are gone too, so the agent navigates back to the page, takes a snapshot, and calls `await_fill` with the `fill_id` and the new `element_uid` or `fields`. The destination binding is checked again as for any fill. +After a restart, the old MCP session and its browser are gone. The client starts a new session (the broker answers the old session id with 404, as the MCP spec requires). A new session of the same tenant and subject can claim a parked fill by presenting its `fill_id`: the broker gave that random id only to the original session. The old element uids are gone too, so the agent navigates back to the page, takes a snapshot, and calls `await_fill` with the `fill_id` and the new `element_uid` or `fields`. The destination binding is checked again as for any fill. If the page is not ready when the approval arrives, the broker drops the plaintext and keeps the fill. The agent can call `await_fill` again with new targets; the approved export is redeemed again. Run one broker process per state directory. Two processes that share a directory do not see each other's writes. +### Upgrading from the shared token + +A shared-token broker parked fills directly in `SBM_STATE_DIR`, owned by no tenant. This broker does not load them, and logs how many it skipped. Before the upgrade, let the agents redeem their parked fills, or wait for the 24-hour expiry. A fill still waiting at the upgrade is lost, and its approval must be requested again. Then delete the `*.fill` files left directly in `SBM_STATE_DIR`. + ## Container ```sh docker build -t secure-browser-mcp . docker run --rm -p 8080:8080 \ - -e SBM_HTTP_TOKEN -e SBM_STATE_KEY -e BROWSERBASE_API_KEY \ - -e TURNKEY_API_PUBLIC_KEY -e TURNKEY_API_PRIVATE_KEY -e TURNKEY_ORGANIZATION_ID \ + -e SBM_PUBLIC_URL=https://sbm.example.com \ + -e SBM_STATE_KEY -e BROWSERBASE_API_KEY \ + -e ACME_TURNKEY_API_PUBLIC_KEY -e ACME_TURNKEY_API_PRIVATE_KEY \ + -v "$PWD/tenants.json:/etc/sbm/tenants.json:ro" \ -v sbm-state:/var/lib/sbm \ secure-browser-mcp ``` @@ -141,7 +263,7 @@ Do not add `--no-sandbox` to `SBM_CHROME_ARGS`: without the sandbox, a compromis ## Deployment requirements -- Terminate TLS in front of the broker. The bearer token and tool results travel in the request and response bodies. -- Restrict the broker's egress to the Turnkey API and Browserbase. With local Chrome, the browser can reach every address the container can reach, because the agent controls where it navigates: then restrict egress to the public internet, and block cloud metadata endpoints and internal services. -- Store `SBM_HTTP_TOKEN`, `SBM_STATE_KEY`, and the Turnkey API key in the platform's secret store. Do not put them in the state volume. +- Terminate TLS in front of the broker, at the origin in `SBM_PUBLIC_URL`. Access tokens and tool results travel in the requests and responses. +- Restrict the broker's egress to the Turnkey API, Browserbase, and the tenants' JWKS URIs. With local Chrome, the browser can reach every address the container can reach, because the agent controls where it navigates: then restrict egress to the public internet, and block cloud metadata endpoints and internal services. +- Store `SBM_STATE_KEY` and the tenants' Turnkey API keys in the platform's secret store. Do not put them in the state volume or the tenant file. - Run one replica per state volume. diff --git a/docs/LIGHTPANDA.md b/docs/LIGHTPANDA.md new file mode 100644 index 0000000..56d07d4 --- /dev/null +++ b/docs/LIGHTPANDA.md @@ -0,0 +1,119 @@ +# Experimental isolated Lightpanda + +This replaces PR #28's in-broker subprocess with an externally started, +credential-free worker. It builds on current main's BrowserHost, caller-owned +browser handles, scoped pending approvals, isolated CDP observations, per-write +binding checks and redaction. It does not implement the separate proposals for +iframe fills, fill intent or additional read-back redaction. + +## Boundary + +Run the broker and worker in **different containers and network namespaces**. +The worker accepts one connection on a Unix socket in a dedicated IPC directory +(mode 0700; socket 0600; both owned by the service UID). Mount only that directory +into the broker, read-only. Never mount broker source overlays, home, state, +credentials, Kubernetes tokens, host runtime sockets or host devices into the +worker. The worker image contains only application code and public dependencies. +Filesystem permissions and exclusive volume assignment authenticate this local +transport; there is no TCP control endpoint or reusable bearer credential. +Operators with access to the socket directory remain trusted. + +The worker starts Lightpanda only after accepting its sole client. It replaces +the browser environment with an allowlist, discards browser stdout/stderr, and +keeps raw CDP on worker-local loopback. The broker never starts containers or +receives orchestration privileges. A second client, failed startup, disconnect, +worker crash or expiry consumes the worker. No reconnect, session reassignment, +worker pool, or automatic replacement is attempted. Provision a new worker and +broker instance for the next session. The runtime deliberately allocates only +one Lightpanda host per broker instance; it is not a multi-tenant worker pool. + +The worker's lifetime is at most five minutes, including idle startup time. +Disconnect sends SIGTERM to the browser and escalates to SIGKILL after one second. +Container exit removes remaining processes; use an init process, no restart +policy, resource limits and an external container lifetime bound. Broker close +requests teardown by closing the channel; it is not a synchronous attestation +that a compromised remote container was destroyed. The independent lifetime cap +bounds orphan cleanup. Retire the container and IPC directory after every run. + +Default worker networking is `none`: no LAN, broker, cluster API, metadata, DNS +or Internet access. The Unix socket still works. A test fixture can listen on +loopback **inside the worker container**. This is deliberately an offline +experiment. Real browsing requires an independently reviewed egress design; +never add a default bridge or broad Internet rule as a shortcut. A broker can +have its own network access without granting it to the browser. + +A same-Pod sidecar shares networking and cannot enforce this boundary with +ordinary Kubernetes NetworkPolicy. Separate Pods require an additional +mutually authenticated transport and provisioning design; this Unix-socket +example does not pretend to provide cross-Pod IPC. Do not use hostPath to bridge +Pods. Chroot alone only changes filesystem path resolution: it does not isolate +environment, credentials, file descriptors, processes or networking. Containers +add namespace, mount, capability and seccomp boundaries but share the kernel; +gVisor, Kata or a VM can strengthen the kernel boundary as a later deployment +choice, subject to CDP compatibility and operational review. + +## Existing security retained, and new checks + +- `BrowserHandles` still binds handles to endpoint, principal and OAuth client. +- `validateTargets`, `observeTargets`, `assertSameDestination` and per-write + `recheckTarget` remain authoritative. Nothing falls back to page-controlled + selector evidence when CDP fails. +- `LightpandaHost.openPage` creates a fresh browser context and runs an observation + gate: nonempty frame/loader identity, isolated-world resistance to page globals + and DOM prototype overrides, and a new loader identity after navigation. + Failure closes the worker. A gate is not a security proof for arbitrary binary + versions: pin and test each version. +- Only the explicitly authorized plaintext field value crosses CDP during a + fill. Turnkey keys, export bundles and broker state do not cross the boundary. + The intended destination page necessarily sees a value filled into it. +- The SDK's `mcpReq.signal` now reaches tools. Cancellation consumes an attached + Lightpanda worker and is checked before export and immediately before each + plaintext CDP write. Values are released on failure. Cancellation cannot undo + a write already sent, revoke an export proposal already submitted, or guarantee + cancellation delivery over a disconnected transport; the worker deadline + remains the backstop. Human MCP elicitation is still not implemented; existing + Turnkey quorum policy supplies actual approval when configured. + +## Build and offline verification + +`examples/lightpanda/Dockerfile` pins Bun's Linux amd64 image and verifies the +Lightpanda 1.0.0 binary hash. Obtain `lightpanda-x86_64-linux` from the official +`lightpanda-io/browser` 1.0.0 GitHub release, place it as `lightpanda` in an +allowlisted build context, and verify SHA-256: + +`aa5a4b8ed53d1e38b3c73f5b2647d0a84a82e6744557f45f9a9c85858aa031c3` + +Context: `package.json`, `bun.lock`, `src/`, the three named Lightpanda integration/lifecycle +fixture files, `scripts/probe-lightpanda-worker.ts`, the example Dockerfile and +that binary. No `.env`, home directories, credentials, Git history or private +configuration. Build locally; do not publish a lab image by default. + +Run `examples/lightpanda/test-offline.sh IMAGE` on an isolated Linux Docker host +whose invoking UID is 1000. The script runs two temporary containers with no +network, a private socket directory, read-only roots, no capabilities, no new +privileges and bounded resources. It tests live observations, mock approval, +synthetic injection, redaction, wrong destinations and exclusive attachment. +Run `examples/lightpanda/test-lifecycle.sh IMAGE` to verify active expiry and +external worker-container crash invalidate the session and refuse reconnect. +It is not an interactive MCP service and never uses Turnkey credentials. + +For broker wiring, set `SBM_BROWSER=lightpanda` and `SBM_LIGHTPANDA_SOCKET` to the +operator-assigned private socket. No raw browser URL, extra browser arguments, +remote endpoint or automatic Browserbase fallback is accepted. Run `src/index.ts` +for stdio MCP after separately provisioning the worker. This single-worker +configuration is intended for private experiments, not a hosted shared service. + +## Validation scope + +The default unit/integration suite uses Chrome for existing end-to-end flows; +`test/lightpanda-integration.test.ts` is explicitly skipped unless a dedicated +worker socket is supplied. Run the offline script to cover the pinned Lightpanda +binary. Mock approval is a timer, not Turnkey quorum, and synthetic values are +not personal credentials. Do not interpret those tests as approval to configure +real secrets or as a complete browser sandbox audit. + +Before any real-secret deployment: review the exact image, isolated runtime, +worker allocation/termination guarantees, destination egress policy and transport +ownership; separately approve the personal Turnkey identity/project and narrowly +scoped credential configuration. No Kubernetes deployment, RBAC, TLS, ingress, +credential provisioning or network changes are performed by this implementation. diff --git a/docs/THREAT-MODEL.md b/docs/THREAT-MODEL.md index ab86ab9..794c27d 100644 --- a/docs/THREAT-MODEL.md +++ b/docs/THREAT-MODEL.md @@ -53,12 +53,31 @@ The hosted broker (`src/http.ts`, [HOSTED.md](HOSTED.md)) is a Turnkey-operated | Browserbase (default browser host) | Yes | From the moment the value crosses the CDP WebSocket (TLS) until the session ends. The value is in their browser, and they operate the machine it runs on. Recording and session logs are off. | | The session's browser | Yes | From injection until the session closes. The filled page holds the value as any form does. | | Operators with access to the broker host | Yes | Anyone who can read the broker's memory, attach a debugger, or change its image can read a value during a fill. | -| The state volume | No | Parked fills hold the export decryption key, encrypted with `SBM_STATE_KEY`. The key is not on the volume. | +| The state volume | No | Parked fills hold the export decryption key, encrypted with a per-tenant key derived from `SBM_STATE_KEY`. The key is not on the volume. | +| The tenant's authorization server | No | Never. It issues access tokens; the broker validates them and passes them nowhere. | | The target site | Yes | By design, as in v1. | -**Parked fills hold key material.** A parked fill contains the ephemeral private key for an export that approvers may still approve. Anyone with both the state volume and `SBM_STATE_KEY` can decrypt that export after approval. The broker encrypts each parked fill with AES-256-GCM and binds it to its file name, and drops parked fills after 24 hours. +**Tenants and tokens.** Each tenant has its own endpoint, Turnkey organization and API key, and trusted authorization servers ([HOSTED.md](HOSTED.md#tenants)). The broker accepts only JWT access tokens whose audience is exactly the tenant's, and no two tenants share an audience, so a token for one tenant fails at another even when both trust the same issuer. Every validation failure, including an unreachable JWKS, refuses the request. Tokens are never forwarded or logged. Each tool needs a scope, checked on every call, so a token can be limited to driving the browser. The shared bearer token remains only as a development mode (`SBM_DEV_SHARED_TOKEN`), off by default. -**Sessions.** Each MCP session gets its own browser. With local Chrome, the broker drives it over a pipe, so "nothing else attached" holds per session. With Browserbase, the broker cannot verify that nothing else is attached: Browserbase can attach, and anyone holding the session's connect URL can too. The broker never logs or returns that URL, and it never requests live-view or debug URLs. A session sees only its own pending fills. A fill whose session is gone (after a restart or reconnect) can be claimed by the session that presents its `fill_id`. The broker gave that random id only to the original session, but any holder of the bearer token and the id can claim the fill. Step 1 has one bearer token and one Turnkey key for all sessions, so every caller with the token can list and fill every secret the key can export. Per-tenant keys come in step 2. +Tenant isolation covers: + +- A caller reaches only its tenant's secrets, through the tenant's API key. Turnkey policy on that key bounds what it can export. Without per-tenant keys scoped by policy, tenant separation rests on the broker's routing alone. +- A session answers only to the tenant, subject (`iss`, `sub`), and OAuth client that opened it. +- A browser handle (`browser_id`) answers only to the tenant, subject, and OAuth client that opened it. For anyone else it does not exist. +- A parked fill belongs to one tenant and one subject, and is stored under the tenant's derived key. No other tenant can see or redeem it, and no other subject can claim it. +- With `allowedClients`, tokens issued to other clients are refused. + +It does not cover: + +- The broker process holds plaintext during every fill, for every tenant. A compromise of the process exposes every tenant's fills in progress. Run separate deployments for tenants that must not share a process. +- The authorization server is trusted for what it issues. If it is compromised, or issues tokens for the broker's audience to the wrong subjects, that tenant's secrets are exposed up to what its Turnkey policy and approvals allow. +- Without `allowedClients`, every client the authorization server issues tokens to is trusted, including dynamically registered clients. A parked fill belongs to the subject, not the client, so another trusted client of the same subject can claim it. +- `SBM_STATE_KEY` derives every tenant's key. The state volume and that key together decrypt every tenant's parked fills. +- Tokens are bearer tokens. A stolen one works until it expires, for 24 hours at most. Sender-constrained tokens (DPoP) are not supported. + +**Parked fills hold key material.** A parked fill contains the ephemeral private key for an export that approvers may still approve. Anyone with both the state volume and `SBM_STATE_KEY` can decrypt that export after approval. The broker encrypts each parked fill with AES-256-GCM under its tenant's key and binds it to its file name, and drops parked fills after 24 hours. + +**Sessions.** Each MCP session, and each browser handle, gets its own browser. With local Chrome, the broker drives it over a pipe, so "nothing else attached" holds per session. With Browserbase, the broker cannot verify that nothing else is attached: Browserbase can attach, and anyone holding the session's connect URL can too. The broker never logs or returns that URL, and it never requests live-view or debug URLs. A session sees only its own pending fills. A fill whose session is gone (after a restart or reconnect) can be claimed by a session of the same tenant and subject that presents its `fill_id`. The broker gave that random id only to the original session. **Browser callers.** The broker refuses any request whose `Origin` header is not in `SBM_ALLOWED_ORIGINS` (empty by default), before it checks the bearer token. A web page that a user opens cannot use the user's browser to call a broker on a private network (DNS rebinding). Non-browser clients send no `Origin` and depend on the bearer token alone. diff --git a/examples/lightpanda/Dockerfile b/examples/lightpanda/Dockerfile new file mode 100644 index 0000000..37d2a66 --- /dev/null +++ b/examples/lightpanda/Dockerfile @@ -0,0 +1,13 @@ +# Build context is an allowlisted export of this repository plus a verified binary. +FROM oven/bun@sha256:53710ce0f14eef8312521c586a7ae1d9aeab4011840f09f1966073b68fc2e2ab +WORKDIR /app +COPY package.json bun.lock ./ +RUN bun install --frozen-lockfile --ignore-scripts +COPY src ./src +COPY test/lightpanda-integration.test.ts test/lightpanda-worker-fixture.ts test/lightpanda-lifecycle.test.ts ./test/ +COPY scripts/probe-lightpanda-worker.ts ./scripts/probe-lightpanda-worker.ts +COPY lightpanda /usr/local/bin/lightpanda +RUN echo 'aa5a4b8ed53d1e38b3c73f5b2647d0a84a82e6744557f45f9a9c85858aa031c3 /usr/local/bin/lightpanda' | sha256sum -c - && chmod 0555 /usr/local/bin/lightpanda +USER 1000:1000 +ENTRYPOINT ["/usr/bin/env", "-i", "PATH=/usr/local/bin:/usr/bin:/bin", "HOME=/tmp", "bun", "run", "src/worker/lightpanda.ts"] +CMD ["/ipc/browser.sock"] diff --git a/examples/lightpanda/test-lifecycle.sh b/examples/lightpanda/test-lifecycle.sh new file mode 100755 index 0000000..627aee7 --- /dev/null +++ b/examples/lightpanda/test-lifecycle.sh @@ -0,0 +1,50 @@ +#!/bin/sh +# External orchestration owns crash/expiry tests; broker has no runtime socket. +set -eu +image=${1:?usage: test-lifecycle.sh LOCAL_IMAGE} +[ "$(id -u)" = 1000 ] || exit 1 +ipc_dir=$(mktemp -d /tmp/sbm-lifecycle.XXXXXX) +chmod 700 "$ipc_dir" +worker=sbm-worker-$(basename "$ipc_dir") +broker=sbm-probe-$(basename "$ipc_dir") +cleanup() { + docker rm -f "$broker" "$worker" >/dev/null 2>&1 || true + rm -rf "$ipc_dir" +} +trap cleanup EXIT HUP INT TERM +run() { + docker run --init --pull=never --network=none --read-only --user=1000:1000 \ + --cap-drop=ALL --security-opt=no-new-privileges --pids-limit=128 \ + --memory=512m --cpus=1 --ulimit core=0 \ + --tmpfs /tmp:rw,nosuid,nodev,noexec,size=67108864,mode=1777 "$@" +} +for mode in expiry crash; do + lifetime=300000 + [ "$mode" != expiry ] || lifetime=4000 + run -d --name "$worker" --mount "type=bind,src=$ipc_dir,dst=/ipc" \ + --entrypoint /usr/bin/env "$image" -i PATH=/usr/local/bin:/usr/bin:/bin HOME=/tmp \ + bun run test/lightpanda-worker-fixture.ts "$lifetime" >/dev/null + for i in 1 2 3 4 5; do + [ -S "$ipc_dir/browser.sock" ] && break + sleep 1 + done + run -d --name "$broker" --mount "type=bind,src=$ipc_dir,dst=/ipc,readonly" \ + --entrypoint /usr/bin/env "$image" -i PATH=/usr/local/bin:/usr/bin:/bin HOME=/tmp \ + SBM_TEST_LIFECYCLE_SOCKET=/ipc/browser.sock bun test test/lightpanda-lifecycle.test.ts >/dev/null + if [ "$mode" = crash ]; then + ready=false + for i in 1 2 3 4 5 6 7 8 9 10; do + case "$(docker logs "$broker" 2>&1)" in *WORKER_ATTACHED*) ready=true; break;; esac + sleep 1 + done + [ "$ready" = true ] || exit 1 + docker kill "$worker" >/dev/null + fi + status=$(docker wait "$broker") + docker logs "$broker" + [ "$status" = 0 ] || exit 1 + docker rm -f "$broker" "$worker" >/dev/null + # Only our disposable socket; an abrupt kill leaves it behind. + rm -f "$ipc_dir/browser.sock" + echo "PASS: $mode" +done diff --git a/examples/lightpanda/test-offline.sh b/examples/lightpanda/test-offline.sh new file mode 100755 index 0000000..a8af8f1 --- /dev/null +++ b/examples/lightpanda/test-offline.sh @@ -0,0 +1,31 @@ +#!/bin/sh +# Two containers, no network; only a disposable Unix socket directory is shared. +set -eu +image=${1:?usage: test-offline.sh LOCAL_IMAGE} +[ "$(id -u)" = 1000 ] || { echo 'This example requires Linux UID 1000.' >&2; exit 1; } +ipc_dir=$(mktemp -d /tmp/sbm-ipc.XXXXXX) +chmod 700 "$ipc_dir" +worker=sbm-worker-$(basename "$ipc_dir") +broker=sbm-probe-$(basename "$ipc_dir") +cleanup() { + docker stop -t 3 "$broker" "$worker" >/dev/null 2>&1 || true + rm -rf "$ipc_dir" +} +trap cleanup EXIT HUP INT TERM +run() { + docker run --rm --init --pull=never --network=none --read-only --user=1000:1000 \ + --cap-drop=ALL --security-opt=no-new-privileges --pids-limit=128 \ + --memory=512m --cpus=1 --ulimit core=0 \ + --tmpfs /tmp:rw,nosuid,nodev,noexec,size=67108864,mode=1777 "$@" +} +run -d --name "$worker" --mount "type=bind,src=$ipc_dir,dst=/ipc" \ + --entrypoint /usr/bin/env "$image" -i PATH=/usr/local/bin:/usr/bin:/bin HOME=/tmp \ + bun run test/lightpanda-worker-fixture.ts >/dev/null +for i in 1 2 3 4 5; do + [ -S "$ipc_dir/browser.sock" ] && break + sleep 1 +done +# Bounded by the test's 30s timeout and the worker's independent 5-minute lease. +run --name "$broker" --mount "type=bind,src=$ipc_dir,dst=/ipc,readonly" \ + --entrypoint /usr/bin/env "$image" -i PATH=/usr/local/bin:/usr/bin:/bin HOME=/tmp \ + SBM_TEST_WORKER_SOCKET=/ipc/browser.sock bun test test/lightpanda-integration.test.ts diff --git a/node_modules b/node_modules deleted file mode 120000 index b0b1a2f..0000000 --- a/node_modules +++ /dev/null @@ -1 +0,0 @@ -/Users/carey/tkhq/code/secure-browser-mcp/node_modules \ No newline at end of file diff --git a/package.json b/package.json index ab079dc..313901e 100644 --- a/package.json +++ b/package.json @@ -19,14 +19,16 @@ }, "dependencies": { "@modelcontextprotocol/node": "2.1.0", - "@modelcontextprotocol/server": "2.2.0", + "@modelcontextprotocol/server": "2.3.1", + "@turnkey/browser-secrets": "0.0.1-alpha.0", "@turnkey/crypto": "^2.12.0", "@turnkey/sdk-server": "^8.3.0", + "jose": "^6", "puppeteer-core": "^24.0.0", "zod": "4.6.5" }, "devDependencies": { - "@modelcontextprotocol/client": "2.2.0", + "@modelcontextprotocol/client": "2.3.1", "@types/bun": "^1.2.0", "@types/node": "^22.0.0", "prettier": "^3.0.0", diff --git a/scripts/import-secret.ts b/scripts/import-secret.ts index 47b9541..103b55e 100644 --- a/scripts/import-secret.ts +++ b/scripts/import-secret.ts @@ -1,6 +1,9 @@ /** Import a bound secret outside the agent session. Never accept plaintext in argv. */ import { parseArgs } from "node:util"; import { createInterface } from "node:readline/promises"; + +import { parseBinding } from "@turnkey/browser-secrets"; + import { BINDING_KEYS } from "../src/broker/types.js"; import { signerOverrideFromEnv, @@ -58,11 +61,11 @@ export function parseImport(args: string[], env: NodeJS.ProcessEnv) { if (pattern !== undefined) { if (!pattern.startsWith("/")) return fail("--url-pattern must be a pathname pattern starting with /."); - try { - new URLPattern({ pathname: pattern, baseURL: origin }); - } catch { - return fail("Invalid pathname pattern."); - } + const parsed = parseBinding({ + [BINDING_KEYS.origin]: origin, + [BINDING_KEYS.urlPattern]: pattern, + }); + if (parsed.status !== "bound") return fail("Invalid pathname pattern."); staticProperties[BINDING_KEYS.urlPattern] = pattern; } const variable = values["value-env"]; diff --git a/scripts/probe-lightpanda-worker.ts b/scripts/probe-lightpanda-worker.ts new file mode 100644 index 0000000..e228e93 --- /dev/null +++ b/scripts/probe-lightpanda-worker.ts @@ -0,0 +1,17 @@ +/** Credential-free compatibility test. Run in a separate network:none container. */ +import { LightpandaHost } from "../src/browser/lightpanda.js"; +import { BrowserSession } from "../src/browser/session.js"; +const session = new BrowserSession( + new LightpandaHost(process.argv[2] ?? "/ipc/browser.sock"), +); +try { + await session.ensureStarted(); + console.log("PASS: pinned Lightpanda satisfies the startup observation gate"); +} catch { + console.log( + "BLOCKED: pinned Lightpanda does not satisfy attachment/observation gate; no secrets used", + ); + process.exitCode = 2; +} finally { + await session.close(); +} diff --git a/skills/secure-browser/SKILL.md b/skills/secure-browser/SKILL.md index 8ec3709..8bd8ecf 100644 --- a/skills/secure-browser/SKILL.md +++ b/skills/secure-browser/SKILL.md @@ -38,6 +38,10 @@ Snapshots and fills target the main frame only. Cross-origin iframe inputs, incl 6. `snapshot` to verify: the filled field's value reads `[REDACTED:secret-filled-field]`. 7. `click` the submit button, then `snapshot` (repeat with short waits) until the page moves on. +## Browser handles + +If a browser tool says it needs a `browser_id`, or the page you navigated to is gone on the next call (`about:blank`), your connection keeps no browser between calls. Call `open_browser` once, then pass its `browser_id` to every browser tool, including `fill_secret` and `await_fill`. Call `close_browser` when the task is done. + ## Consensus approvals Some secrets need multi-party approval to export. `fill_secret` then returns `status: "pending_approval"` with a `fill_id`, a Turnkey `activity_id`, and usually an `approval_url` instead of filling. This is normal, not an error: diff --git a/skills/secure-browser/references/tools.md b/skills/secure-browser/references/tools.md index ac1f5b7..2eda217 100644 --- a/skills/secure-browser/references/tools.md +++ b/skills/secure-browser/references/tools.md @@ -1,6 +1,16 @@ # Tool reference -Seven working tools and one registered, unavailable stub. All results are redacted server-side before you see them. +Nine working tools and one registered, unavailable stub. All results are redacted server-side before you see them. + +Every browser tool (navigate, snapshot, click, type_text, fill_secret, await_fill, list_network_requests) also takes an optional `browser_id` from `open_browser`. With it, the tool acts in that browser. Without it, the tool uses the connection's own browser. A connection with no browser of its own (an MCP 2026-07-28 client) must pass `browser_id`. + +## open_browser + +No arguments. Returns `{ browser_id, idle_timeout_seconds }`. Opens a broker-owned browser that outlives this connection: pass the `browser_id` to every later browser tool, from any connection of the same caller. Each browser has its own pages, element uids, redaction, and pending fills. The browser closes after `idle_timeout_seconds` without a tool call. Fails when the caller holds its limit of browsers; close one first. + +## close_browser + +`{ browser_id: string }` → `{ closed }`. Closes the browser. A fill still pending approval stays valid: pass its `fill_id` to `await_fill` in another browser. An unknown, closed, or timed-out `browser_id` fails with `Unknown browser_id`. ## list_secret_refs diff --git a/src/auth/challenge.ts b/src/auth/challenge.ts new file mode 100644 index 0000000..c6219f7 --- /dev/null +++ b/src/auth/challenge.ts @@ -0,0 +1,46 @@ +/** + * What the hosted broker tells an OAuth client: Protected Resource Metadata + * (RFC 9728) and the WWW-Authenticate challenges (RFC 6750 §3) that point + * at it, as MCP authorization requires. + */ +import { SCOPES } from "./scopes.js"; +import { resourceUri, type TenantConfig } from "./tenants.js"; + +export function protectedResourceMetadata( + publicUrl: string, + tenant: TenantConfig, +) { + return { + resource: resourceUri(publicUrl, tenant.id), + authorization_servers: + tenant.authorizationServers ?? tenant.issuers.map((i) => i.issuer), + scopes_supported: [...SCOPES], + bearer_methods_supported: ["header"], + resource_name: `secure-browser-mcp (${tenant.id})`, + }; +} + +/** RFC 6750 auth-param values are quoted strings; keep them printable and + * free of quotes and backslashes. */ +function quote(value: string): string { + return `"${value.replace(/[^\x20-\x7e]|["\\]/g, "")}"`; +} + +export type Challenge = { + /** Omitted when the request carried no Bearer credentials (RFC 6750 + * §3.1). */ + error?: "invalid_request" | "invalid_token" | "insufficient_scope"; + description?: string; + /** Scopes the client should request. */ + scopes: readonly string[]; + resourceMetadata: string; +}; + +export function wwwAuthenticate(c: Challenge): string { + const params: [string, string][] = []; + if (c.error) params.push(["error", c.error]); + if (c.description) params.push(["error_description", c.description]); + params.push(["scope", c.scopes.join(" ")]); + params.push(["resource_metadata", c.resourceMetadata]); + return `Bearer ${params.map(([k, v]) => `${k}=${quote(v)}`).join(", ")}`; +} diff --git a/src/auth/scopes.ts b/src/auth/scopes.ts new file mode 100644 index 0000000..f57361c --- /dev/null +++ b/src/auth/scopes.ts @@ -0,0 +1,27 @@ +/** + * OAuth scopes for the hosted broker (docs/HOSTED.md, "Scopes"). Every tool + * declares one in its definition (ToolDef.scope), so a new tool cannot ship + * without one. The stdio broker ignores them. + */ +export const SCOPES = ["sbm:browse", "sbm:fill", "sbm:refs"] as const; + +export type Scope = (typeof SCOPES)[number]; + +/** The union of `scope` (RFC 9068, space-separated) and `scp` (Okta and + * others: a string or an array of strings). */ +export function parseScopes(claims: { + scope?: unknown; + scp?: unknown; +}): Set { + const scopes = new Set(); + for (const claim of [claims.scope, claims.scp]) { + const parts = + typeof claim === "string" + ? claim.split(" ") + : Array.isArray(claim) + ? claim.filter((s): s is string => typeof s === "string") + : []; + for (const s of parts) if (s) scopes.add(s); + } + return scopes; +} diff --git a/src/auth/tenants.ts b/src/auth/tenants.ts new file mode 100644 index 0000000..87cd965 --- /dev/null +++ b/src/auth/tenants.ts @@ -0,0 +1,246 @@ +/** + * Tenant configuration for the hosted broker (docs/HOSTED.md, "Tenants"). + * For a Turnkey tenant the file holds references to keys, never keys. A + * mock tenant (development and tests only, refused at startup without + * SBM_DEV_SHARED_TOKEN) may hold its fake secret values inline, in + * `mockSecrets`. + */ +import { readFileSync } from "node:fs"; + +import { z } from "zod"; + +/** Tenant ids appear in URL paths and state directory names. */ +const TENANT_ID = /^[a-z0-9][a-z0-9-]{0,62}$/; + +/** HTTPS, or HTTP to a loopback host for local development and tests. */ +function secureUrl(value: string): boolean { + try { + const url = new URL(value); + if (url.protocol === "https:") return true; + return ( + url.protocol === "http:" && + ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname) + ); + } catch { + return false; + } +} + +const url = z + .string() + .refine(secureUrl, "must be an https URL (http only for localhost)"); + +const issuerSchema = z + .object({ + /** Exact `iss` value of the authorization server's tokens. */ + issuer: url, + /** Where that server publishes its signing keys. */ + jwksUri: url, + /** Also require the `typ: at+jwt` header (RFC 9068 §2.1). Off by + * default, since many servers do not set it; on by default when the + * tenant's `audience` is not its resource URI (tenantIssuers). */ + requireTyp: z.boolean().optional(), + }) + .strict(); + +/** A Turnkey API key by reference: two environment variables, or a JSON + * file `{"publicKey", "privateKey"}` in a secrets mount. */ +const apiKeySchema = z.union([ + z + .object({ + publicKeyEnv: z.string().min(1), + privateKeyEnv: z.string().min(1), + }) + .strict(), + z.object({ path: z.string().min(1) }).strict(), +]); + +const mockSecretSchema = z + .object({ + name: z.string().min(1), + value: z.string(), + staticProperties: z.record(z.string(), z.string()), + }) + .strict(); + +const common = { + id: z.string().regex(TENANT_ID, "lowercase letters, digits, and dashes"), + issuers: z.array(issuerSchema).min(1), + /** Advertised in Protected Resource Metadata. Defaults to the issuers. */ + authorizationServers: z.array(url).min(1).optional(), + /** The exact `aud`, when it cannot be the resource URI (Entra ID's + * `api://`). Must be unique across tenants (checkAudiences). */ + audience: z.string().min(1).optional(), + /** Accepted clients, by `azp` else `client_id`. Default: every client. */ + allowedClients: z.array(z.string().min(1)).min(1).optional(), +}; + +const tenantSchema = z.discriminatedUnion("backend", [ + z + .object({ + ...common, + backend: z.literal("turnkey"), + /** The tenant's Turnkey (sub-)organization. */ + organizationId: z.string().min(1), + apiKey: apiKeySchema, + }) + .strict(), + // Development and tests only: in-memory secrets, seeded per tenant. + z + .object({ + ...common, + backend: z.literal("mock"), + mockSecrets: z.array(mockSecretSchema).optional(), + /** Tests only: how long listing secrets takes. */ + listDelayMs: z.number().int().positive().optional(), + }) + .strict(), +]); + +const fileSchema = z + .object({ + tenants: z + .array( + // `backend` defaults to turnkey; mock must be asked for by name. + z.preprocess( + (t) => + t && typeof t === "object" && !("backend" in t) + ? { ...t, backend: "turnkey" } + : t, + tenantSchema, + ), + ) + .min(1), + }) + .strict(); + +export type TenantConfig = z.infer; +export type TrustedIssuer = z.infer; + +/** Parse and check a tenants document. Throws with every problem found. */ +export function parseTenants(raw: unknown): TenantConfig[] { + const parsed = fileSchema.safeParse(raw); + if (!parsed.success) { + const problems = parsed.error.issues + .map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`) + .join("; "); + throw new Error(`Invalid tenant config: ${problems}`); + } + const tenants = parsed.data.tenants as TenantConfig[]; + const seen = new Set(); + for (const t of tenants) { + if (seen.has(t.id)) throw new Error(`Duplicate tenant id: ${t.id}`); + seen.add(t.id); + } + return tenants; +} + +export function loadTenants(path: string): TenantConfig[] { + let raw: unknown; + try { + raw = JSON.parse(readFileSync(path, "utf8")); + } catch (err) { + const reason = err instanceof Error ? err.message : String(err); + throw new Error(`Cannot read SBM_TENANTS (${path}): ${reason}`); + } + return parseTenants(raw); +} + +/** Resolve a tenant's Turnkey API key reference. */ +export function resolveApiKey( + tenant: Extract, +): { apiPublicKey: string; apiPrivateKey: string } { + const ref = tenant.apiKey; + if ("path" in ref) { + let key: { publicKey?: unknown; privateKey?: unknown }; + try { + key = JSON.parse(readFileSync(ref.path, "utf8")); + } catch { + throw new Error(`Tenant ${tenant.id}: cannot read API key file`); + } + if (typeof key.publicKey !== "string" || typeof key.privateKey !== "string") + throw new Error( + `Tenant ${tenant.id}: API key file needs publicKey and privateKey`, + ); + return { apiPublicKey: key.publicKey, apiPrivateKey: key.privateKey }; + } + const apiPublicKey = process.env[ref.publicKeyEnv]; + const apiPrivateKey = process.env[ref.privateKeyEnv]; + if (!apiPublicKey || !apiPrivateKey) { + throw new Error( + `Tenant ${tenant.id}: set ${ref.publicKeyEnv} and ${ref.privateKeyEnv}`, + ); + } + return { apiPublicKey, apiPrivateKey }; +} + +/** + * SBM_PUBLIC_URL: the origin clients reach the broker at, e.g. + * https://sbm.example.com. Tenant resource URIs, and so token audiences, + * are built from it, so it must not come from request headers. + */ +export function parsePublicUrl(value: string): string { + const parsed = secureUrl(value) ? new URL(value) : undefined; + if (!parsed || parsed.pathname !== "/" || parsed.search || parsed.hash) { + throw new Error( + "SBM_PUBLIC_URL must be an https origin, e.g. https://sbm.example.com", + ); + } + return parsed.origin; +} + +/** RFC 8707 resource indicator, and the default token audience. */ +export function resourceUri(publicUrl: string, id: string): string { + return `${publicUrl}/t/${id}/mcp`; +} + +/** The exact `aud` the tenant's tokens must carry. */ +export function tenantAudience( + publicUrl: string, + tenant: TenantConfig, +): string { + return tenant.audience ?? resourceUri(publicUrl, tenant.id); +} + +/** + * The tenant's issuers, with `requireTyp` on by default when `audience` is + * not the resource URI. An override can be a client id, which is also the + * `aud` of that client's ID tokens, and an ID token without `nonce`, + * `at_hash`, or `c_hash` differs from an access token only by `typ`. An + * explicit `requireTyp: false` (Entra ID, which sends `typ: JWT`) is kept. + */ +export function tenantIssuers( + publicUrl: string, + tenant: TenantConfig, +): TrustedIssuer[] { + const overridden = + tenantAudience(publicUrl, tenant) !== resourceUri(publicUrl, tenant.id); + return tenant.issuers.map((i) => ({ + ...i, + requireTyp: i.requireTyp ?? overridden, + })); +} + +/** Refuse two tenants with one audience: the audience is what keeps one + * tenant's token from working at another that trusts the same issuer. */ +export function checkAudiences( + publicUrl: string, + tenants: readonly TenantConfig[], +): void { + const owner = new Map(); + for (const t of tenants) { + const aud = tenantAudience(publicUrl, t); + const other = owner.get(aud); + if (other !== undefined) { + throw new Error( + `Tenants ${other} and ${t.id} accept the same token audience`, + ); + } + owner.set(aud, t.id); + } +} + +/** RFC 9728 §3.1: the well-known path goes before the resource path. */ +export function metadataUri(publicUrl: string, id: string): string { + return `${publicUrl}/.well-known/oauth-protected-resource/t/${id}/mcp`; +} diff --git a/src/auth/tokens.ts b/src/auth/tokens.ts new file mode 100644 index 0000000..44c26bc --- /dev/null +++ b/src/auth/tokens.ts @@ -0,0 +1,144 @@ +/** + * Access-token validation for the hosted broker, an OAuth 2.1 resource + * server. The rules are in docs/HOSTED.md ("Authorization"). They follow + * RFC 9068 with two differences: `typ: at+jwt` is checked only for an + * issuer with `requireTyp` (on by default when the tenant overrides its + * audience, see tenantIssuers), because many servers do not send it; and + * `aud` must be a single value. + * + * Every failure, including a JWKS that cannot be fetched, rejects the + * token. The token itself goes nowhere: not to Turnkey, not to Browserbase, + * not to the logs. + */ +import { + createRemoteJWKSet, + decodeJwt, + jwtVerify, + type JWTVerifyGetKey, +} from "jose"; + +import { parseScopes } from "./scopes.js"; +import type { TrustedIssuer } from "./tenants.js"; + +export const CLOCK_SKEW_S = 30; + +/** The longest lifetime the broker accepts, so a stolen token works for a + * day at most. */ +export const MAX_TOKEN_LIFETIME_S = 24 * 60 * 60; + +const ALGORITHMS = ["RS256", "PS256", "ES256", "ES384", "EdDSA"]; + +/** Who a validated token speaks for. */ +export type Principal = { + issuer: string; + subject: string; + /** `azp`, else `client_id`. Undefined when the token names no client: + * then nothing binds the caller to a client, only to (iss, sub). */ + client: string | undefined; + scopes: Set; + /** `exp`, in seconds since the epoch. */ + expiresAt: number; +}; + +export class InvalidTokenError extends Error { + override name = "InvalidTokenError"; +} + +/** One remote key set per JWKS URI, shared by every tenant that trusts the + * issuer. jose caches it and refetches for an unknown kid. */ +export class JwksCache { + private readonly sets = new Map(); + + get(jwksUri: string): JWTVerifyGetKey { + let set = this.sets.get(jwksUri); + if (!set) { + set = createRemoteJWKSet(new URL(jwksUri), { + cooldownDuration: 30_000, + cacheMaxAge: 10 * 60_000, + timeoutDuration: 5_000, + }); + this.sets.set(jwksUri, set); + } + return set; + } +} + +export async function verifyAccessToken( + token: string, + expect: { + audience: string; + issuers: TrustedIssuer[]; + allowedClients?: readonly string[] | undefined; + }, + jwks: JwksCache, +): Promise { + // Pick the key set by the unverified issuer, then verify that exact + // issuer: a token cannot choose a key set its tenant does not trust. + let unverifiedIss: unknown; + try { + unverifiedIss = decodeJwt(token).iss; + } catch { + throw new InvalidTokenError("Malformed token"); + } + const trusted = expect.issuers.find((i) => i.issuer === unverifiedIss); + if (!trusted) throw new InvalidTokenError("Untrusted issuer"); + + let payload; + try { + ({ payload } = await jwtVerify(token, jwks.get(trusted.jwksUri), { + issuer: trusted.issuer, + audience: expect.audience, + algorithms: ALGORITHMS, + clockTolerance: CLOCK_SKEW_S, + requiredClaims: ["exp", "sub"], + // jose compares typ case-insensitively and accepts application/at+jwt. + ...(trusted.requireTyp ? { typ: "at+jwt" } : {}), + })); + } catch { + // jose's message can carry claim values; keep the reason generic. + throw new InvalidTokenError("Token rejected"); + } + + // jose accepts an aud array that merely contains the audience. + const aud = payload.aud; + const audiences = Array.isArray(aud) ? aud : [aud]; + if (audiences.length !== 1 || audiences[0] !== expect.audience) { + throw new InvalidTokenError("Token audience is not this resource"); + } + if (typeof payload.sub !== "string" || payload.sub === "") { + throw new InvalidTokenError("Token has no subject"); + } + // nonce, at_hash, and c_hash mark an OpenID Connect ID token. Not + // checked by typ, since Entra ID and others send `typ: JWT` on access + // tokens too. + if ("nonce" in payload || "at_hash" in payload || "c_hash" in payload) { + throw new InvalidTokenError("Token is an ID token"); + } + // jose has checked that exp (required) and iat (if present) are numbers. + const exp = payload.exp!; + const now = Math.floor(Date.now() / 1000); + if ( + exp > now + MAX_TOKEN_LIFETIME_S + CLOCK_SKEW_S || + (payload.iat !== undefined && exp - payload.iat > MAX_TOKEN_LIFETIME_S) + ) { + throw new InvalidTokenError("Token lifetime is too long"); + } + const client = [payload["azp"], payload["client_id"]].find( + (c): c is string => typeof c === "string" && c !== "", + ); + // With allowedClients, a token must name its client. Without the list, a + // token without a client claim is bound to (iss, sub) only. + if ( + expect.allowedClients && + (client === undefined || !expect.allowedClients.includes(client)) + ) { + throw new InvalidTokenError("Token client is not allowed"); + } + return { + issuer: trusted.issuer, + subject: payload.sub, + client, + scopes: parseScopes(payload), + expiresAt: exp, + }; +} diff --git a/src/broker/binding.ts b/src/broker/binding.ts deleted file mode 100644 index 0f47116..0000000 --- a/src/broker/binding.ts +++ /dev/null @@ -1,87 +0,0 @@ -import type { FillTarget, SecretRef } from "./types.js"; - -/** Thrown when a fill is attempted outside the secret's declared destination. */ -export class BindingViolationError extends Error { - constructor(ref: SecretRef, target: FillTarget, reason: string) { - super( - `Refusing to fill "${ref.name ?? ref.secretId}" into ${target.pageUrl}: ${reason}`, - ); - this.name = "BindingViolationError"; - } -} - -/** Page-side facts the policy needs but cannot compute itself. */ -export type BindingProbe = { - /** Whether the resolved target element matches a CSS selector. */ - elementMatches: (selector: string) => Promise; -}; - -/** - * Destination-binding enforcement — the broker-side prompt-injection defense. - * A malicious page can talk the agent into *requesting* a fill, but the fill - * only proceeds when the live page matches the binding baked into the - * secret's static properties at import time. - * - * Longer-term the same check moves into Turnkey's policy engine (static - * properties are policy-visible), making even the export unobtainable for a - * non-matching destination. - */ -export class BindingPolicy { - /** Throws `BindingViolationError` unless the fill target satisfies the binding. */ - async assertAllowed( - ref: SecretRef, - target: FillTarget, - probe: BindingProbe, - ): Promise { - if (ref.bindingError) { - throw new BindingViolationError( - ref, - target, - `secret's destination binding is invalid (${ref.bindingError})`, - ); - } - const binding = ref.binding; - if (!binding) { - throw new BindingViolationError( - ref, - target, - "secret has no destination binding; unbound secrets are not fillable", - ); - } - - const url = new URL(target.pageUrl); - if (url.origin !== binding.origin) { - throw new BindingViolationError( - ref, - target, - `page origin ${url.origin} does not match bound origin ${binding.origin}`, - ); - } - - if (binding.urlPattern) { - const pattern = new URLPattern({ - pathname: binding.urlPattern, - baseURL: binding.origin, - }); - if (!pattern.test(target.pageUrl)) { - throw new BindingViolationError( - ref, - target, - `page path ${url.pathname} does not match bound pattern ${binding.urlPattern}`, - ); - } - } - - if (binding.selector) { - const matches = await probe.elementMatches(binding.selector); - if (!matches) { - throw new BindingViolationError( - ref, - target, - `target element${target.selectorInfo ? ` (${target.selectorInfo})` : ""} ` + - `does not match bound selector ${binding.selector}`, - ); - } - } - } -} diff --git a/src/broker/mock-secrets.ts b/src/broker/mock-secrets.ts index 634087c..9624daa 100644 --- a/src/broker/mock-secrets.ts +++ b/src/broker/mock-secrets.ts @@ -1,3 +1,5 @@ +import { createSecretRef } from "@turnkey/browser-secrets"; + import { ConsensusNeededError, ConsensusPendingError, @@ -5,7 +7,7 @@ import { type SecretsClient, } from "./secrets-client.js"; import type { ExportedSecret, SecretRef } from "./types.js"; -import { BINDING_KEYS, type SecretBinding } from "./types.js"; +import { BINDING_KEYS } from "./types.js"; type MockSecret = { name: string; @@ -18,75 +20,10 @@ type MockOptions = { consensusNames?: string[]; /** How long after the first export attempt approval "arrives". */ approvalDelayMs?: number; + /** Tests only: how long listRefs takes, to hold a call in flight. */ + listDelayMs?: number | undefined; }; -/** - * Parse a destination binding from static properties. Returns `{}` when no - * `sbm:origin` is declared, and `{ bindingError }` when a declared part is - * malformed. A malformed part never falls back to a looser binding: a bad - * `sbm:fields` must not turn a card secret into an origin-only one. - */ -export function parseBinding( - staticProperties: Record, -): Pick { - const origin = staticProperties[BINDING_KEYS.origin]; - if (origin === undefined) return {}; - const invalid = (reason: string) => ({ bindingError: reason }); - - let parsedOrigin: string; - try { - parsedOrigin = new URL(origin).origin; - } catch { - return invalid(`${BINDING_KEYS.origin} is not a URL`); - } - if (parsedOrigin === "null" || parsedOrigin !== origin) { - return invalid( - `${BINDING_KEYS.origin} must be an origin like https://example.com`, - ); - } - const binding: SecretBinding = { origin }; - - const urlPattern = staticProperties[BINDING_KEYS.urlPattern]; - if (urlPattern !== undefined) { - try { - new URLPattern({ pathname: urlPattern, baseURL: origin }); - } catch { - return invalid(`${BINDING_KEYS.urlPattern} is not a valid URL pattern`); - } - binding.urlPattern = urlPattern; - } - - const selector = staticProperties[BINDING_KEYS.selector]; - if (selector !== undefined) { - if (!selector.trim()) return invalid(`${BINDING_KEYS.selector} is empty`); - binding.selector = selector; - } - - const fields = staticProperties[BINDING_KEYS.fields]; - if (fields !== undefined) { - let map: unknown; - try { - map = JSON.parse(fields); - } catch { - return invalid(`${BINDING_KEYS.fields} is not JSON`); - } - const entries = - map && typeof map === "object" && !Array.isArray(map) - ? Object.entries(map) - : []; - if ( - entries.length === 0 || - entries.some(([k, v]) => !k || typeof v !== "string" || !v.trim()) - ) { - return invalid( - `${BINDING_KEYS.fields} must map payload keys to CSS selectors`, - ); - } - binding.fields = Object.fromEntries(entries) as Record; - } - return { binding }; -} - /** * In-memory secrets backend for development until Secrets API beta access / * a published SDK release. Mirrors the shapes of `TurnkeySecretsClient`. @@ -95,6 +32,7 @@ export class MockSecretsClient implements SecretsClient { private readonly secrets = new Map(); private readonly consensusNames: Set; private readonly approvalDelayMs: number; + private readonly listDelayMs: number; /** secretId → when the first export attempt happened; approval "arrives" * approvalDelayMs later, simulating an out-of-band approver. */ private readonly pendingSince = new Map(); @@ -105,18 +43,20 @@ export class MockSecretsClient implements SecretsClient { } this.consensusNames = new Set(opts?.consensusNames ?? []); this.approvalDelayMs = opts?.approvalDelayMs ?? 3_000; + this.listDelayMs = opts?.listDelayMs ?? 0; } async listRefs(): Promise { - return [...this.secrets.entries()].map(([secretId, s]) => { - const ref: SecretRef = { + if (this.listDelayMs > 0) { + await new Promise((r) => setTimeout(r, this.listDelayMs)); + } + return [...this.secrets.entries()].map(([secretId, s]) => + createSecretRef({ secretId, name: s.name, staticProperties: s.staticProperties, - }; - Object.assign(ref, parseBinding(s.staticProperties)); - return ref; - }); + }), + ); } async exportSecret(ref: SecretRef): Promise { diff --git a/src/broker/pending-store.ts b/src/broker/pending-store.ts index d913bcf..616ee1c 100644 --- a/src/broker/pending-store.ts +++ b/src/broker/pending-store.ts @@ -12,17 +12,22 @@ * hosted broker can restart without stranding an approval. The key comes * from the deployment (SBM_STATE_KEY), never from the state directory. * - * Every fill has an owner: the agent session that parked it. Tools only see - * their own session's fills through `scopedFills`. A fill whose owner - * session no longer exists (the broker restarted, or the client - * reconnected) can be claimed by the session that presents its fill_id. - * The fill_id is a random UUID the broker gave only to the original - * session, so presenting it is the proof of ownership. + * Every fill has an owner, the agent session that parked it, and a + * principal, the caller that session speaks for (hosted: the token's iss + * and sub). Tools see only their own session's fills through `scopedFills`. + * A fill whose owner session is gone (restart, reconnect) can be claimed by + * another session of the same principal that presents its fill_id. The + * fill_id is a random UUID the broker gave only to the original session; + * the principal check keeps a leaked id from working for anyone else. + * + * The hosted broker keeps one store per tenant, in its own directory under + * its own key (`tenantStateKey`). */ import { createCipheriv, createDecipheriv, createHash, + hkdfSync, randomBytes, } from "node:crypto"; import { mkdirSync, readdirSync, readFileSync } from "node:fs"; @@ -46,7 +51,12 @@ export type PendingFill = { createdAt: number; }; -export type StoredFill = PendingFill & { owner: string }; +export type StoredFill = PendingFill & { + /** Session that parked the fill, or that last claimed it. */ + owner: string; + /** Who the owning session acts for. Never changes. */ + principal: string; +}; /** Parked fills older than this are dropped. Turnkey activities do not * wait forever either. */ @@ -64,21 +74,33 @@ export class MemoryPendingFillStore { /** * The fill, if `owner` may use it: it owns it, or the owning session is - * gone and the fill moves to `owner`. Checked and moved synchronously, so - * two sessions cannot both claim one orphan. + * gone, `principal` is the fill's principal, and the fill moves to + * `owner`. Checked and moved synchronously, so two sessions cannot both + * claim one orphan. */ async claim( fillId: string, owner: string, + principal: string, isLive: (owner: string) => boolean, ): Promise { this.expire(); const fill = this.fills.get(fillId); - if (!fill) return undefined; + if (!fill || fill.principal !== principal) return undefined; if (fill.owner === owner) return fill; if (isLive(fill.owner)) return undefined; const claimed = { ...fill, owner }; - await this.put(claimed); + // In memory before the write, so a claim that arrives while the file + // store writes sees the new owner. + this.fills.set(fillId, claimed); + try { + await this.put(claimed); + } catch (err) { + // The claim did not persist: give the fill back to its owner, unless + // something replaced it meanwhile. + if (this.fills.get(fillId) === claimed) this.fills.set(fillId, fill); + throw err; + } return claimed; } @@ -188,6 +210,20 @@ export function parseStateKey(value: string): Buffer { return key; } +/** A tenant's state key: HKDF-SHA256 of SBM_STATE_KEY with the tenant id + * as context, so one tenant's files do not decrypt under another's key. */ +export function tenantStateKey(master: Buffer, tenantId: string): Buffer { + return Buffer.from( + hkdfSync( + "sha256", + master, + "secure-browser-mcp pending fills", + `tenant:${tenantId}`, + 32, + ), + ); +} + /** One agent session's view of the store. */ export type PendingFills = { list(): PendingFill[]; @@ -198,15 +234,20 @@ export type PendingFills = { export function scopedFills( store: MemoryPendingFillStore, - owner: string, - isLive: (owner: string) => boolean = () => true, + session: { + owner: string; + principal: string; + isLive?: (owner: string) => boolean; + }, ): PendingFills { + const { owner, principal, isLive = () => true } = session; return { list: () => store.list(owner), - get: (fillId) => store.claim(fillId, owner, isLive), - put: (fill) => store.put({ ...fill, owner }), + get: (fillId) => store.claim(fillId, owner, principal, isLive), + put: (fill) => store.put({ ...fill, owner, principal }), delete: async (fillId) => { - if (await store.claim(fillId, owner, isLive)) await store.delete(fillId); + if (await store.claim(fillId, owner, principal, isLive)) + await store.delete(fillId); }, }; } diff --git a/src/broker/turnkey-env.ts b/src/broker/turnkey-env.ts index 69ac000..365eb13 100644 --- a/src/broker/turnkey-env.ts +++ b/src/broker/turnkey-env.ts @@ -8,16 +8,26 @@ import { Turnkey, type TurnkeyApiClient } from "@turnkey/sdk-server"; export function turnkeyClientFromEnv(): TurnkeyApiClient | undefined { const apiPrivateKey = process.env["TURNKEY_API_PRIVATE_KEY"]; const apiPublicKey = process.env["TURNKEY_API_PUBLIC_KEY"]; - const defaultOrganizationId = process.env["TURNKEY_ORGANIZATION_ID"]; - if (!apiPrivateKey || !apiPublicKey || !defaultOrganizationId) { + const organizationId = process.env["TURNKEY_ORGANIZATION_ID"]; + if (!apiPrivateKey || !apiPublicKey || !organizationId) { return undefined; } + return turnkeyClient({ apiPrivateKey, apiPublicKey, organizationId }); +} + +/** A Turnkey API client for one organization, against TURNKEY_API_BASE_URL. + * The hosted broker builds one per tenant. */ +export function turnkeyClient(key: { + apiPublicKey: string; + apiPrivateKey: string; + organizationId: string; +}): TurnkeyApiClient { return new Turnkey({ apiBaseUrl: process.env["TURNKEY_API_BASE_URL"] ?? "https://api.turnkey.com", - apiPrivateKey, - apiPublicKey, - defaultOrganizationId, + apiPrivateKey: key.apiPrivateKey, + apiPublicKey: key.apiPublicKey, + defaultOrganizationId: key.organizationId, }).apiClient(); } diff --git a/src/broker/turnkey-secrets.ts b/src/broker/turnkey-secrets.ts index 1bcd495..5cb9352 100644 --- a/src/broker/turnkey-secrets.ts +++ b/src/broker/turnkey-secrets.ts @@ -1,3 +1,4 @@ +import { createSecretRef } from "@turnkey/browser-secrets"; import { generateP256KeyPair } from "@turnkey/crypto"; import type { TurnkeyApiClient } from "@turnkey/sdk-server"; @@ -8,7 +9,6 @@ import { type SecretsClient, } from "./secrets-client.js"; import type { ExportedSecret, SecretRef } from "./types.js"; -import { parseBinding } from "./mock-secrets.js"; import { signerOverrideFromEnv } from "./turnkey-env.js"; /** Proposal shape returned by createExportSecretsProposal; the broker only @@ -60,13 +60,13 @@ export class TurnkeySecretsClient implements SecretsClient { }, }); for (const s of secrets) { - const ref: SecretRef = { - secretId: s.secretId, - staticProperties: s.staticProperties, - }; - if (s.name !== undefined) ref.name = s.name; - Object.assign(ref, parseBinding(s.staticProperties)); - refs.push(ref); + refs.push( + createSecretRef({ + secretId: s.secretId, + name: s.name, + staticProperties: s.staticProperties, + }), + ); } if (secrets.length < PAGE_SIZE) break; after = secrets[secrets.length - 1]!.secretId; diff --git a/src/broker/types.ts b/src/broker/types.ts index b77933e..774fbe1 100644 --- a/src/broker/types.ts +++ b/src/broker/types.ts @@ -7,51 +7,17 @@ */ /** - * Well-known keys for Turnkey secret `staticProperties`. Static properties - * are bound immutably to a secret at import time and are policy-visible, so - * bindings expressed through them are enforced both broker-side (here) and, - * eventually, by Turnkey's policy engine on the export activity itself. + * Secret references and destination bindings come from + * @turnkey/browser-secrets, so the broker and other hosts parse the same + * `sbm:*` static properties the same way. Static properties are bound + * immutably to a Turnkey secret at import time and are policy-visible. */ -export const BINDING_KEYS = { - /** Required. Exact origin the secret may be filled into, e.g. "https://github.com". */ - origin: "sbm:origin", - /** Optional. URL pattern (URLPattern syntax) narrowing beyond origin, e.g. "/login*". */ - urlPattern: "sbm:url-pattern", - /** Optional. CSS selector the target field must match, e.g. "input[type=password]". */ - selector: "sbm:selector", - /** - * Optional. For secrets whose value is a JSON object filled into several - * fields under ONE export (one approval): a JSON-encoded map from payload - * key to the CSS selector its field must match, e.g. - * `{"number":"input[name=cardNumber]","cvc":"input[name=cardCvc]"}`. - */ - fields: "sbm:fields", -} as const; - -/** Destination binding parsed out of a secret's static properties. */ -export type SecretBinding = { - origin: string; - urlPattern?: string; - selector?: string; - /** Per-payload-key selectors for JSON multi-field secrets. */ - fields?: Record; -}; - -/** - * The agent-visible handle for a secret. Contains metadata only — never the - * secret value. Safe to serialize into tool results. - */ -export type SecretRef = { - secretId: string; - name?: string; - /** Full static properties, including non-binding ones. */ - staticProperties: Record; - /** Parsed destination binding, if the static properties declare one. */ - binding?: SecretBinding; - /** Why the declared binding is unusable. Set instead of `binding`; a - * secret with a malformed binding is never fillable. */ - bindingError?: string; -}; +export { + BINDING_KEYS, + type SecretBinding, + type SecretRef, +} from "@turnkey/browser-secrets"; +import type { SecretRef } from "@turnkey/browser-secrets"; /** * Broker-internal carrier for exported plaintext. Never serialized into a @@ -66,12 +32,3 @@ export type ExportedSecret = { value: string; release: () => void; }; - -/** Element info the binding policy evaluates a fill against. */ -export type FillTarget = { - pageUrl: string; - /** Stable element handle from the most recent snapshot. */ - elementUid: string; - /** Resolved element facts (tag, type, name/id, matched selector), if known. */ - selectorInfo?: string; -}; diff --git a/src/browser/hosts.ts b/src/browser/hosts.ts index 2e88a70..64835a4 100644 --- a/src/browser/hosts.ts +++ b/src/browser/hosts.ts @@ -14,12 +14,14 @@ import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import puppeteer, { type Browser } from "puppeteer-core"; +import puppeteer, { type Browser, type Page } from "puppeteer-core"; export interface BrowserHost { - readonly kind: "local" | "browserbase"; + readonly kind: "local" | "browserbase" | "lightpanda"; /** Start a fresh browser for one agent session. */ start(): Promise; + /** Optional host-specific fresh page creation and compatibility gate. */ + openPage?(browser: Browser): Promise; /** Tear the browser down; safe to call when nothing is running. */ stop(): Promise; } diff --git a/src/browser/inject.ts b/src/browser/inject.ts index b6b53e1..2d7479e 100644 --- a/src/browser/inject.ts +++ b/src/browser/inject.ts @@ -1,5 +1,5 @@ -import type { ExportedSecret, FillTarget } from "../broker/types.js"; -import type { RedactionRegistry } from "../redaction/registry.js"; +import { assertActive } from "../tools/tool.js"; +import type { ExportedSecret } from "../broker/types.js"; import type { BrowserSession } from "./session.js"; /** @@ -8,20 +8,20 @@ import type { BrowserSession } from "./session.js"; * value must never transit `Runtime.evaluate`, whose expressions end up in * agent-visible tool code paths). * - * Ordering contract: `registry.trackValue()` and `registry.trackField()` are - * called BEFORE the value touches the page, so no snapshot/network read can - * race ahead of redaction. + * Ordering contract: the caller registers the value and the target with + * the redaction registry BEFORE calling this, so no snapshot/network read + * can race ahead of redaction (see `injectResolved` in + * src/tools/fill-common.ts). */ export async function injectSecret( session: BrowserSession, - target: FillTarget, + elementUid: string, secret: ExportedSecret, - registry: RedactionRegistry, + signal?: AbortSignal, ): Promise { - registry.trackValue(secret.value, secret.ref.secretId); - registry.trackField(target.elementUid, secret.ref.secretId); try { - const element = session.resolveElement(target.elementUid); + assertActive(signal); + const element = session.resolveElement(elementUid); const cdp = session.cdpSession(); // Clear any stale content, then focus via CDP and insert the plaintext. @@ -34,6 +34,7 @@ export async function injectSecret( // Enable the DOM agent on the broker's session so backendNodeId resolves. await cdp.send("DOM.getDocument", { depth: 0 }); await cdp.send("DOM.focus", { backendNodeId: element.backendNodeId }); + assertActive(signal); await cdp.send("Input.insertText", { text: secret.value }); // Verify acceptance by length only — never echo the value. The field may diff --git a/src/browser/lightpanda-transport.ts b/src/browser/lightpanda-transport.ts new file mode 100644 index 0000000..3f0a1bd --- /dev/null +++ b/src/browser/lightpanda-transport.ts @@ -0,0 +1,107 @@ +import { connect, type Socket } from "node:net"; +import { lstat } from "node:fs/promises"; +import { dirname, isAbsolute } from "node:path"; +import type { ConnectionTransport } from "puppeteer-core"; + +export const MAX_CDP_BYTES = 4 * 1024 * 1024; + +/** Bounded newline framing; CDP JSON strings escape embedded newlines. */ +export function readFrames(socket: Socket, receive: (message: string) => void) { + let pending = ""; + socket.setEncoding("utf8"); + socket.on("data", (chunk: string) => { + pending += chunk; + let end: number; + while ((end = pending.indexOf("\n")) >= 0) { + if (Buffer.byteLength(pending.slice(0, end)) > MAX_CDP_BYTES) { + socket.destroy(); + return; + } + const message = pending.slice(0, end); + pending = pending.slice(end + 1); + try { + const parsed = JSON.parse(message); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) + throw new Error(); + receive(message); + } catch { + socket.destroy(); + return; + } + } + if (Buffer.byteLength(pending) > MAX_CDP_BYTES) socket.destroy(); + }); +} + +export function writeFrame(socket: Socket, message: string) { + if ( + socket.destroyed || + message.includes("\n") || + Buffer.byteLength(message) > MAX_CDP_BYTES || + socket.writableLength > MAX_CDP_BYTES + ) { + socket.destroy(); + throw new Error("Worker transport unavailable"); + } + socket.write(message + "\n"); +} + +/** The directory is a dedicated IPC volume, never a broker state directory. */ +export async function validateSocketDirectory(path: string) { + if (!isAbsolute(path) || Buffer.byteLength(path) > 100) + throw new Error("Invalid worker socket path"); + const directory = await lstat(dirname(path)); + if ( + !directory.isDirectory() || + directory.isSymbolicLink() || + (directory.mode & 0o777) !== 0o700 || + directory.uid !== process.getuid?.() + ) { + throw new Error( + "Worker socket directory must be owned by this UID with mode 0700", + ); + } +} + +export class WorkerTransport implements ConnectionTransport { + onmessage?: (message: string) => void; + onclose?: () => void; + private constructor(private readonly socket: Socket) { + readFrames(socket, (message) => this.onmessage?.(message)); + socket.on("error", () => {}); // Never expose CDP or path contents in logs. + socket.once("close", () => this.onclose?.()); + } + static async open(path: string): Promise { + await validateSocketDirectory(path); + const file = await lstat(path); + if ( + !file.isSocket() || + (file.mode & 0o777) !== 0o600 || + file.uid !== process.getuid?.() + ) + throw new Error("Untrusted worker socket"); + const socket = connect(path); + const transport = new WorkerTransport(socket); + await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + socket.destroy(); + reject(new Error("Worker connection timed out")); + }, 5000); + socket.once("connect", () => { + clearTimeout(timer); + resolve(); + }); + socket.once("error", () => { + clearTimeout(timer); + reject(new Error("Worker connection failed")); + }); + }); + return transport; + } + send(message: string) { + writeFrame(this.socket, message); + } + close() { + this.socket.destroy(); + } +} diff --git a/src/browser/lightpanda.ts b/src/browser/lightpanda.ts new file mode 100644 index 0000000..50d996c --- /dev/null +++ b/src/browser/lightpanda.ts @@ -0,0 +1,124 @@ +import puppeteer, { type Browser, type Page } from "puppeteer-core"; +import type { BrowserHost } from "./hosts.js"; +import { WorkerTransport } from "./lightpanda-transport.js"; + +/** One dedicated, externally provisioned worker. Never reconnect or reuse it. */ +export class LightpandaHost implements BrowserHost { + readonly kind = "lightpanda"; + private used = false; + private generation = 0; + private abort: AbortController | undefined; + private transport: WorkerTransport | undefined; + private browser: Browser | undefined; + constructor(private readonly socketPath: string) {} + async start(): Promise { + if (this.used) + throw new Error("Lightpanda worker consumed; provision a fresh worker"); + this.used = true; + const generation = ++this.generation; + const abort = new AbortController(); + this.abort = abort; + try { + this.transport = await WorkerTransport.open(this.socketPath); + if (generation !== this.generation) throw new Error("Cancelled"); + const connecting = puppeteer + .connect({ + transport: this.transport, + defaultViewport: null, + protocolTimeout: 10000, + }) + .then(async (browser) => { + if (abort.signal.aborted) { + await browser.disconnect(); + throw new Error("Cancelled"); + } + return browser; + }); + let onAbort!: () => void; + const cancelled = new Promise((_, reject) => { + onAbort = () => reject(new Error("Cancelled")); + abort.signal.addEventListener("abort", onAbort, { once: true }); + if (abort.signal.aborted) onAbort(); + }); + try { + this.browser = await Promise.race([connecting, cancelled]); + } finally { + abort.signal.removeEventListener("abort", onAbort); + } + if (generation !== this.generation) throw new Error("Cancelled"); + return this.browser; + } catch { + await this.stop(); + throw new Error("Lightpanda worker attachment failed"); + } + } + async openPage(browser: Browser): Promise { + const generation = this.generation; + try { + if (browser !== this.browser) throw new Error("Stale browser"); + const context = await browser.createBrowserContext(); + const page = await context.newPage(); + await verifyObservationIsolation(page); + if (generation !== this.generation) throw new Error("Cancelled"); + return page; + } catch { + await this.stop(); + throw new Error( + "Lightpanda lacks required CDP observation isolation; worker closed", + ); + } + } + async stop() { + ++this.generation; + this.abort?.abort(); + this.transport?.close(); + this.transport = undefined; + await this.browser?.disconnect().catch(() => {}); + this.browser = undefined; + } +} + +/** A startup gate, not a substitute for conformance testing of a pinned build. */ +export async function verifyObservationIsolation(page: Page) { + const cdp = await page.createCDPSession(); + try { + await page.goto('data:text/html,'); + const { + frameTree: { frame }, + } = await cdp.send("Page.getFrameTree"); + if ( + !frame.id || + !frame.loaderId || + !frame.url.startsWith("data:text/html,") + ) + throw new Error("Missing document identity"); + await page.evaluate(() => { + (globalThis as any).__sbmProbe = true; + Element.prototype.matches = () => false; + Element.prototype.getRootNode = () => { + throw new Error("page override"); + }; + }); + const { executionContextId } = await cdp.send("Page.createIsolatedWorld", { + frameId: frame.id, + worldName: "sbm-observe", + }); + const { result, exceptionDetails } = await cdp.send("Runtime.evaluate", { + expression: + 'globalThis.__sbmProbe === undefined && document.querySelector("#probe").matches("input") && document.querySelector("#probe").getRootNode() === document', + contextId: executionContextId, + returnByValue: true, + }); + if (exceptionDetails || result.value !== true) + throw new Error("World not isolated"); + await page.goto("about:blank"); + const next = await cdp.send("Page.getFrameTree"); + if ( + !next.frameTree.frame.loaderId || + next.frameTree.frame.loaderId === frame.loaderId + ) + throw new Error("Document identity reused"); + } finally { + await cdp.detach().catch(() => {}); + } +} diff --git a/src/browser/observe.ts b/src/browser/observe.ts new file mode 100644 index 0000000..cea8ca3 --- /dev/null +++ b/src/browser/observe.ts @@ -0,0 +1,211 @@ +/** + * Builds the `TargetObservation` that @turnkey/browser-secrets `authorize` + * decides on. `authorize` trusts the observation as given, so every fact + * here comes from the browser over the broker's own CDP session, where page + * script cannot change it: + * + * - frame and document identity, URL and effective origin come from + * `Page.getFrameTree` (browser-side state, not page JS); + * - shadow-root mode and selector matches are evaluated in an isolated + * world, so a page that overrides `Element.prototype.matches` or + * `getRootNode` in its own world changes nothing here. + * + * Snapshots list elements of the top-level document only (`page.$$` on the + * main frame), so every target's frame ancestry is that one document. An + * element outside it, or one that is detached, is left out of the + * observation, and `authorize` refuses it as `target_not_observed`. + */ +import { + requiredSelectors, + type FillRequest, + type ObservedDocument, + type ObservedTarget, + type RedactionTarget, + type SecretRef, + type ShadowRootMode, + type TargetObservation, +} from "@turnkey/browser-secrets"; +import type { CDPSession } from "puppeteer-core"; + +import type { BrowserSession } from "./session.js"; + +/** Isolated world the broker reads element facts in. */ +const WORLD_NAME = "sbm-observe"; + +/** + * Runs with `this` bound to the element, in the isolated world. Returns + * null for a detached element. A selector the browser rejects records + * false, as browser-secrets requires. + */ +const INSPECT_ELEMENT = `function (selectors) { + if (!this.isConnected) return null; + let shadowRoot = "none"; + for (let root = this.getRootNode(); root instanceof ShadowRoot; root = root.host.getRootNode()) { + if (root.mode === "closed") { shadowRoot = "closed"; break; } + shadowRoot = "open"; + } + const matches = selectors.map((selector) => { + try { return Element.prototype.matches.call(this, selector) === true; } + catch { return false; } + }); + return { shadowRoot, matches }; +}`; + +type InspectResult = { shadowRoot: ShadowRootMode; matches: boolean[] } | null; + +/** The live top-level document and the tab that shows it. */ +export type TopLevelDocument = { + browserSessionId: string; + tabId: string; + document: ObservedDocument; +}; + +/** + * Reads the top-level document's identity from the browser. The frame ID of + * a tab's main frame is its target ID, so it doubles as the tab identity. + * The loader ID changes with every new document in the frame, and is the + * document identity. + */ +export async function topLevelDocument( + session: BrowserSession, +): Promise { + await session.ensureStarted(); + const cdp = session.cdpSession(); + const { frameTree } = await cdp.send("Page.getFrameTree"); + const frame = frameTree.frame; + return { + browserSessionId: session.sessionId(), + tabId: frame.id, + document: { + frameId: frame.id, + documentId: frame.loaderId, + url: frame.url + (frame.urlFragment ?? ""), + // Chrome reports an opaque origin as "null"; an empty one (a frame + // with no document yet) is treated the same. + origin: frame.securityOrigin || "null", + }, + }; +} + +/** The redaction identity of a snapshot element in the top-level document. */ +export function redactionTarget( + top: TopLevelDocument, + elementId: string, +): RedactionTarget { + return { + browserSessionId: top.browserSessionId, + tabId: top.tabId, + frameId: top.document.frameId, + documentId: top.document.documentId, + elementId, + }; +} + +/** + * Observes every element `request` names, evaluating the selectors the + * secret's binding requires for it (`requiredSelectors`). Each element is + * observed once, even when the request names it under several keys, so + * element IDs are unique across the observation. + */ +export async function observeTargets( + session: BrowserSession, + ref: Pick, + request: FillRequest, +): Promise { + const page = await session.ensureStarted(); + const cdp = session.cdpSession(); + const top = await topLevelDocument(session); + + const selectors = new Map>(); + for (const target of request.targets) { + if (!selectors.has(target.elementId)) { + selectors.set(target.elementId, new Set()); + } + } + for (const { elementId, selector } of requiredSelectors(ref, request)) { + if (selector !== undefined) selectors.get(elementId)?.add(selector); + } + + // Enable the DOM agent on the broker's session so backendNodeIds resolve. + await cdp.send("DOM.getDocument", { depth: 0 }); + const { executionContextId } = await cdp.send("Page.createIsolatedWorld", { + frameId: top.document.frameId, + worldName: WORLD_NAME, + }); + + const targets: ObservedTarget[] = []; + for (const [elementId, wanted] of selectors) { + let element; + try { + element = session.resolveElement(elementId); + } catch { + continue; // Unknown or stale uid: not observed. + } + if (element.handle.frame !== page.mainFrame()) continue; + const list = [...wanted]; + const facts = await inspect( + cdp, + element.backendNodeId, + executionContextId, + list, + ); + if (!facts) continue; + const selectorMatches: Record = {}; + list.forEach((selector, i) => { + Object.defineProperty(selectorMatches, selector, { + value: facts.matches[i] === true, + enumerable: true, + }); + }); + targets.push({ + elementId, + frames: [top.document], + shadowRoot: facts.shadowRoot, + selectorMatches, + }); + } + + return { + browserSessionId: top.browserSessionId, + tabId: top.tabId, + topLevelUrl: top.document.url, + targets, + }; +} + +/** Element facts from the isolated world, or null when it cannot be read. */ +async function inspect( + cdp: CDPSession, + backendNodeId: number, + executionContextId: number, + selectors: string[], +): Promise { + let objectId: string | undefined; + try { + // Fails when the node is gone or is not in this frame's document. + ({ + object: { objectId }, + } = await cdp.send("DOM.resolveNode", { + backendNodeId, + executionContextId, + })); + if (!objectId) return null; + const { result, exceptionDetails } = await cdp.send( + "Runtime.callFunctionOn", + { + objectId, + functionDeclaration: INSPECT_ELEMENT, + arguments: [{ value: selectors }], + returnByValue: true, + }, + ); + if (exceptionDetails) return null; + return (result.value ?? null) as InspectResult; + } catch { + return null; + } finally { + if (objectId) { + void cdp.send("Runtime.releaseObject", { objectId }).catch(() => {}); + } + } +} diff --git a/src/browser/session.ts b/src/browser/session.ts index 1fee578..5724627 100644 --- a/src/browser/session.ts +++ b/src/browser/session.ts @@ -1,3 +1,5 @@ +import { randomUUID } from "node:crypto"; + import type { Browser, CDPSession, ElementHandle, Page } from "puppeteer-core"; import type { BrowserHost } from "./hosts.js"; @@ -21,9 +23,19 @@ export class BrowserSession { private page: Page | undefined; private cdp: CDPSession | undefined; private elements = new Map(); + private browserSessionId = randomUUID(); constructor(private readonly host: BrowserHost) {} + /** + * Host-owned identity of the running browser, for browser-secrets + * observations and redaction targets. Each browser start gets a new one, + * so nothing recorded against an earlier browser matches the new one. + */ + sessionId(): string { + return this.browserSessionId; + } + async ensureStarted(): Promise { if (this.page && !this.page.isClosed()) return this.page; @@ -31,6 +43,7 @@ export class BrowserSession { // session that dropped) before starting fresh. await this.host.stop(); this.browser = await this.host.start(); + this.browserSessionId = randomUUID(); // A remote browser can end on its own (session timeout, network). Drop // the page so the next call starts a fresh session instead of failing. @@ -41,9 +54,18 @@ export class BrowserSession { this.browser = undefined; }); - const pages = await this.browser.pages(); - this.page = pages[0] ?? (await this.browser.newPage()); - this.cdp = await this.page.createCDPSession(); + try { + if (this.host.openPage) + this.page = await this.host.openPage(this.browser); + else { + const pages = await this.browser.pages(); + this.page = pages[0] ?? (await this.browser.newPage()); + } + this.cdp = await this.page.createCDPSession(); + } catch (error) { + await this.close(); + throw error; + } // Element uids are backendNodeId-based and die with the document. this.page.on("framenavigated", (frame) => { if (frame === this.page?.mainFrame()) this.clearElements(); @@ -51,6 +73,11 @@ export class BrowserSession { return this.page; } + /** Whether a browser is running, so the session holds one. */ + isStarted(): boolean { + return this.page !== undefined && !this.page.isClosed(); + } + currentPage(): Page | undefined { return this.page; } @@ -67,6 +94,11 @@ export class BrowserSession { for (const e of elements) this.elements.set(e.uid, e); } + /** Whether a snapshot uid is known (it may still be detached). */ + hasElement(uid: string): boolean { + return this.elements.has(uid); + } + /** * Resolve a snapshot uid to a live element. Throws if the uid is unknown * or stale — callers surface "take a new snapshot" to the agent. @@ -87,6 +119,11 @@ export class BrowserSession { this.elements.clear(); } + /** Cancellation consumes a one-shot worker, including an in-flight start. */ + async cancelRequest(): Promise { + if (this.host.kind === "lightpanda") await this.close(); + } + async close(): Promise { this.clearElements(); await this.host.stop(); diff --git a/src/browser/snapshot.ts b/src/browser/snapshot.ts index fe919ae..7308c94 100644 --- a/src/browser/snapshot.ts +++ b/src/browser/snapshot.ts @@ -1,6 +1,11 @@ +import type { RedactionRegistry } from "@turnkey/browser-secrets"; import type { ElementHandle } from "puppeteer-core"; -import type { RedactionRegistry } from "../redaction/registry.js"; +import { + redactionTarget, + topLevelDocument, + type TopLevelDocument, +} from "./observe.js"; import type { BrowserSession, ResolvedElement } from "./session.js"; /** Agent-visible description of one interactive element. */ @@ -54,8 +59,9 @@ type RawInfo = { * Capture the interactive elements of the page and register them with the * session so uids resolve back to live nodes. Uids are derived from CDP * backendNodeIds, so a field keeps its uid across snapshots of the same - * document — which is what lets `RedactionRegistry.trackField` tags survive - * re-snapshotting. + * document — which is what lets `RedactionRegistry.registerTarget` tags + * survive re-snapshotting. Tags are document-scoped: a new document in the + * tab never matches an old tag. * * Structural redaction happens HERE, not in scrub(): tagged fields, fields * that hold a live secret (a re-rendered or mirrored copy), and password @@ -66,6 +72,7 @@ export async function captureSnapshot( registry: RedactionRegistry, ): Promise { const page = await session.ensureStarted(); + const top = await topLevelDocument(session); const handles = await page.$$(INTERACTIVE_SELECTOR); const resolved: ResolvedElement[] = []; @@ -73,7 +80,7 @@ export async function captureSnapshot( // Values the page shows in the clear, in document order, so a secret the // page splits across several fields can be found in their concatenation. - const shown: { uid: string; el: SnapshotElement; value: string }[] = []; + const shown: Shown[] = []; for (const handle of handles) { const info = await describeElement(handle); @@ -96,13 +103,13 @@ export async function captureSnapshot( if (info.text) el.text = info.text; if (info.disabled) el.disabled = true; if (info.value !== undefined) { - const copied = registry.findSecret(info.value); - if (copied !== undefined) { - // A field the page re-rendered or mirrored the secret into: tag it - // so it stays redacted even after the page edits the copy. - registry.trackField(uid, copied); + const target = redactionTarget(top, uid); + // A field the page re-rendered or mirrored the secret into: tag it + // so it stays redacted even after the page edits the copy. + for (const copied of registry.findSecretIds(info.value)) { + registry.registerTarget(target, copied); } - if (registry.isTaggedField(uid)) { + if (registry.isRegisteredTarget(target)) { el.value = REDACTED_FIELD; } else if (info.isPassword) { // Password values are never echoed, filled or not. @@ -115,7 +122,7 @@ export async function captureSnapshot( elements.push(el); } - tagSplitCopies(registry, shown); + tagSplitCopies(registry, top, shown); session.setElements(resolved); return { url: page.url(), title: await page.title(), elements }; } @@ -126,9 +133,12 @@ export async function captureSnapshot( * part is too short to match alone, so match the values joined in document * order and redact every field a match touches. */ +type Shown = { uid: string; el: SnapshotElement; value: string }; + function tagSplitCopies( registry: RedactionRegistry, - shown: { uid: string; el: SnapshotElement; value: string }[], + top: TopLevelDocument, + shown: Shown[], ): void { if (shown.length < 2) return; let joined = ""; @@ -137,11 +147,13 @@ function tagSplitCopies( joined += value + " "; return { start, end: start + value.length }; }); - for (const span of registry.matches(joined)) { + for (const span of registry.findMatches(joined)) { shown.forEach(({ uid, el }, k) => { const range = ranges[k]!; if (range.start >= span.end || range.end <= span.start) return; - registry.trackField(uid, span.secretId); + for (const id of span.secretIds) { + registry.registerTarget(redactionTarget(top, uid), id); + } el.value = REDACTED_FIELD; }); } diff --git a/src/browsers.ts b/src/browsers.ts new file mode 100644 index 0000000..57277a3 --- /dev/null +++ b/src/browsers.ts @@ -0,0 +1,258 @@ +/** + * Browser handles (EMG-288): browsers a caller opens with open_browser and + * names by `browser_id` in later tool calls, so a browser outlives the + * connection that opened it. + * + * MCP 2026-07-28 has no protocol sessions, so state across calls must be a + * handle the server mints and the client passes as an ordinary argument. + * Some 2025-era clients (the Arcade gateway) open a new session for every + * tool call, which loses a browser tied to the session. Both use handles. + * + * A handle holds what a 2025 session holds: its own browser, redaction + * registry, and view of the pending fills. It is bound to the endpoint, + * principal, and OAuth client that opened it, the same binding a hosted + * session has. To anyone else it does not exist: a wrong owner gets the + * same "unknown browser_id" as an id that was never minted. The id is 192 + * random bits, so it cannot be guessed either. + * + * Handles end with close_browser, after the idle timeout, or when the + * process stops. The caps bound how many browsers callers can hold. + */ +import { randomBytes } from "node:crypto"; + +import { RedactionRegistry } from "@turnkey/browser-secrets"; + +import type { PendingFills } from "./broker/pending-store.js"; +import type { BrowserHost } from "./browser/hosts.js"; +import { BrowserSession } from "./browser/session.js"; + +/** One browser's state: what tools act on and redact with. */ +export type BrowserContext = { + session: BrowserSession; + registry: RedactionRegistry; + /** This browser's parked fills (see src/broker/pending-store.ts). */ + pendingFills: PendingFills; +}; + +/** Who a handle answers to. */ +export type HandleOwner = { + endpoint: string; + principal: string; + client: string | undefined; +}; + +export type HandleLimits = { + /** Handles in the whole process. */ + max: number; + /** Handles per endpoint (tenant). */ + perEndpoint: number; + /** Handles per principal of an endpoint; Infinity for none. */ + perPrincipal: (owner: HandleOwner) => number; +}; + +type Handle = { + owner: HandleOwner; + ctx: BrowserContext; + lastSeen: number; + /** Tool calls running in this browser. The sweep skips a busy handle. */ + active: number; +}; + +const sameOwner = (a: HandleOwner, b: HandleOwner) => + a.endpoint === b.endpoint && + a.principal === b.principal && + a.client === b.client; + +export class UnknownBrowserError extends Error { + constructor(browserId: string) { + super( + `Unknown browser_id: ${browserId}. It was closed, timed out, or ` + + "belongs to another caller. Call open_browser for a new one.", + ); + this.name = "UnknownBrowserError"; + } +} + +export class BrowserLimitError extends Error { + constructor(message: string) { + super(message); + this.name = "BrowserLimitError"; + } +} + +export class BrowserHandles { + private readonly handles = new Map(); + + constructor( + private readonly limits: HandleLimits, + readonly idleMs: number, + ) {} + + /** + * Mint a handle for `owner`. `make` builds its browser context; the id is + * passed in so the context can use it as the owner of its pending fills. + * The browser itself starts on the first tool call that needs it. + */ + open(owner: HandleOwner, make: (browserId: string) => BrowserContext) { + const all = [...this.handles.values()]; + const endpoint = all.filter((h) => h.owner.endpoint === owner.endpoint); + const mine = endpoint.filter((h) => h.owner.principal === owner.principal); + if (mine.length >= this.limits.perPrincipal(owner)) { + throw new BrowserLimitError( + "This caller has the most browsers it may hold open. Call " + + "close_browser on one it no longer needs.", + ); + } + if (endpoint.length >= this.limits.perEndpoint) { + throw new BrowserLimitError("This tenant is at its browser limit"); + } + if (all.length >= this.limits.max) { + throw new BrowserLimitError("Broker is at its browser limit"); + } + const id = `br_${randomBytes(24).toString("base64url")}`; + this.handles.set(id, { + owner, + ctx: make(id), + lastSeen: Date.now(), + active: 0, + }); + return id; + } + + /** Run `fn` in the browser `browserId` names, if `owner` holds it. */ + async use( + browserId: string, + owner: HandleOwner, + fn: (ctx: BrowserContext) => Promise, + ): Promise { + const h = this.handles.get(browserId); + if (!h || !sameOwner(h.owner, owner)) { + throw new UnknownBrowserError(browserId); + } + h.active++; + h.lastSeen = Date.now(); + try { + return await fn(h.ctx); + } finally { + h.active--; + h.lastSeen = Date.now(); + } + } + + /** Close `browserId` if `owner` holds it; false if it does not. */ + async close(browserId: string, owner: HandleOwner): Promise { + const h = this.handles.get(browserId); + if (!h || !sameOwner(h.owner, owner)) return false; + await this.drop(browserId); + return true; + } + + /** Whether a handle exists, for pending-fill ownership checks. */ + has(browserId: string): boolean { + return this.handles.has(browserId); + } + + get size(): number { + return this.handles.size; + } + + /** Close every handle idle longer than the timeout and not in use. */ + sweep(now = Date.now()): Promise { + const cutoff = now - this.idleMs; + const idle = [...this.handles].filter( + ([, h]) => h.active === 0 && h.lastSeen < cutoff, + ); + return Promise.all(idle.map(([id]) => this.drop(id))).then(() => {}); + } + + closeAll(): Promise { + return Promise.all( + [...this.handles.keys()].map((id) => this.drop(id)), + ).then(() => {}); + } + + private async drop(browserId: string): Promise { + const h = this.handles.get(browserId); + if (!h) return; + this.handles.delete(browserId); + // Parked fills stay in the store. Their owner is gone, so another + // browser of the same principal can claim them by fill_id. + await h.ctx.session.close().catch(() => {}); + } +} + +/** + * The browsers one connection can reach: a 2025 session, one 2026-07-28 + * request, or the stdio process. + */ +export type BrowserScope = { + /** + * Run `fn` in a browser: the one `browserId` names, or else the + * connection's own browser. With `required`, a connection without its own + * browser must name one; without it, `fn` gets a context with no browser + * (for tools that do not touch the page). + */ + run( + browserId: string | undefined, + required: boolean, + fn: (ctx: BrowserContext) => Promise, + ): Promise; + /** Mint a handle for this connection's caller. */ + open(): string; + /** Close a handle this connection's caller holds; false if it holds + * none by that id. */ + close(browserId: string): Promise; + idleSeconds: number; +}; + +const NO_HOST: BrowserHost = { + kind: "local", + start: () => Promise.reject(new Error("This call has no browser")), + stop: () => Promise.resolve(), +}; + +const NO_FILLS: PendingFills = { + list: () => [], + get: async () => undefined, + put: () => Promise.reject(new Error("This call has no browser")), + delete: async () => {}, +}; + +/** A context for a tool that uses no browser. */ +function detached(): BrowserContext { + return { + session: new BrowserSession(NO_HOST), + registry: new RedactionRegistry(), + pendingFills: NO_FILLS, + }; +} + +export function browserScope(opts: { + handles: BrowserHandles; + owner: HandleOwner; + /** Builds a handle's context (see BrowserHandles.open). */ + make: (browserId: string) => BrowserContext; + /** The connection's own browser: a 2025 session's, or stdio's. The + * 2026-07-28 leg has none. */ + implicit?: BrowserContext | undefined; +}): BrowserScope { + const { handles, owner, make, implicit } = opts; + return { + run: (browserId, required, fn) => { + if (browserId !== undefined) return handles.use(browserId, owner, fn); + if (implicit) return fn(implicit); + if (required) { + return Promise.reject( + new Error( + "This tool needs a browser_id on this connection. Call " + + "open_browser and pass the browser_id it returns.", + ), + ); + } + return fn(detached()); + }, + open: () => handles.open(owner, make), + close: (browserId) => handles.close(browserId, owner), + idleSeconds: Math.floor(handles.idleMs / 1_000), + }; +} diff --git a/src/get-stream.ts b/src/get-stream.ts new file mode 100644 index 0000000..e69f6e6 --- /dev/null +++ b/src/get-stream.ts @@ -0,0 +1,52 @@ +/** + * End a GET (SSE) stream when the token that opened it expires, and release + * it when the client drops the connection. POSTs are checked one by one, but + * a GET stream would outlive its token. The transport allows one GET stream + * per session, so this is that stream, and until a dropped one is released + * the session cannot open another. The transport releases it on the + * response's `close`, which node:http on Bun 1.3.x and earlier does not + * emit when the client disconnects; the request's `close` fires on every + * runtime. + */ + +/** The parts of the transport, request, and response this uses. */ +type Transport = { closeStandaloneSSEStream(): void }; +type StreamRequest = { once(event: "close", listener: () => void): unknown }; +type StreamResponse = { + readonly headersSent: boolean; + readonly statusCode: number; + readonly writableFinished: boolean; + destroy(): unknown; +}; + +export function watchGetStream( + transport: Transport, + req: StreamRequest, + res: StreamResponse, + expiresAt: number | undefined, +): void { + // After expiry the session's stream can already be a new one, which a + // late `close` of this request must not release. + let expired = false; + const timer = + expiresAt === undefined + ? undefined + : setTimeout( + () => { + expired = true; + transport.closeStandaloneSSEStream(); + // If closing the stream does not end the response, drop it. + setTimeout(() => res.destroy(), 1_000).unref(); + }, + Math.max(0, expiresAt - Date.now()), + ); + timer?.unref(); + req.once("close", () => { + clearTimeout(timer); + if (expired) return; + // A stream the server opened and did not end is one the client dropped. + if (res.headersSent && res.statusCode === 200 && !res.writableFinished) { + transport.closeStandaloneSSEStream(); + } + }); +} diff --git a/src/http.ts b/src/http.ts index 2444720..de30387 100644 --- a/src/http.ts +++ b/src/http.ts @@ -1,35 +1,31 @@ /** * Hosted entrypoint: the same tool surface as src/index.ts, served as MCP - * Streamable HTTP at /mcp for agent platforms that connect by URL. + * Streamable HTTP for agent platforms that connect by URL. * * This is a Turnkey-operated fill service (design 1 in EMG-89): the broker * and its browsers run here, so secret plaintext exists in this process * during a fill. See docs/THREAT-MODEL.md ("Hosted broker"). * - * Each MCP session gets its own ToolContext: its own Chrome process, its own - * redaction registry, and a view of the pending-fill store that shows only - * its own fills. Closing the session (DELETE, idle timeout, or shutdown) - * closes its browser. + * Each tenant in SBM_TENANTS has its own endpoint, /t/{id}/mcp, with its + * own Turnkey key and pending-fill store, and the broker is an OAuth 2.1 + * resource server for it. * - * Protocol: the 2025-era session protocol (initialize, Mcp-Session-Id), - * through to 2025-11-25. A 2026-07-28 request (no session, per-request - * `_meta` envelope) gets an UnsupportedProtocolVersion error; see - * rejectModern below. + * Protocol: both eras, on the same endpoint. * - * Environment: - * SBM_HTTP_TOKEN required. Clients send `Authorization: Bearer `. - * SBM_HTTP_HOST listen address (default 127.0.0.1; 0.0.0.0 in the image) - * SBM_HTTP_PORT listen port (default 8080) - * SBM_STATE_DIR directory for encrypted pending fills. Without it, - * pending fills are in memory and a restart strands them. - * SBM_STATE_KEY 32-byte key (hex or base64), required with SBM_STATE_DIR - * SBM_MAX_SESSIONS concurrent agent sessions (default 8) - * SBM_SESSION_IDLE_S close sessions idle this long (default 1800) - * SBM_ALLOWED_ORIGINS comma-separated origins (https://app.example.com) - * that browser clients may call from. Default empty: a - * request with any Origin header gets 403. Requests - * without Origin (non-browser clients) are allowed. - * plus the TURNKEY_* and SBM_* variables the stdio broker reads. + * - 2025-era sessions (initialize, Mcp-Session-Id), through to 2025-11-25. + * Each session has its own browser, redaction registry, and view of the + * pending fills, and is bound to the tenant, subject, and OAuth client + * that opened it. Closing the session (DELETE, idle timeout, or + * shutdown) closes its browser. + * - 2026-07-28 (no session, per-request `_meta` envelope), through the + * SDK's createMcpHandler. A request has no browser of its own. + * + * Both eras can open browser handles (src/browsers.ts) with open_browser + * and name them by browser_id. A handle is bound like a session and + * outlives the connection that opened it, which 2026-07-28 clients and + * 2025 clients that open a session per call (the Arcade gateway) need. + * + * Configuration: docs/HOSTED.md. */ import { createHash, randomUUID, timingSafeEqual } from "node:crypto"; import { @@ -37,38 +33,104 @@ import { type IncomingMessage, type ServerResponse, } from "node:http"; +import { existsSync, readdirSync } from "node:fs"; +import { join } from "node:path"; import { NodeStreamableHTTPServerTransport, + toNodeHandler, toWebRequest, type NodeIncomingMessageLike, + type NodeMcpRequestHandler, } from "@modelcontextprotocol/node"; import { + createMcpHandler, isInitializeRequest, + isJsonContentType, isLegacyRequest, - PROTOCOL_VERSION_META_KEY, - SUPPORTED_PROTOCOL_VERSIONS, + type AuthInfo, } from "@modelcontextprotocol/server"; +import { + protectedResourceMetadata, + wwwAuthenticate, + type Challenge, +} from "./auth/challenge.js"; +import { SCOPES } from "./auth/scopes.js"; +import { + checkAudiences, + loadTenants, + metadataUri, + parsePublicUrl, + tenantAudience, + tenantIssuers, + type TenantConfig, +} from "./auth/tenants.js"; +import { JwksCache, verifyAccessToken } from "./auth/tokens.js"; +import { + BrowserHandles, + browserScope, + type BrowserContext, + type HandleOwner, +} from "./browsers.js"; +import { watchGetStream } from "./get-stream.js"; +import { InFlightIds } from "./inflight.js"; import { FilePendingFillStore, MemoryPendingFillStore, parseStateKey, scopedFills, + tenantStateKey, } from "./broker/pending-store.js"; -import { makeBroker, sessionContext } from "./runtime.js"; -import { createServer } from "./server.js"; -import type { ToolContext } from "./tools/tool.js"; +import { + brokerContext, + browserContext, + makeBroker, + tenantSecretsClient, + type Broker, +} from "./runtime.js"; +import { createServer, TOOLS } from "./server.js"; +import type { BrokerContext } from "./tools/tool.js"; const MAX_BODY_BYTES = 1_000_000; -/** JSON-RPC code for UnsupportedProtocolVersion (MCP 2026-07-28). */ -const UNSUPPORTED_PROTOCOL_VERSION = -32022; +/** The dev endpoint's id. Not a valid tenant id, so it cannot collide. */ +const DEV_ENDPOINT_ID = "_shared-token"; + +const SCOPE_BY_TOOL = new Map(TOOLS.map((t) => [t.name, t.scope])); + +/** The caller a request was authenticated as. */ +type Caller = { + /** Binds sessions and parked fills. */ + principal: string; + client: string | undefined; + scopes: ReadonlySet; + /** Token expiry in ms since the epoch; absent for the dev token. */ + expiresAt?: number; +}; + +/** One MCP endpoint: a tenant, or the dev shared-token endpoint. */ +type Endpoint = { + id: string; + broker: Broker; + store: MemoryPendingFillStore; + /** The caller for a bearer token, or undefined if it is not valid. */ + verify: (token: string) => Promise; + /** Protected Resource Metadata; the dev endpoint has none. */ + metadata?: { uri: string; doc: object }; +}; + +type TenantEndpoint = Endpoint & { metadata: { uri: string; doc: object } }; type Session = { transport: NodeStreamableHTTPServerTransport; - ctx: ToolContext; + /** The session's own browser. */ + browser: BrowserContext; lastSeen: number; + endpoint: string; + principal: string; + client: string | undefined; + inFlight: InFlightIds; }; function envInt(name: string, fallback: number): number { @@ -81,18 +143,159 @@ function envInt(name: string, fallback: number): number { return n; } -function makeStore(): MemoryPendingFillStore { +/** A store per endpoint: its own directory, under its own derived key. */ +function storeFactory(): (endpointId: string) => MemoryPendingFillStore { const dir = process.env["SBM_STATE_DIR"]; if (!dir) { console.error( "secure-browser-mcp: SBM_STATE_DIR unset; pending fills do not " + "survive a restart", ); - return new MemoryPendingFillStore(); + return () => new MemoryPendingFillStore(); } const key = process.env["SBM_STATE_KEY"]; if (!key) throw new Error("SBM_STATE_DIR requires SBM_STATE_KEY"); - return new FilePendingFillStore(dir, parseStateKey(key)); + const master = parseStateKey(key); + // Fills a shared-token broker parked directly in SBM_STATE_DIR belong to + // no tenant, so they are not loaded (docs/HOSTED.md). + const legacy = existsSync(dir) + ? readdirSync(dir).filter((f) => f.endsWith(".fill")).length + : 0; + if (legacy > 0) { + console.error( + `secure-browser-mcp: WARNING: ignoring ${legacy} parked fill(s) from ` + + `a shared-token broker in ${dir}; their approvals must be restarted`, + ); + } + return (id) => + new FilePendingFillStore( + join(dir, "tenants", id), + tenantStateKey(master, id), + ); +} + +/** RFC 6750 §2.1 b64token: the only characters a bearer token may hold. */ +const B64TOKEN = /^[A-Za-z0-9._~+/-]+=*$/; + +/** The Bearer token; "none" when the request has no Bearer credentials, + * which RFC 6750 §3.1 answers without an error code. */ +function bearerToken( + req: IncomingMessage, +): { token: string } | "none" | "malformed" { + const header = req.headers.authorization; + if (header === undefined || !/^Bearer(\s|$)/i.test(header)) return "none"; + const token = /^Bearer (\S+)$/i.exec(header)?.[1]; + return token && B64TOKEN.test(token) ? { token } : "malformed"; +} + +/** Constant-time compare; compares digests so lengths do not leak. */ +function sameSecret(a: string, b: string): boolean { + const digest = (s: string) => createHash("sha256").update(s).digest(); + return timingSafeEqual(digest(a), digest(b)); +} + +function tenantEndpoint( + publicUrl: string, + tenant: TenantConfig, + jwks: JwksCache, + makeStore: (id: string) => MemoryPendingFillStore, +): TenantEndpoint { + const expect = { + audience: tenantAudience(publicUrl, tenant), + issuers: tenantIssuers(publicUrl, tenant), + allowedClients: tenant.allowedClients, + }; + return { + id: tenant.id, + broker: makeBroker(tenantSecretsClient(tenant)), + store: makeStore(tenant.id), + metadata: { + uri: metadataUri(publicUrl, tenant.id), + doc: protectedResourceMetadata(publicUrl, tenant), + }, + verify: async (token) => { + // Fail closed on every error, including an unreachable JWKS. + const p = await verifyAccessToken(token, expect, jwks).catch( + () => undefined, + ); + return ( + p && { + // (iss, sub) without the client, so the same subject can claim a + // parked fill from another client. + principal: JSON.stringify([p.issuer, p.subject]), + client: p.client, + scopes: p.scopes, + expiresAt: p.expiresAt * 1_000, + } + ); + }, + }; +} + +function devEndpoint( + token: string, + makeStore: (id: string) => MemoryPendingFillStore, +): Endpoint { + const caller: Caller = { + principal: DEV_ENDPOINT_ID, + client: undefined, + scopes: new Set(SCOPES), + }; + return { + id: DEV_ENDPOINT_ID, + broker: makeBroker(), + store: makeStore(DEV_ENDPOINT_ID), + verify: async (t) => (sameSecret(t, token) ? caller : undefined), + }; +} + +/** + * Scopes the body's tools/call messages need and the caller lacks. This is + * the union over a batch, so one 403 names them all; SDK v2's per-tool + * scopeChallenge answers a batch with its first challenge only. + */ +function missingScopes(body: unknown, caller: Caller): string[] { + const messages = Array.isArray(body) ? body : [body]; + const missing = new Set(); + for (const m of messages) { + if (!m || typeof m !== "object" || m.method !== "tools/call") continue; + const name = (m.params as { name?: unknown } | undefined)?.name; + // Unknown tools need no scope here; the MCP server rejects them. + const scope = + typeof name === "string" ? SCOPE_BY_TOOL.get(name) : undefined; + if (scope && !caller.scopes.has(scope)) missing.add(scope); + } + return [...missing]; +} + +async function readJson(req: IncomingMessage): Promise { + const chunks: Buffer[] = []; + let size = 0; + for await (const chunk of req) { + size += (chunk as Buffer).length; + if (size > MAX_BODY_BYTES) throw new Error("Request body too large"); + chunks.push(chunk as Buffer); + } + return JSON.parse(Buffer.concat(chunks).toString("utf8")); +} + +function send( + res: ServerResponse, + status: number, + body: unknown, + headers: Record = {}, +): void { + res.writeHead(status, { "content-type": "application/json", ...headers }); + res.end(JSON.stringify(body)); +} + +function rpcError( + res: ServerResponse, + status: number, + message: string, + code = -32000, +) { + send(res, status, { jsonrpc: "2.0", error: { code, message }, id: null }); } /** @@ -140,189 +343,467 @@ function originAllowed(req: IncomingMessage, allowed: Set): boolean { return origin === undefined || allowed.has(origin); } -/** Constant-time bearer check; compares digests so lengths do not leak. */ -function authorized(req: IncomingMessage, token: string): boolean { - const header = req.headers.authorization ?? ""; - const match = /^Bearer (.+)$/i.exec(header); - if (!match) return false; - const digest = (s: string) => createHash("sha256").update(s).digest(); - return timingSafeEqual(digest(match[1]!), digest(token)); -} - -async function readJson(req: IncomingMessage): Promise { - const chunks: Buffer[] = []; - let size = 0; - for await (const chunk of req) { - size += (chunk as Buffer).length; - if (size > MAX_BODY_BYTES) throw new Error("Request body too large"); - chunks.push(chunk as Buffer); - } - return JSON.parse(Buffer.concat(chunks).toString("utf8")); -} - -function send( +function refuse( res: ServerResponse, - status: number, - body: unknown, - headers: Record = {}, -): void { - res.writeHead(status, { "content-type": "application/json", ...headers }); - res.end(JSON.stringify(body)); -} - -function rpcError(res: ServerResponse, status: number, message: string) { - send(res, status, { - jsonrpc: "2.0", - error: { code: -32000, message }, - id: null, - }); + status: 400 | 401 | 403, + endpoint: Endpoint, + challenge: Omit, +) { + const header = endpoint.metadata + ? wwwAuthenticate({ ...challenge, resourceMetadata: endpoint.metadata.uri }) + : challenge.error + ? `Bearer error="${challenge.error}"` + : "Bearer"; + send( + res, + status, + { + error: challenge.error ?? "unauthorized", + ...(challenge.description + ? { error_description: challenge.description } + : {}), + }, + { "www-authenticate": header }, + ); } -/** - * Answer a 2026-07-28 (stateless, `server/discover`) request. The broker - * serves only the 2025-era session protocol: one browser per - * `Mcp-Session-Id`. The 2026 revision has no sessions, so it needs a - * different browser-handle model, which is not built yet. - * - * The answer is the spec's UnsupportedProtocolVersion error, listing the - * 2025-era versions the broker serves. A client in version-negotiation - * `auto` mode reads this and falls back to `initialize`; a client pinned to - * 2026-07-28 fails with a clear error instead of hanging. - */ -function rejectModern( - res: ServerResponse, +/** The request's caller, or undefined after answering 400 or 401. */ +async function authenticate( + endpoint: Endpoint, req: IncomingMessage, - body: unknown, -) { - const msg = (Array.isArray(body) ? body[0] : body) as - { id?: unknown; params?: { _meta?: Record } } | undefined; - const claimed = msg?.params?._meta?.[PROTOCOL_VERSION_META_KEY]; - const header = req.headers["mcp-protocol-version"]; - const requested = - typeof claimed === "string" - ? claimed - : typeof header === "string" - ? header - : "unknown"; - const id = - typeof msg?.id === "string" || typeof msg?.id === "number" ? msg.id : null; - send(res, 400, { - jsonrpc: "2.0", - error: { - code: UNSUPPORTED_PROTOCOL_VERSION, - message: - `Unsupported protocol version: ${requested}. This broker serves ` + - "the 2025-era session protocol only; connect with initialize " + - `(protocol version ${SUPPORTED_PROTOCOL_VERSIONS[0]}).`, - data: { supported: [...SUPPORTED_PROTOCOL_VERSIONS], requested }, - }, - id, - }); + res: ServerResponse, +): Promise { + const bearer = bearerToken(req); + if (bearer === "none") { + refuse(res, 401, endpoint, { scopes: SCOPES }); + } else if (bearer === "malformed") { + refuse(res, 400, endpoint, { + error: "invalid_request", + description: "The Authorization header must be Bearer ", + scopes: SCOPES, + }); + } else { + const caller = await endpoint.verify(bearer.token); + if (caller) return caller; + refuse(res, 401, endpoint, { + error: "invalid_token", + description: "The access token is not valid for this resource", + scopes: SCOPES, + }); + } + return undefined; } async function main(): Promise { - const token = process.env["SBM_HTTP_TOKEN"]; - if (!token) throw new Error("SBM_HTTP_TOKEN is required"); const host = process.env["SBM_HTTP_HOST"] ?? "127.0.0.1"; const port = envInt("SBM_HTTP_PORT", 8080); const maxSessions = envInt("SBM_MAX_SESSIONS", 8); const idleMs = envInt("SBM_SESSION_IDLE_S", 1800) * 1_000; + // Sweep at least as often as the idle timeout, so a short one holds. + const sweepMs = Math.min(60_000, idleMs); const allowedOrigins = parseAllowedOrigins( process.env["SBM_ALLOWED_ORIGINS"], ); + const makeStore = storeFactory(); + // Development mode: the shared-token /mcp endpoint and mock tenants. + const devFlag = ["1", "true"].includes( + (process.env["SBM_DEV_SHARED_TOKEN"] ?? "").toLowerCase(), + ); + + const tenants = new Map(); + const tenantsPath = process.env["SBM_TENANTS"]; + if (tenantsPath) { + const rawPublicUrl = process.env["SBM_PUBLIC_URL"]; + if (!rawPublicUrl) throw new Error("SBM_TENANTS requires SBM_PUBLIC_URL"); + const publicUrl = parsePublicUrl(rawPublicUrl); + const jwks = new JwksCache(); + const configs = loadTenants(tenantsPath); + checkAudiences(publicUrl, configs); + for (const tenant of configs) { + if (tenant.backend === "mock") { + if (!devFlag) { + throw new Error( + `Tenant ${tenant.id} uses the mock secrets backend, which is ` + + "for development only: set SBM_DEV_SHARED_TOKEN=1 to allow " + + 'it, or use backend "turnkey"', + ); + } + console.error( + `secure-browser-mcp: WARNING: tenant ${tenant.id} uses the mock ` + + "secrets backend", + ); + } + tenants.set( + tenant.id, + tenantEndpoint(publicUrl, tenant, jwks, makeStore), + ); + } + } + + // The step-1 shared token, kept for local development only. + let dev: Endpoint | undefined; + const sharedToken = process.env["SBM_HTTP_TOKEN"]; + if (sharedToken) { + if (!devFlag) { + throw new Error( + "SBM_HTTP_TOKEN is for development only: set SBM_DEV_SHARED_TOKEN=1 " + + "to serve /mcp with it, or unset it and configure SBM_TENANTS", + ); + } + // A token the header parser would never accept would 401 every request. + if (!B64TOKEN.test(sharedToken)) { + throw new Error( + "SBM_HTTP_TOKEN may contain only letters, digits, and - . _ ~ + / " + + "(with trailing =); openssl rand -hex 32 makes one", + ); + } + dev = devEndpoint(sharedToken, makeStore); + console.error( + "secure-browser-mcp: WARNING: SBM_DEV_SHARED_TOKEN is on. /mcp accepts " + + "one shared bearer token with every scope and the TURNKEY_* key. " + + "Do not use this in production.", + ); + } + if (tenants.size === 0 && !dev) { + throw new Error("Configure tenants with SBM_TENANTS and SBM_PUBLIC_URL"); + } + // By default each endpoint gets an equal share of the session slots, and + // one principal cannot take all of its tenant's. + const endpoints = tenants.size + (dev ? 1 : 0); + const maxPerTenant = envInt( + "SBM_MAX_SESSIONS_PER_TENANT", + Math.max(1, Math.floor(maxSessions / endpoints)), + ); + const maxPerPrincipal = envInt( + "SBM_MAX_SESSIONS_PER_PRINCIPAL", + Math.min(4, maxPerTenant), + ); - const broker = makeBroker(); - const store = makeStore(); const sessions = new Map(); - const isLive = (owner: string) => sessions.has(owner); + // Browser handles have their own copy of the session limits, so a caller + // that holds its limit of handles can still open a session to close one. + const handles = new BrowserHandles( + { + max: maxSessions, + perEndpoint: maxPerTenant, + // The dev endpoint has one principal, so only the tenant cap applies. + perPrincipal: (owner) => + owner.endpoint === DEV_ENDPOINT_ID ? Infinity : maxPerPrincipal, + }, + idleMs, + ); + // Sessions and handles both own pending fills: session ids are UUIDs and + // handle ids start with br_, so the two cannot collide. + const isLive = (owner: string) => sessions.has(owner) || handles.has(owner); const closeSession = async (id: string) => { const s = sessions.get(id); if (!s) return; sessions.delete(id); - await s.ctx.session.close().catch(() => {}); + await s.browser.session.close().catch(() => {}); await s.transport.close().catch(() => {}); }; - const openSession = async (): Promise => { + /** The browsers a connection of `caller` reaches: its handles, and the + * connection's own browser when it has one. */ + const scopeFor = ( + endpoint: Endpoint, + caller: Caller, + implicit?: BrowserContext, + ) => { + const owner: HandleOwner = { + endpoint: endpoint.id, + principal: caller.principal, + client: caller.client, + }; + return brokerContext( + endpoint.broker, + browserScope({ + handles, + owner, + make: (browserId) => + browserContext( + endpoint.broker, + scopedFills(endpoint.store, { + owner: browserId, + principal: caller.principal, + isLive, + }), + ), + implicit, + }), + ); + }; + + const openSession = async ( + endpoint: Endpoint, + caller: Caller, + ): Promise => { // The session id doubles as the owner of this session's pending fills, // so it is fixed before the context is built. const id = randomUUID(); - const ctx = sessionContext(broker, scopedFills(store, id, isLive)); + const fills = scopedFills(endpoint.store, { + owner: id, + principal: caller.principal, + isLive, + }); + const browser = browserContext(endpoint.broker, fills); + const inFlight = new InFlightIds(); const transport = new NodeStreamableHTTPServerTransport({ sessionIdGenerator: () => id, onsessioninitialized: () => { - sessions.set(id, { transport, ctx, lastSeen: Date.now() }); + sessions.set(id, { + transport, + browser, + lastSeen: Date.now(), + endpoint: endpoint.id, + principal: caller.principal, + client: caller.client, + inFlight, + }); }, }); transport.onclose = () => void closeSession(id); - await createServer(ctx).connect(transport); + await createServer(scopeFor(endpoint, caller, browser)).connect(transport); + // Watch what passes between the transport and the server, so an id is + // released only when the server is done with it. + const deliver = transport.onmessage; + transport.onmessage = (message, extra) => { + inFlight.inbound(message); + deliver?.(message, extra); + }; + const sendToClient = transport.send.bind(transport); + transport.send = async (message, options) => { + try { + await sendToClient(message, options); + } finally { + inFlight.outbound(message); + } + }; return transport; }; - const handleMcp = async (req: IncomingMessage, res: ServerResponse) => { - const sessionId = req.headers["mcp-session-id"]; + /** + * Close the caller's least recently used session that holds nothing: no + * running browser and no request in flight. A GET stream does not count: + * the broker sends no server-initiated messages. Some 2025 clients + * (the Arcade gateway) open a session for each tool call and never close + * it, keeping their state in browser handles. Without this they would + * reach their session limit after a few calls. The client of a closed + * session gets 404 and opens a new one. Returns whether one was closed. + */ + const reclaimSession = async (endpoint: Endpoint, caller: Caller) => { + let oldest: [string, Session] | undefined; + for (const entry of sessions) { + const s = entry[1]; + if ( + s.endpoint === endpoint.id && + s.principal === caller.principal && + s.client === caller.client && + !s.browser.session.isStarted() && + s.inFlight.idle && + (!oldest || s.lastSeen < oldest[1].lastSeen) + ) { + oldest = entry; + } + } + if (!oldest) return false; + await closeSession(oldest[0]); + return true; + }; + + /** Why `caller` cannot open another session, or undefined if it can. */ + const sessionLimit = (endpoint: Endpoint, caller: Caller) => { + if (sessions.size >= maxSessions) { + return "Broker is at its session limit"; + } + const tenantSessions = [...sessions.values()].filter( + (s) => s.endpoint === endpoint.id, + ); + const callerSessions = tenantSessions.filter( + (s) => s.principal === caller.principal, + ).length; + // The dev endpoint has one principal, so only the tenant cap applies. + if (endpoint !== dev && callerSessions >= maxPerPrincipal) { + return "This caller is at its session limit"; + } + if (tenantSessions.length >= maxPerTenant) { + return "This tenant is at its session limit"; + } + return undefined; + }; + + // The 2026-07-28 leg. Era routing happens in handleMcp, so this handler + // sees modern requests only. Each request gets a fresh server over the + // caller's browser scope, which handleMcp passes in authInfo. + const modern: NodeMcpRequestHandler = toNodeHandler( + createMcpHandler( + ({ authInfo }) => + createServer((authInfo?.extra as { ctx: BrokerContext }).ctx), + { + legacy: "reject", + maxRequestBodySize: MAX_BODY_BYTES, + onerror: (err) => + console.error("secure-browser-mcp: modern request failed:", err), + }, + ), + ); + + const handleMcp = async ( + endpoint: Endpoint, + req: IncomingMessage, + res: ServerResponse, + ) => { + const caller = await authenticate(endpoint, req, res); + if (!caller) return; const body = req.method === "POST" ? await readJson(req) : undefined; - // Era routing (SDK v2 legacy-clients guide): 2025-era traffic goes to the - // sessionful transports below. isLegacyRequest is the SDK's own - // classifier, so this branch agrees with what createMcpHandler would do. + // Era routing (SDK v2 legacy-clients guide): 2025-era traffic goes to + // the sessionful transports below, the rest to the modern leg. + // isLegacyRequest is the SDK's own classifier, so this branch agrees + // with what createMcpHandler would do. // GET and DELETE are session operations and always classify legacy. // The cast only bridges `method?: string` under exactOptionalPropertyTypes. - if ( - req.method === "POST" && - !(await isLegacyRequest( + const legacy = + req.method !== "POST" || + (await isLegacyRequest( await toWebRequest(req as NodeIncomingMessageLike, body), body, - )) - ) { - return rejectModern(res, req, body); + )); + + // Checked against the token sent with each request, so a session's + // rights follow its current token. + const missing = missingScopes(body, caller); + if (missing.length > 0) { + return refuse(res, 403, endpoint, { + error: "insufficient_scope", + description: `This tool requires ${missing.join(" ")}`, + scopes: missing, + }); } + if (!legacy) { + // The SDK leaves this check to a hand-wired modern leg. + if (!isJsonContentType(req.headers["content-type"])) { + return rpcError(res, 415, "Content-Type must be application/json"); + } + const authInfo: AuthInfo = { + token: "", + clientId: caller.client ?? "", + scopes: [...caller.scopes], + extra: { ctx: scopeFor(endpoint, caller) }, + }; + // toNodeHandler forwards req.auth as the handler's authInfo. + (req as IncomingMessage & { auth?: AuthInfo }).auth = authInfo; + return modern(req as NodeIncomingMessageLike, res, body); + } + + const sessionId = req.headers["mcp-session-id"]; if (typeof sessionId === "string") { const s = sessions.get(sessionId); - // 404 tells the client to start a new session (MCP spec), which is - // what happens after a broker restart. - if (!s) return rpcError(res, 404, "Session not found"); + // To anyone but the endpoint, principal, and client that opened it, + // the session does not exist. 404 tells the client to start a new one. + // A session opened by a token without a client claim (client + // undefined) takes only tokens without one, and the reverse: it is + // bound to (tenant, iss, sub) alone. allowedClients makes the claim + // required. + if ( + !s || + s.endpoint !== endpoint.id || + s.principal !== caller.principal || + s.client !== caller.client + ) { + return rpcError(res, 404, "Session not found"); + } s.lastSeen = Date.now(); + const reserved = s.inFlight.reserve(body); + if (!reserved.ok) { + // id null: an answer with the reused id would look, to the client, + // like the answer to the request that holds it. + return rpcError( + res, + 409, + `Request id ${JSON.stringify(reserved.id)} is already in use ` + + "in this session", + -32600, + ); + } + res.once("finish", reserved.done); + res.once("close", reserved.done); + if (req.method === "GET") + watchGetStream(s.transport, req, res, caller.expiresAt); return s.transport.handleRequest(req, res, body); } if (req.method !== "POST" || !isInitializeRequest(body)) { return rpcError(res, 400, "Missing mcp-session-id"); } - if (sessions.size >= maxSessions) { - return rpcError(res, 503, "Broker is at its session limit"); + // A session holds a browser, so opening one needs some sbm:* scope. The + // challenge names sbm:browse, the scope every useful session needs. + if (!SCOPES.some((s) => caller.scopes.has(s))) { + return refuse(res, 403, endpoint, { + error: "insufficient_scope", + description: "Opening a session needs at least one sbm:* scope", + scopes: ["sbm:browse"], + }); + } + // At a limit, the caller's own idle, browserless sessions make room + // first. Other callers' sessions are never closed for it. + let limit = sessionLimit(endpoint, caller); + while (limit && (await reclaimSession(endpoint, caller))) { + limit = sessionLimit(endpoint, caller); } - const transport = await openSession(); + if (limit) return rpcError(res, 503, limit); + const transport = await openSession(endpoint, caller); await transport.handleRequest(req, res, body); }; + const route = ( + path: string, + ): + | { kind: "mcp"; endpoint: Endpoint } + | { kind: "metadata"; doc: object } + | undefined => { + if (path === "/mcp" && dev) return { kind: "mcp", endpoint: dev }; + const m = + /^(\/\.well-known\/oauth-protected-resource)?\/t\/([^/]+)\/mcp$/.exec( + path, + ); + const endpoint = m && tenants.get(m[2]!); + if (!m || !endpoint) return undefined; + return m[1] + ? { kind: "metadata", doc: endpoint.metadata.doc } + : { kind: "mcp", endpoint }; + }; + const http = createHttpServer((req, res) => { const path = new URL(req.url ?? "/", "http://localhost").pathname; if (path === "/healthz") { - return send(res, 200, { - ok: true, - backend: broker.backend, - browser: broker.browser, + return send(res, 200, { ok: true, tenants: tenants.size }); + } + const target = route(path); + if (!target) return send(res, 404, { error: "not found" }); + if (target.kind === "metadata") { + if (req.method !== "GET") { + return send( + res, + 405, + { error: "method not allowed" }, + { allow: "GET" }, + ); + } + // Public, and holds nothing the 401 challenge does not point at, so + // it skips the Origin check; browser clients fetch it cross-origin. + return send(res, 200, target.doc, { + "access-control-allow-origin": "*", + "cache-control": "max-age=300", }); } - if (path !== "/mcp") return send(res, 404, { error: "not found" }); // Before auth and before any session work. if (!originAllowed(req, allowedOrigins)) { return rpcError(res, 403, "Origin not allowed"); } - if (!authorized(req, token)) { - return send( - res, - 401, - { error: "unauthorized" }, - { - "www-authenticate": "Bearer", - }, - ); - } - handleMcp(req, res).catch((err) => { + handleMcp(target.endpoint, req, res).catch((err) => { console.error("secure-browser-mcp: request failed:", err); if (!res.headersSent) rpcError(res, 400, "Bad request"); else res.end(); @@ -334,21 +815,24 @@ async function main(): Promise { for (const [id, s] of sessions) { if (s.lastSeen < cutoff) void closeSession(id); } - }, 60_000); + void handles.sweep(); + }, sweepMs); sweep.unref(); await new Promise((resolve) => http.listen(port, host, resolve)); console.error( - `secure-browser-mcp: listening on http://${host}:${port}/mcp ` + - `(backend: ${broker.backend}, browser: ${broker.browser})`, + `secure-browser-mcp: listening on http://${host}:${port} ` + + `(tenants: ${[...tenants.keys()].join(", ") || "none"}` + + `${dev ? "; dev /mcp" : ""})`, ); - // Pending fills stay in the store; browsers do not outlive the process. + // Pending fills stay in the stores; browsers do not outlive the process. const shutdown = () => { http.close(); - void Promise.all([...sessions.keys()].map(closeSession)).finally(() => - process.exit(0), - ); + void Promise.all([ + ...[...sessions.keys()].map(closeSession), + handles.closeAll(), + ]).finally(() => process.exit(0)); }; process.on("SIGTERM", shutdown); process.on("SIGINT", shutdown); diff --git a/src/index.ts b/src/index.ts index 7182535..bce742c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,30 +1,55 @@ import { StdioServerTransport } from "@modelcontextprotocol/server/stdio"; import { MemoryPendingFillStore, scopedFills } from "./broker/pending-store.js"; -import { makeBroker, sessionContext } from "./runtime.js"; +import { BrowserHandles, browserScope } from "./browsers.js"; +import { brokerContext, browserContext, makeBroker } from "./runtime.js"; import { createServer } from "./server.js"; +const OWNER = { endpoint: "stdio", principal: "stdio", client: undefined }; + async function main(): Promise { const broker = makeBroker(); // One process, one agent session: pending fills live in memory and die // with the process. The hosted entrypoint (src/http.ts) persists them. - const ctx = sessionContext( + const store = new MemoryPendingFillStore(); + const idleS = Number(process.env["SBM_SESSION_IDLE_S"] ?? "1800"); + // The process's own browser, plus any the agent opens with open_browser. + const handles = new BrowserHandles( + { max: 4, perEndpoint: 4, perPrincipal: () => 4 }, + (idleS > 0 ? idleS : 1800) * 1_000, + ); + const isLive = (owner: string) => owner === "stdio" || handles.has(owner); + const implicit = browserContext( broker, - scopedFills(new MemoryPendingFillStore(), "stdio"), + scopedFills(store, { owner: "stdio", principal: "stdio", isLive }), ); + const scope = browserScope({ + handles, + owner: OWNER, + make: (id) => + browserContext( + broker, + scopedFills(store, { owner: id, principal: "stdio", isLive }), + ), + implicit, + }); - const server = createServer(ctx); + const server = createServer(brokerContext(broker, scope)); await server.connect(new StdioServerTransport()); // stdout carries the MCP transport; diagnostics go to stderr only. console.error( `secure-browser-mcp: listening on stdio (backend: ${broker.backend}, ` + `browser: ${broker.browser})`, ); + const sweep = setInterval(() => void handles.sweep(), 60_000); + sweep.unref(); - // When the client disconnects, take the browser down with us — an orphaned - // broker browser is an unowned browser. + // When the client disconnects, take the browsers down with us — an + // orphaned broker browser is an unowned browser. const shutdown = () => { - void ctx.session.close().finally(() => process.exit(0)); + void Promise.all([implicit.session.close(), handles.closeAll()]).finally( + () => process.exit(0), + ); }; process.stdin.on("end", shutdown); process.stdin.on("close", shutdown); diff --git a/src/inflight.ts b/src/inflight.ts new file mode 100644 index 0000000..6eba725 --- /dev/null +++ b/src/inflight.ts @@ -0,0 +1,128 @@ +/** + * JSON-RPC request ids in flight in one hosted MCP session. + * + * The SDK's Streamable HTTP transport sends each response on the HTTP + * stream of the request with the same id. A second request that reuses an + * id in flight would get the first one's response. Two tokens of one + * subject, with different scopes, can share a session, so a browse-only + * request could read a list_secret_refs result. src/http.ts refuses a + * request whose id is in flight. + * + * An id the server received stays reserved until the server has answered + * it or aborted it on a cancel. After a client disconnect the SDK still maps + * the id and would send the late answer to the next request that uses it, + * and on Bun 1.3.x and earlier node:http emits no event on the server when + * a POST client disconnects, so the HTTP response is no signal for these. + * An id the transport refused before the server saw it is reserved until + * that HTTP response has ended. A cancel that arrives before its request does nothing + * to the server, which still answers, so it does not settle the id either. + * + * A cancel frees the id at once, but the aborted handler keeps running. A + * notification or elicitation it then sends with `relatedRequestId` goes to + * the stream of a new request that reuses the id. No tool sends these + * today. A tool that does must check `signal.aborted` before each send, or + * the id must stay reserved until the handler returns. + */ +import type { JSONRPCMessage, RequestId } from "@modelcontextprotocol/server"; + +type Entry = { + httpDone: boolean; + /** The transport gave the request to the server. */ + dispatched: boolean; + /** The server answered, or the client cancelled a dispatched request. */ + settled: boolean; +}; + +const isId = (id: unknown): id is RequestId => + typeof id === "string" || typeof id === "number"; + +/** Ids of the requests in a POST body. Notifications and responses have no + * id that the transport maps. */ +function requestIds(body: unknown): RequestId[] { + const messages = Array.isArray(body) ? body : [body]; + const ids: RequestId[] = []; + for (const m of messages) { + if (!m || typeof m !== "object") continue; + const { method, id } = m as { method?: unknown; id?: unknown }; + if (typeof method === "string" && isId(id)) ids.push(id); + } + return ids; +} + +export class InFlightIds { + private readonly entries = new Map(); + + /** + * Reserve a POST body's request ids, or refuse the whole body when an id + * is in flight or appears twice. Call `done` when the HTTP response ends; + * it frees the ids the transport never gave to the server. + */ + reserve( + body: unknown, + ): { ok: true; done: () => void } | { ok: false; id: RequestId } { + const ids = requestIds(body); + const seen = new Set(); + for (const id of ids) { + if (this.entries.has(id) || seen.has(id)) return { ok: false, id }; + seen.add(id); + } + const mine = ids.map((id) => { + const entry = { httpDone: false, dispatched: false, settled: false }; + this.entries.set(id, entry); + return [id, entry] as const; + }); + let called = false; + return { + ok: true, + done: () => { + if (called) return; + called = true; + for (const [id, entry] of mine) { + entry.httpDone = true; + this.releaseIfFree(id, entry); + } + }, + }; + } + + /** Whether no request id of this session is in flight. */ + get idle(): boolean { + return this.entries.size === 0; + } + + /** A message the transport gives to the server. */ + inbound(message: JSONRPCMessage): void { + const m = message as { + method?: unknown; + id?: unknown; + params?: { requestId?: unknown }; + }; + if (typeof m.method !== "string") return; + if (isId(m.id)) { + const entry = this.entries.get(m.id); + if (entry) entry.dispatched = true; + } else if (m.method === "notifications/cancelled") { + // The server drops the answer to a dispatched request it cancels. + this.settle(m.params?.requestId); + } + } + + /** A message the server sent through the transport. */ + outbound(message: JSONRPCMessage): void { + const m = message as { method?: unknown; id?: unknown }; + if (m.method === undefined) this.settle(m.id); + } + + private settle(id: unknown): void { + if (!isId(id)) return; + const entry = this.entries.get(id); + if (!entry || !entry.dispatched) return; + entry.settled = true; + this.releaseIfFree(id, entry); + } + + private releaseIfFree(id: RequestId, entry: Entry): void { + const free = entry.dispatched ? entry.settled : entry.httpDone; + if (free && this.entries.get(id) === entry) this.entries.delete(id); + } +} diff --git a/src/redaction/registry.ts b/src/redaction/registry.ts deleted file mode 100644 index f3bf74b..0000000 --- a/src/redaction/registry.ts +++ /dev/null @@ -1,196 +0,0 @@ -/** - * Tracks which secret material is "live" — exported and possibly present in - * the page, network requests, or console output — so every tool result can - * be scrubbed before it reaches the agent. - * - * Two registries: - * - plaintext scrub-set: values to value-scan out of any output - * (backstop; catches secrets echoed via network bodies, console, etc.) - * - tagged fields: element uids whose values must be structurally elided - * from snapshots (primary; does not depend on spotting the value) - * - * The value-scan also catches reformatted copies. Pages normalize what they - * receive ("4242424242424242" renders as "4242 4242 4242 4242"), so values - * long enough to match safely are also compared on their letters and digits - * only, case folded and NFKC-normalized. Values made only of digits compare - * on digits only, so letter separators ("4242x4242") match too. Any - * separator counts, but each gap between kept characters is capped so a - * match cannot stitch together digits from unrelated text. A hostile - * destination can always encode a value past any scanner; that is the - * out-of-scope "target site" case in THREAT-MODEL.md. - */ - -const ALNUM = /[\p{L}\p{N}]/u; -const DIGIT = /\p{Nd}/u; - -/** Most separator characters allowed between two kept characters of a match. */ -const MAX_GAP = 3; - -/** - * Shortest value matched loosely. Shorter values (a CVC, an expiry) would - * redact unrelated digits all over the output; they keep exact matching. - */ -export const MIN_LOOSE_MATCH_LENGTH = 8; - -type Loose = { text: string; keep: RegExp }; - -type LiveValue = { secretId: string; loose: Loose | undefined }; - -export type Span = { start: number; end: number; secretId: string }; - -export class RedactionRegistry { - private readonly plaintexts = new Map(); // value → info - private readonly taggedFields = new Map(); // elementUid → secretId - - /** Register a live plaintext the moment it is exported into broker memory. */ - trackValue(plaintext: string, secretId: string): void { - const text = normalize(plaintext, ALNUM).text; - const keep = [...text].every((ch) => DIGIT.test(ch)) ? DIGIT : ALNUM; - this.plaintexts.set(plaintext, { - secretId, - loose: text.length >= MIN_LOOSE_MATCH_LENGTH ? { text, keep } : undefined, - }); - } - - /** Register a field that received a secret, keyed by snapshot element uid. */ - trackField(elementUid: string, secretId: string): void { - this.taggedFields.set(elementUid, secretId); - } - - isTaggedField(elementUid: string): boolean { - return this.taggedFields.has(elementUid); - } - - /** The secretId of the first live plaintext in `text`, if any. */ - findSecret(text: string): string | undefined { - return this.spans(text)[0]?.secretId; - } - - /** Every live plaintext in `text`, as sorted, non-overlapping spans. */ - matches(text: string): Span[] { - return this.spans(text); - } - - /** Value-scan: replace every occurrence of a live plaintext in `text`. */ - scrubText(text: string): string { - const spans = this.spans(text); - if (spans.length === 0) return text; - let out = ""; - let cursor = 0; - for (const span of spans) { - out += text.slice(cursor, span.start) + `[REDACTED:${span.secretId}]`; - cursor = span.end; - } - return out + text.slice(cursor); - } - - /** Every match in `text`, sorted, with overlapping matches merged. */ - private spans(text: string): Span[] { - const found: Span[] = []; - const normalized = new Map>(); - for (const [value, { secretId, loose }] of this.plaintexts) { - if (value.length === 0) continue; - for (let i = text.indexOf(value); i !== -1;) { - found.push({ start: i, end: i + value.length, secretId }); - i = text.indexOf(value, i + value.length); - } - if (loose === undefined) continue; - let haystack = normalized.get(loose.keep); - if (!haystack) { - haystack = normalize(text, loose.keep); - normalized.set(loose.keep, haystack); - } - const { text: hay, starts, ends, gaps } = haystack; - const needle = loose.text; - for (let i = hay.indexOf(needle); i !== -1;) { - const last = i + needle.length - 1; - if (!withinGap(gaps, i, last)) { - i = hay.indexOf(needle, i + 1); - continue; - } - found.push({ start: starts[i]!, end: ends[last]!, secretId }); - i = hay.indexOf(needle, i + needle.length); - } - } - found.sort((a, b) => a.start - b.start || b.end - a.end); - const merged: Span[] = []; - for (const span of found) { - const prev = merged[merged.length - 1]; - if (prev && span.start < prev.end) { - prev.end = Math.max(prev.end, span.end); - } else { - merged.push({ ...span }); - } - } - return merged; - } -} - -/** Whether no gap between kept characters `first`..`last` exceeds MAX_GAP. */ -function withinGap(gaps: number[], first: number, last: number): boolean { - for (let k = first + 1; k <= last; k++) { - if (gaps[k]! > MAX_GAP) return false; - } - return true; -} - -const PERCENT_ESCAPE = /^%[0-9a-f]{2}$/i; - -/** - * Keep only characters matching `keep`, NFKC-normalized, case folded and with - * decimal digits read as 0-9, one code point (or percent escape) at a time. - * Records the span of the original each kept character came from, so a match - * maps back to the original text, and how many characters were dropped - * before it. - */ -function normalize( - text: string, - keep: RegExp, -): { text: string; starts: number[]; ends: number[]; gaps: number[] } { - let out = ""; - const starts: number[] = []; - const ends: number[] = []; - const gaps: number[] = []; - let gap = 0; - let i = 0; - while (i < text.length) { - let unit = String.fromCodePoint(text.codePointAt(i)!); - let width = unit.length; - const escape = text.slice(i, i + 3); - if (PERCENT_ESCAPE.test(escape)) { - const byte = parseInt(escape.slice(1), 16); - // Multi-byte UTF-8 escapes stay separators: formatters emit ASCII digits. - unit = byte < 0x80 ? String.fromCharCode(byte) : "%"; - width = 3; - } - for (const ch of unit.normalize("NFKC").toLowerCase()) { - if (!keep.test(ch)) { - gap++; - continue; - } - out += DIGIT.test(ch) ? digitValue(ch) : ch; - starts.push(i); - ends.push(i + width); - gaps.push(gap); - gap = 0; - } - i += width; - } - return { text: out, starts, ends, gaps }; -} - -/** - * The 0-9 value of a decimal digit in any script. Unicode lays each script's - * digits out as a contiguous run starting at zero, so the value is the - * position in that run. - */ -function digitValue(ch: string): string { - if (ch >= "0" && ch <= "9") return ch; - let cp = ch.codePointAt(0)!; - let n = 0; - while (DIGIT.test(String.fromCodePoint(cp - 1))) { - cp--; - n++; - } - return String(n % 10); -} diff --git a/src/redaction/scrub.ts b/src/redaction/scrub.ts deleted file mode 100644 index 315cb94..0000000 --- a/src/redaction/scrub.ts +++ /dev/null @@ -1,26 +0,0 @@ -import type { RedactionRegistry } from "./registry.js"; - -/** - * The single choke point between tool handlers and the agent: `server.ts` - * pipes EVERY tool result through this before returning it over MCP. Tool - * handlers never serialize their own output to the transport. - * - * Today: recursive value-scan over all string content. TODO(scaffold): - * structural redaction — snapshot serializers consult - * `registry.isTaggedField()` and elide tagged input values outright, and the - * network-log tool drops request bodies for origins with live secrets. - */ -export function scrub(registry: RedactionRegistry, output: T): T { - return scrubValue(registry, output) as T; -} - -function scrubValue(registry: RedactionRegistry, value: unknown): unknown { - if (typeof value === "string") return registry.scrubText(value); - if (Array.isArray(value)) return value.map((v) => scrubValue(registry, v)); - if (value !== null && typeof value === "object") { - return Object.fromEntries( - Object.entries(value).map(([k, v]) => [k, scrubValue(registry, v)]), - ); - } - return value; -} diff --git a/src/runtime.ts b/src/runtime.ts index acc01de..f99b6be 100644 --- a/src/runtime.ts +++ b/src/runtime.ts @@ -4,12 +4,15 @@ */ import { existsSync } from "node:fs"; -import { BindingPolicy } from "./broker/binding.js"; +import { RedactionRegistry } from "@turnkey/browser-secrets"; + +import { resolveApiKey, type TenantConfig } from "./auth/tenants.js"; import { MockSecretsClient } from "./broker/mock-secrets.js"; import type { PendingFills } from "./broker/pending-store.js"; import type { SecretsClient } from "./broker/secrets-client.js"; import { dashboardActivityUrl, + turnkeyClient, turnkeyClientFromEnv, } from "./broker/turnkey-env.js"; import { TurnkeySecretsClient } from "./broker/turnkey-secrets.js"; @@ -18,31 +21,51 @@ import { LocalChromeHost, type BrowserHost, } from "./browser/hosts.js"; +import { LightpandaHost } from "./browser/lightpanda.js"; import { BrowserSession } from "./browser/session.js"; -import { RedactionRegistry } from "./redaction/registry.js"; -import type { ToolContext } from "./tools/tool.js"; +import type { BrowserContext, BrowserScope } from "./browsers.js"; +import type { BrokerContext } from "./tools/tool.js"; // Backend selection: Turnkey when credentials are configured, mock otherwise. // SBM_MOCK_CONSENSUS (comma-separated secret names) makes those mock secrets // consensus-gated, with approval arriving SBM_MOCK_CONSENSUS_DELAY_MS after // the first export attempt — enough to exercise the pending → await flow. -export function makeSecretsClient(): { +export type Secrets = { client: SecretsClient; backend: "mock" | "turnkey"; -} { +}; + +export function makeSecretsClient(): Secrets { const apiClient = turnkeyClientFromEnv(); if (apiClient) { return { client: new TurnkeySecretsClient(apiClient), backend: "turnkey" }; } + return { client: mockFromEnv(), backend: "mock" }; +} + +function mockFromEnv( + tenant?: Extract, +): MockSecretsClient { const consensus = process.env["SBM_MOCK_CONSENSUS"]; const delay = Number(process.env["SBM_MOCK_CONSENSUS_DELAY_MS"] ?? "3000"); - const client = new MockSecretsClient( - undefined, - consensus + return new MockSecretsClient(tenant?.mockSecrets, { + ...(consensus ? { consensusNames: consensus.split(","), approvalDelayMs: delay } - : {}, - ); - return { client, backend: "mock" }; + : {}), + listDelayMs: tenant?.listDelayMs, + }); +} + +/** A hosted tenant's secrets: its own organization, through its own key. */ +export function tenantSecretsClient(tenant: TenantConfig): Secrets { + if (tenant.backend === "mock") { + return { client: mockFromEnv(tenant), backend: "mock" }; + } + const apiClient = turnkeyClient({ + ...resolveApiKey(tenant), + organizationId: tenant.organizationId, + }); + return { client: new TurnkeySecretsClient(apiClient), backend: "turnkey" }; } const CHROME_CANDIDATES = [ @@ -70,10 +93,9 @@ export function findChrome(): string { export type Broker = { secrets: SecretsClient; backend: "mock" | "turnkey"; - binding: BindingPolicy; /** Builds one browser host per agent session. */ browserHost: () => BrowserHost; - browser: "local" | "browserbase"; + browser: "local" | "browserbase" | "lightpanda"; }; /** @@ -83,6 +105,24 @@ export type Broker = { */ function browserHostFromEnv(): Pick { const kind = process.env["SBM_BROWSER"] ?? "local"; + if (kind === "lightpanda") { + const socket = process.env["SBM_LIGHTPANDA_SOCKET"]; + if (!socket) + throw new Error("Lightpanda requires a dedicated SBM_LIGHTPANDA_SOCKET"); + // One worker per broker instance; hosted multi-session pooling is unsupported. + let allocated = false; + return { + browser: "lightpanda", + browserHost: () => { + if (allocated) + throw new Error( + "Provision a new isolated Lightpanda worker and broker", + ); + allocated = true; + return new LightpandaHost(socket); + }, + }; + } if (kind === "browserbase") { const apiKey = process.env["BROWSERBASE_API_KEY"]; if (!apiKey) @@ -101,7 +141,7 @@ function browserHostFromEnv(): Pick { } if (kind !== "local") { throw new Error( - `SBM_BROWSER must be "local" or "browserbase", not "${kind}"`, + `SBM_BROWSER must be "local", "lightpanda" or "browserbase", not "${kind}"`, ); } const executablePath = findChrome(); @@ -116,31 +156,41 @@ function browserHostFromEnv(): Pick { }; } -export function makeBroker(): Broker { - const { client, backend } = makeSecretsClient(); +/** A broker over `secrets`, by default the TURNKEY_* key (or the mock). */ +export function makeBroker( + { client, backend }: Secrets = makeSecretsClient(), +): Broker { return { secrets: client, backend, - binding: new BindingPolicy(), ...browserHostFromEnv(), }; } -/** A fresh context for one agent session: its own browser, redaction - * registry, and view of the pending fills. */ -export function sessionContext( +/** The broker side of a connection's tool calls. */ +export function brokerContext( broker: Broker, - pendingFills: PendingFills, -): ToolContext { + browsers: BrowserScope, +): BrokerContext { return { secrets: broker.secrets, backend: broker.backend, - binding: broker.binding, - session: new BrowserSession(broker.browserHost()), - registry: new RedactionRegistry(), - pendingFills, + browsers, ...(broker.backend === "turnkey" ? { approvalUrl: dashboardActivityUrl } : {}), }; } + +/** A fresh browser for one agent session or browser handle: its own + * browser, redaction registry, and view of the pending fills. */ +export function browserContext( + broker: Broker, + pendingFills: PendingFills, +): BrowserContext { + return { + session: new BrowserSession(broker.browserHost()), + registry: new RedactionRegistry(), + pendingFills, + }; +} diff --git a/src/server.ts b/src/server.ts index 7554d51..e6e7b84 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,8 +1,13 @@ import { McpServer, type CallToolResult } from "@modelcontextprotocol/server"; +import { RedactionRegistry } from "@turnkey/browser-secrets"; import { z } from "zod"; -import { scrub } from "./redaction/scrub.js"; import { awaitFill } from "./tools/await-fill.js"; +import { + browserIdArg, + closeBrowser, + openBrowser, +} from "./tools/browser-handles.js"; import { click } from "./tools/click.js"; import { fillSecret } from "./tools/fill-secret.js"; import { listNetwork } from "./tools/list-network.js"; @@ -10,13 +15,15 @@ import { listSecretRefs } from "./tools/list-secret-refs.js"; import { navigate } from "./tools/navigate.js"; import { snapshot } from "./tools/snapshot.js"; import { typeText } from "./tools/type-text.js"; -import type { AnyToolDef, ToolContext } from "./tools/tool.js"; +import type { AnyToolDef, BrokerContext } from "./tools/tool.js"; // Deliberate absence: there is no evaluate_script tool. Agent-authored page // JS could hook input events and exfiltrate a secret before or after a fill, // so script evaluation is excluded from the tool surface by design — see // docs/DESIGN.md and docs/THREAT-MODEL.md before adding anything here. -const TOOLS: AnyToolDef[] = [ +export const TOOLS: AnyToolDef[] = [ + openBrowser, + closeBrowser, listSecretRefs, navigate, snapshot, @@ -27,7 +34,7 @@ const TOOLS: AnyToolDef[] = [ listNetwork, ]; -export function createServer(ctx: ToolContext): McpServer { +export function createServer(ctx: BrokerContext): McpServer { const server = new McpServer({ name: "secure-browser-mcp", version: "0.0.1", @@ -40,29 +47,73 @@ export function createServer(ctx: ToolContext): McpServer { const registerTool = server.registerTool.bind(server) as ( name: string, config: { description: string; inputSchema: z.ZodObject }, - cb: (args: Record) => Promise, + cb: ( + args: Record, + request: { mcpReq: { signal: AbortSignal } }, + ) => Promise, ) => void; for (const tool of TOOLS) { registerTool( tool.name, // v2 takes a schema object; the tools keep their v1-style raw shapes, - // and z.object() is what v1 wrapped them in. + // and z.object() is what v1 wrapped them in. Every browser tool takes + // browser_id; the handler never sees it. { description: tool.description, - inputSchema: z.object(tool.inputSchema), + inputSchema: z.object( + tool.browser + ? { ...tool.inputSchema, browser_id: browserIdArg } + : tool.inputSchema, + ), }, - async (args: Record) => { + async ( + input: Record, + request: { mcpReq: { signal: AbortSignal } }, + ) => { + const { browser_id, ...rest } = input; + const args = tool.browser ? rest : input; + // Errors are scrubbed with the registry of the browser the call + // ran in. One that fails before reaching a browser (an unknown + // browser_id) has no secret to scrub. + let registry = new RedactionRegistry(); try { // Single choke point: every result — success or error — passes - // through scrub() before it reaches the transport. Args were - // already validated against the tool's zod shape by the SDK. - const result = await tool.handler(ctx, args as never); + // through registry.scrub() before it reaches the transport. Args + // were already validated against the tool's zod shape by the SDK. + const result = await ctx.browsers.run( + tool.browser ? (browser_id as string | undefined) : undefined, + tool.browser, + async (browser) => { + registry = browser.registry; + const cancel = () => { + void browser.session.cancelRequest().catch(() => {}); + }; + request.mcpReq.signal.addEventListener("abort", cancel, { + once: true, + }); + try { + if (request.mcpReq.signal.aborted) { + cancel(); + throw new Error("Request cancelled"); + } + const out = await tool.handler( + { ...ctx, ...browser, signal: request.mcpReq.signal }, + args as never, + ); + if (request.mcpReq.signal.aborted) + throw new Error("Request cancelled"); + return browser.registry.scrub(out); + } finally { + request.mcpReq.signal.removeEventListener("abort", cancel); + } + }, + ); return { content: [ { type: "text" as const, - text: JSON.stringify(scrub(ctx.registry, result), null, 2), + text: JSON.stringify(result, null, 2), }, ], }; @@ -73,7 +124,7 @@ export function createServer(ctx: ToolContext): McpServer { content: [ { type: "text" as const, - text: ctx.registry.scrubText(message), + text: registry.scrubText(message), }, ], }; diff --git a/src/tools/await-fill.ts b/src/tools/await-fill.ts index 1d676d8..965c05c 100644 --- a/src/tools/await-fill.ts +++ b/src/tools/await-fill.ts @@ -2,16 +2,21 @@ import { z } from "zod"; import { ConsensusPendingError } from "../broker/secrets-client.js"; import { + assertSameDestination, + FillDeniedError, injectResolved, specsFromArgs, targetArgs, validateTargets, + type ValidatedFill, } from "./fill-common.js"; import { pendingResult } from "./fill-secret.js"; import { defineTool, type PendingFill, type ToolContext } from "./tool.js"; export const awaitFill = defineTool({ name: "await_fill", + scope: "sbm:fill", + browser: true, description: "Complete a fill_secret that returned pending_approval. Waits for the " + "consensus export to be approved, re-validates the destination against " + @@ -40,17 +45,18 @@ export const awaitFill = defineTool({ // checks, so this lets the agent point at a fresh snapshot, not at a // destination fill_secret would refuse. They are checked before they // replace anything: a refused re-target leaves the parked targets as-is. + let checked: ValidatedFill; if (args.element_uid !== undefined || args.fields !== undefined) { const specs = specsFromArgs(args); if (!specs) throw new Error("Provide at most one of element_uid or fields"); - await validateOrExplain(ctx, fill, specs); + checked = await validateOrExplain(ctx, fill, specs); fill.targets = specs; await ctx.pendingFills.put(fill); } else { // Check the destination before waiting, so a stale page is reported // now rather than after the approver acts. The fill stays parked. - await validateOrExplain(ctx, fill); + checked = await validateOrExplain(ctx, fill); } // Wait for quorum. Timeout is not failure: report pending and keep the @@ -74,20 +80,26 @@ export const awaitFill = defineTool({ throw err; } - // Approval took wall-clock time; the page may have moved. Re-resolve - // every element and re-check the binding against the live page before - // the plaintext goes anywhere. If a target is gone, the value is dropped - // (never redirected to a "close enough" field) and the fill stays - // parked: the approved export can be redeemed again into new targets. - let resolved; + // Approval took wall-clock time; the page may have moved. Re-check + // every element against the live page, and require the same tab, + // document and elements that were checked before the wait, before the + // plaintext goes anywhere. If a target is gone or the page changed, the + // value is dropped (never redirected to a "close enough" field) and the + // fill stays parked: the approved export can be redeemed again into + // new targets. + let validated; try { - resolved = await validateOrExplain(ctx, fill); + validated = await validateOrExplain(ctx, fill); + assertSameDestination(checked, validated); } catch (err) { exported.release(); + if (err instanceof FillDeniedError) { + if (err.reason === "destination_changed") throw staleTargets(fill); + } throw err; } - await injectResolved(ctx, exported, resolved); + await injectResolved(ctx, exported, validated); await ctx.pendingFills.delete(fill.fillId); return { filled: true, @@ -101,18 +113,25 @@ async function validateOrExplain( ctx: ToolContext, fill: PendingFill, targets = fill.targets, -) { +): Promise { try { return await validateTargets(ctx, fill.pending.ref, targets); } catch (err) { - if (err instanceof Error && /Unknown element uid/i.test(err.message)) { - throw new Error( - "A target element is no longer available (the page changed or the " + - `broker restarted). Navigate to ${fill.pageUrl} if needed, take a ` + - "snapshot, and call await_fill again with this fill_id and the " + - "new element_uid or fields.", - ); + if ( + err instanceof FillDeniedError && + err.reason === "target_not_observed" + ) { + throw staleTargets(fill); } throw err; } } + +function staleTargets(fill: PendingFill): Error { + return new Error( + "A target element is no longer available (the page changed or the " + + `broker restarted). Navigate to ${fill.pageUrl} if needed, take a ` + + "snapshot, and call await_fill again with this fill_id and the " + + "new element_uid or fields.", + ); +} diff --git a/src/tools/browser-handles.ts b/src/tools/browser-handles.ts new file mode 100644 index 0000000..dc027a8 --- /dev/null +++ b/src/tools/browser-handles.ts @@ -0,0 +1,53 @@ +import { z } from "zod"; + +import { UnknownBrowserError } from "../browsers.js"; +import { defineTool } from "./tool.js"; + +/** The `browser_id` argument server.ts adds to every browser tool. */ +export const browserIdArg = z + .string() + .optional() + .describe( + "browser_id from open_browser. Required on connections that have no " + + "browser of their own (MCP 2026-07-28); on a 2025-era session, omit " + + "it to use the session's own browser.", + ); + +export const openBrowser = defineTool({ + name: "open_browser", + scope: "sbm:browse", + browser: false, + description: + "Open a broker-owned browser and return its browser_id. Pass the " + + "browser_id to every browser tool (navigate, snapshot, click, " + + "type_text, fill_secret, await_fill) to act in that browser, from any " + + "connection of the same caller. Call close_browser when done; an " + + "unused browser also closes after its idle timeout.", + inputSchema: {}, + handler: async (ctx) => { + const browserId = ctx.browsers.open(); + return { + browser_id: browserId, + idle_timeout_seconds: ctx.browsers.idleSeconds, + }; + }, +}); + +export const closeBrowser = defineTool({ + name: "close_browser", + scope: "sbm:browse", + browser: false, + description: + "Close a browser opened with open_browser. Its pages and element uids " + + "are gone. A fill still pending approval stays redeemable: pass its " + + "fill_id to await_fill in another browser.", + inputSchema: { + browser_id: z.string().describe("browser_id from open_browser"), + }, + handler: async (ctx, args) => { + if (!(await ctx.browsers.close(args.browser_id))) { + throw new UnknownBrowserError(args.browser_id); + } + return { closed: args.browser_id }; + }, +}); diff --git a/src/tools/click.ts b/src/tools/click.ts index 26febf3..ace200a 100644 --- a/src/tools/click.ts +++ b/src/tools/click.ts @@ -4,6 +4,8 @@ import { defineTool } from "./tool.js"; export const click = defineTool({ name: "click", + scope: "sbm:browse", + browser: true, description: "Click an element identified by its snapshot uid.", inputSchema: { element_uid: z.string(), diff --git a/src/tools/fill-common.ts b/src/tools/fill-common.ts index ede9960..5584a2c 100644 --- a/src/tools/fill-common.ts +++ b/src/tools/fill-common.ts @@ -1,16 +1,31 @@ /** - * Fill logic shared by fill_secret and await_fill: target validation against - * the binding, and plaintext fan-out for JSON multi-field secrets. Both - * callers run validation immediately before injection — for await_fill that - * means re-validating after the approval wait, against the live page. + * Fill logic shared by fill_secret and await_fill: target authorization + * against the binding, and plaintext fan-out for JSON multi-field secrets. + * + * The decision is @turnkey/browser-secrets `authorize`, on an observation + * the browser layer reads from the live page (src/browser/observe.ts). + * Callers authorize before export, and `injectResolved` authorizes each + * target again immediately before its write and requires the same + * destination (`isSameDestination`): the same tab, frame, document and + * element that were checked. */ -import type { ElementHandle } from "puppeteer-core"; +import { + authorize, + DENY_REASON_MESSAGES, + isSameDestination, + redactionEntries, + validateRequest, + type AuthorizationAllowed, + type DenyReason, + type FillRequest, + type RedactionTarget, +} from "@turnkey/browser-secrets"; import { z } from "zod"; -import { BindingViolationError } from "../broker/binding.js"; -import type { ExportedSecret, FillTarget, SecretRef } from "../broker/types.js"; +import type { ExportedSecret, SecretRef } from "../broker/types.js"; import { injectSecret } from "../browser/inject.js"; -import type { ToolContext } from "./tool.js"; +import { observeTargets } from "../browser/observe.js"; +import { assertActive, type ToolContext } from "./tool.js"; /** One requested destination: an element, plus the payload key for JSON * multi-field secrets (absent for plain single-value fills). */ @@ -51,104 +66,168 @@ export function specsFromArgs(args: { : [{ elementUid: args.element_uid! }]; } -type ResolvedTarget = { - spec: FillFieldSpec; - target: FillTarget; - handle: ElementHandle; +/** Why a fill was refused: a browser-secrets deny reason, or a page that + * changed between the check and the write. */ +export type FillDenyReason = DenyReason | "destination_changed"; + +const DESTINATION_CHANGED = + "The page changed after the fill target was checked. Take a new " + + "snapshot and try again."; + +/** + * A refused fill. The message is a fixed text per reason, the reason code, + * and agent-supplied names only (the secret's name or ID, an element uid), + * so it holds no secret material and no page URL. + */ +export class FillDeniedError extends Error { + constructor( + readonly reason: FillDenyReason, + ref: Pick, + elementUid?: string, + ) { + const text = + reason === "destination_changed" + ? DESTINATION_CHANGED + : DENY_REASON_MESSAGES[reason]; + super( + `Refusing to fill "${ref.name ?? ref.secretId}": ${text} ` + + `(${reason}${elementUid !== undefined ? `, element ${elementUid}` : ""})`, + ); + this.name = "FillDeniedError"; + } +} + +/** A fill that passed `authorize` against the live page. */ +export type ValidatedFill = { + ref: SecretRef; + specs: FillFieldSpec[]; + request: FillRequest; + decision: AuthorizationAllowed; + /** The live top-level URL the decision was made on. */ + pageUrl: string; }; +function toRequest(specs: FillFieldSpec[]): FillRequest { + return { + targets: specs.map((s) => + s.key === undefined + ? { elementId: s.elementUid } + : { elementId: s.elementUid, key: s.key }, + ), + }; +} + +function deny( + ref: SecretRef, + specs: FillFieldSpec[], + reason: FillDenyReason, + targetIndex?: number, +): FillDeniedError { + const uid = targetIndex === undefined ? undefined : specs[targetIndex]; + return new FillDeniedError(reason, ref, uid?.elementUid); +} + /** - * Resolve every requested element on the live page and enforce the binding - * per target. Plain fills check `binding.selector`; keyed fills check the - * key's selector from `binding.fields` (and refuse keys the binding doesn't - * declare — the agent cannot invent new destinations for payload parts). + * Authorize every requested element on the live page. Request-shape and + * binding checks run first, before the browser is touched. Plain fills + * check `sbm:selector`; keyed fills check the key's selector from + * `sbm:fields` (and keys the binding doesn't declare are refused — the + * agent cannot invent new destinations for payload parts). */ export async function validateTargets( ctx: ToolContext, ref: SecretRef, specs: FillFieldSpec[], -): Promise { - if (specs.length === 0) throw new Error("No fill targets given"); - const keyed = specs.filter((s) => s.key !== undefined); - if (keyed.length !== 0 && keyed.length !== specs.length) { - throw new Error("Mix of keyed and unkeyed fill targets"); - } - const dupes = new Set(); - for (const s of specs) { - const tag = `${s.key ?? ""}\u0000${s.elementUid}`; - if (dupes.has(tag)) - throw new Error(`Duplicate fill target: ${s.key ?? s.elementUid}`); - dupes.add(tag); +): Promise { + const request = toRequest(specs); + const early = validateRequest(ref, request); + if (!early.allowed) throw deny(ref, specs, early.reason, early.targetIndex); + // A uid this session never handed out (or one a navigation or restart + // invalidated) is refused before the page is looked at, so the agent is + // told to take a new snapshot rather than about the page it is on. + const unknown = specs.findIndex((s) => !ctx.session.hasElement(s.elementUid)); + if (unknown !== -1) throw deny(ref, specs, "target_not_observed", unknown); + + const observation = await observeTargets(ctx.session, ref, request); + const decision = authorize(ref, request, observation); + if (!decision.allowed) { + throw deny(ref, specs, decision.reason, decision.targetIndex); } + return { + ref, + specs, + request, + decision, + pageUrl: observation.topLevelUrl, + }; +} - const page = await ctx.session.ensureStarted(); - const resolved: ResolvedTarget[] = []; - for (const spec of specs) { - const element = ctx.session.resolveElement(spec.elementUid); - const selectorInfo = await element.handle.evaluate((el) => { - const input = el as HTMLInputElement; - return `${el.tagName.toLowerCase()}${input.type ? `[type=${input.type}]` : ""}`; - }); - const target: FillTarget = { - pageUrl: page.url(), - elementUid: spec.elementUid, - selectorInfo, - }; +/** + * Throws unless `later` was made on the same destination as `earlier`: + * the same browser, tab, documents and elements. + */ +export function assertSameDestination( + earlier: ValidatedFill, + later: ValidatedFill, +): void { + if (!isSameDestination(earlier.decision, later.decision)) { + throw deny(later.ref, later.specs, "destination_changed"); + } +} - if (spec.key === undefined) { - // A field-bound secret is a JSON payload with a selector per part. - // Without its own sbm:selector, an unkeyed fill would write the whole - // payload into any field on the page, so it is refused. - if (ref.binding?.fields && !ref.binding.selector) { - throw new BindingViolationError( - ref, - target, - "binding declares sbm:fields; fill each part by key with `fields`", - ); - } - await ctx.binding.assertAllowed(ref, target, { - elementMatches: (selector) => - element.handle.evaluate((el, sel) => el.matches(sel), selector), - }); - } else { - // Keyed fill: origin/urlPattern checks as usual, then the key's own - // selector instead of the single-field one. - const fieldSelector = ref.binding?.fields?.[spec.key]; - if (!fieldSelector) { - throw new BindingViolationError( - ref, - target, - `binding declares no field "${spec.key}" (sbm:fields)`, - ); - } - const scoped: SecretRef = { - ...ref, - binding: { ...ref.binding!, selector: fieldSelector }, - }; - delete scoped.binding!.fields; - await ctx.binding.assertAllowed(scoped, target, { - elementMatches: (selector) => - element.handle.evaluate((el, sel) => el.matches(sel), selector), - }); - } - resolved.push({ spec, target, handle: element.handle }); +/** + * Authorize target `index` again on a fresh observation, immediately before + * its write, and require the destination that was checked. Only that target + * is observed: an earlier write may have made the page replace an element + * it is done with. Returns the target's redaction identity. + */ +async function recheckTarget( + ctx: ToolContext, + fill: ValidatedFill, + index: number, +): Promise { + const request = { targets: [fill.request.targets[index]!] }; + const observation = await observeTargets(ctx.session, fill.ref, request); + const current = authorize(fill.ref, request, observation); + if (!current.allowed) throw deny(fill.ref, fill.specs, current.reason, index); + const checked: AuthorizationAllowed = { + ...fill.decision, + targets: [fill.decision.targets[index]!], + }; + if (!isSameDestination(checked, current)) { + throw deny(fill.ref, fill.specs, "destination_changed", index); } - return resolved; + const target = current.targets[0]!; + return { + browserSessionId: current.browserSessionId, + tabId: current.tabId, + frameId: target.frameId, + documentId: target.documentId, + elementId: target.elementId, + }; } /** * Route exported plaintext into the validated targets. For keyed fills the - * value must be a JSON object holding every requested key; each part is - * tracked by the redaction registry before it touches the page. + * value must be a JSON object holding every requested key. The value (and, + * for a secret bound with sbm:fields, each payload part) is registered with + * the redaction registry before anything touches the page. Each target is + * authorized again immediately before its write, and registered as a + * redaction target before the write. + * + * Parts shorter than the registry's minimum length (a 3-digit CVC) are not + * text-scanned; the registered target still redacts the field itself. */ export async function injectResolved( ctx: ToolContext, exported: ExportedSecret, - resolved: ResolvedTarget[], + fill: ValidatedFill, ): Promise { try { + assertActive(ctx.signal); + ctx.registry.registerValues(redactionEntries(exported.ref, exported.value)); let payload: Record | undefined; - if (resolved.some((r) => r.spec.key !== undefined)) { + if (fill.specs.some((s) => s.key !== undefined)) { try { payload = JSON.parse(exported.value) as Record; } catch { @@ -157,7 +236,7 @@ export async function injectResolved( ); } } - for (const { spec, target } of resolved) { + for (const [index, spec] of fill.specs.entries()) { let value = exported.value; if (spec.key !== undefined) { const part = payload?.[spec.key]; @@ -166,11 +245,17 @@ export async function injectResolved( } value = part; } + const target = await recheckTarget(ctx, fill, index); + ctx.registry.registerTarget(target, exported.ref.secretId); await injectSecret( ctx.session, - target, - { ref: exported.ref, value, release: () => {} }, - ctx.registry, + spec.elementUid, + { + ref: exported.ref, + value, + release: () => {}, + }, + ctx.signal, ); } } finally { diff --git a/src/tools/fill-secret.ts b/src/tools/fill-secret.ts index 9fd2b12..8443337 100644 --- a/src/tools/fill-secret.ts +++ b/src/tools/fill-secret.ts @@ -10,7 +10,12 @@ import { validateTargets, type FillFieldSpec, } from "./fill-common.js"; -import { defineTool, type PendingFill, type ToolContext } from "./tool.js"; +import { + assertActive, + defineTool, + type PendingFill, + type ToolContext, +} from "./tool.js"; export function pendingResult(ctx: ToolContext, fill: PendingFill) { const activityId = fill.pending.activityId; @@ -39,6 +44,8 @@ function specsKey(secretId: string, specs: FillFieldSpec[]): string { export const fillSecret = defineTool({ name: "fill_secret", + scope: "sbm:fill", + browser: true, description: "Fill a secret into form fields, by reference. The secret value is " + "exported from Turnkey, injected via CDP, and never enters this " + @@ -62,9 +69,10 @@ export const fillSecret = defineTool({ // 3. Prompt-injection gate: the live page and every element must match // the binding baked into the secret's static properties at import. - const resolved = await validateTargets(ctx, ref, specs); + const validated = await validateTargets(ctx, ref, specs); - // 4. TODO: human confirmation (elicitation / MCP App) — later milestone. + // 4. Human confirmation (elicitation / MCP App) remains a later milestone. + // The SDK cancellation signal is checked before export and every write. // 5. Idempotency, BEFORE any export attempt: re-requesting a fill that // is already parked on approval returns the same handle. Checking @@ -83,6 +91,7 @@ export const fillSecret = defineTool({ // and await_fill completes the fill once approvers reach quorum. let exported; try { + assertActive(ctx.signal); exported = await ctx.secrets.exportSecret(ref); } catch (err) { if (err instanceof ConsensusNeededError && err.pending) { @@ -90,7 +99,7 @@ export const fillSecret = defineTool({ fillId: randomUUID(), pending: err.pending, targets: specs, - pageUrl: resolved[0]!.target.pageUrl, + pageUrl: validated.pageUrl, createdAt: Date.now(), }; await ctx.pendingFills.put(fill); @@ -99,9 +108,11 @@ export const fillSecret = defineTool({ throw err; } - // 7. Inject via CDP; each part registers with the redaction registry - // BEFORE the value touches the page, then the value is dropped. - await injectResolved(ctx, exported, resolved); + // 7. Inject via CDP. Each target is authorized again just before its + // write and must still be the destination checked in step 3; each + // part registers with the redaction registry BEFORE the value + // touches the page, then the value is dropped. + await injectResolved(ctx, exported, validated); // Outcome only — never the value. return { diff --git a/src/tools/list-network.ts b/src/tools/list-network.ts index 3687254..4c67c1d 100644 --- a/src/tools/list-network.ts +++ b/src/tools/list-network.ts @@ -2,6 +2,8 @@ import { defineTool, NotImplementedError } from "./tool.js"; export const listNetwork = defineTool({ name: "list_network_requests", + scope: "sbm:browse", + browser: true, description: "List network requests made by the current page (method, URL, status). " + "Request bodies are withheld for origins with live secrets.", diff --git a/src/tools/list-secret-refs.ts b/src/tools/list-secret-refs.ts index 321608a..7674fb1 100644 --- a/src/tools/list-secret-refs.ts +++ b/src/tools/list-secret-refs.ts @@ -25,6 +25,8 @@ export function selectRefs( export const listSecretRefs = defineTool({ name: "list_secret_refs", + scope: "sbm:refs", + browser: false, description: "Report the active backend (mock or turnkey) and list the secrets available to fill, as opaque references: id, name, and " + "destination binding (origin/url pattern/selector). Pass origin to see only " + diff --git a/src/tools/navigate.ts b/src/tools/navigate.ts index 4eb068b..f94223c 100644 --- a/src/tools/navigate.ts +++ b/src/tools/navigate.ts @@ -4,6 +4,8 @@ import { defineTool } from "./tool.js"; export const navigate = defineTool({ name: "navigate", + scope: "sbm:browse", + browser: true, description: "Navigate the broker-owned browser to a URL.", inputSchema: { url: z.url(), diff --git a/src/tools/snapshot.ts b/src/tools/snapshot.ts index 8dc9da8..653bd33 100644 --- a/src/tools/snapshot.ts +++ b/src/tools/snapshot.ts @@ -3,6 +3,8 @@ import { defineTool } from "./tool.js"; export const snapshot = defineTool({ name: "snapshot", + scope: "sbm:browse", + browser: true, description: "Snapshot of the current page's interactive elements with stable " + "element uids for click/type/fill targeting. Values of fields that " + diff --git a/src/tools/tool.ts b/src/tools/tool.ts index 0090e39..83fad87 100644 --- a/src/tools/tool.ts +++ b/src/tools/tool.ts @@ -1,38 +1,47 @@ import type { ZodObject, ZodRawShape, output } from "zod"; -import type { BindingPolicy } from "../broker/binding.js"; -import type { PendingFills } from "../broker/pending-store.js"; +import type { Scope } from "../auth/scopes.js"; import type { SecretsClient } from "../broker/secrets-client.js"; -import type { BrowserSession } from "../browser/session.js"; -import type { RedactionRegistry } from "../redaction/registry.js"; +import type { BrowserContext, BrowserScope } from "../browsers.js"; export type { PendingFill } from "../broker/pending-store.js"; -/** - * Shared wiring every tool handler receives. One context per agent session: - * `session`, `registry`, and `pendingFills` belong to that session alone; - * `secrets` and `binding` are shared by every session of the broker. - */ -export type ToolContext = { +/** The broker side of a tool call: shared by every browser. */ +export type BrokerContext = { secrets: SecretsClient; backend: "mock" | "turnkey"; - session: BrowserSession; - registry: RedactionRegistry; - binding: BindingPolicy; - /** This session's parked fills (see src/broker/pending-store.ts). */ - pendingFills: PendingFills; + /** The browsers this connection's caller can open, reach, and close. */ + browsers: BrowserScope; /** Dashboard link for an approval-gated activity; undefined when the * backend has no dashboard (mock) or the API host is unknown. */ approvalUrl?: (activityId: string) => string | undefined; }; +/** + * Shared wiring every tool handler receives. `session`, `registry`, and + * `pendingFills` belong to one browser: the one the call's `browser_id` + * names, or the connection's own (src/browsers.ts). `secrets` is shared by + * every browser of the broker. + */ +export type ToolContext = BrokerContext & + BrowserContext & { signal?: AbortSignal }; + +export function assertActive(signal?: AbortSignal): void { + if (signal?.aborted) throw new Error("Request cancelled"); +} + /** * Tool results are plain JSON values. `server.ts` owns serialization: every - * result passes through `scrub()` and is wrapped into MCP content there — + * result passes through `registry.scrub()` and is wrapped into MCP content there — * handlers cannot write to the transport directly. */ export type ToolDef = { name: string; + /** The OAuth scope a hosted caller needs for this tool. */ + scope: Scope; + /** Whether the tool acts on a browser. server.ts gives such a tool a + * `browser_id` argument and runs it in that browser. */ + browser: boolean; description: string; inputSchema: Shape; handler: ( @@ -44,6 +53,8 @@ export type ToolDef = { /** Type-erased view for the heterogeneous tool registry in server.ts. */ export type AnyToolDef = { name: string; + scope: Scope; + browser: boolean; description: string; inputSchema: ZodRawShape; handler: (ctx: ToolContext, args: never) => Promise; diff --git a/src/tools/type-text.ts b/src/tools/type-text.ts index 54958cc..a4d5edc 100644 --- a/src/tools/type-text.ts +++ b/src/tools/type-text.ts @@ -4,6 +4,8 @@ import { defineTool } from "./tool.js"; export const typeText = defineTool({ name: "type_text", + scope: "sbm:browse", + browser: true, description: "Type non-secret text into an element. For secrets, use fill_secret — " + "passing a secret value here would expose it to the conversation.", diff --git a/src/urlpattern.d.ts b/src/urlpattern.d.ts deleted file mode 100644 index a7b3703..0000000 --- a/src/urlpattern.d.ts +++ /dev/null @@ -1,6 +0,0 @@ -// Minimal ambient declaration: URLPattern exists at runtime in Bun and -// Node >= 24 but is not yet in the bundled TS libs. -declare class URLPattern { - constructor(init?: { pathname?: string; baseURL?: string } | string); - test(input: string): boolean; -} diff --git a/src/worker/lightpanda.ts b/src/worker/lightpanda.ts new file mode 100644 index 0000000..17975d3 --- /dev/null +++ b/src/worker/lightpanda.ts @@ -0,0 +1,185 @@ +/** Run only in a dedicated credential-free, network-isolated worker container. */ +import { spawn } from "node:child_process"; +import { chmod } from "node:fs/promises"; +import { createServer, type Socket } from "node:net"; +import { + readFrames, + writeFrame, + validateSocketDirectory, + MAX_CDP_BYTES, +} from "../browser/lightpanda-transport.js"; + +export function browserEnvironment(): NodeJS.ProcessEnv { + return { + PATH: "/usr/local/bin:/usr/bin:/bin", + HOME: "/tmp", + TMPDIR: "/tmp", + LIGHTPANDA_DISABLE_TELEMETRY: "true", + }; +} + +export async function runWorker( + socketPath: string, + executablePath: string, + lifetimeMs = 300000, +) { + if ( + !Number.isSafeInteger(lifetimeMs) || + lifetimeMs < 1000 || + lifetimeMs > 300000 + ) + throw new Error("Invalid worker lifetime"); + await validateSocketDirectory(socketPath); + let used = false; + let client: Socket | undefined; + let ws: WebSocket | undefined; + let child: ReturnType | undefined; + let stopped = false; + let finish!: () => void; + const done = new Promise((resolve) => { + finish = resolve; + }); + const server = createServer((socket) => { + socket.on("error", () => {}); + if (used || stopped) { + socket.destroy(); + return; + } + used = true; + client = socket; + server.close(); // One connection, including failed startup. No reconnect. + socket.once("close", () => void stop()); + void attach().catch(() => stop()); + }); + const timer = setTimeout(() => void stop(), lifetimeMs); + async function stop() { + if (stopped) return done; + stopped = true; + clearTimeout(timer); + server.close(); + client?.destroy(); + ws?.close(); + if (child && child.exitCode === null && child.signalCode === null) { + const exited = new Promise((resolve) => + child!.once("close", () => resolve()), + ); + child.kill("SIGTERM"); + const kill = setTimeout(() => child?.kill("SIGKILL"), 1000); + await exited; + clearTimeout(kill); + } + finish(); + } + async function attach() { + // Loopback CDP exists ONLY in this worker's separate network namespace. + const port = 9222; + child = spawn( + executablePath, + [ + "serve", + "--host", + "127.0.0.1", + "--port", + String(port), + "--cdp-max-connections", + "1", + "--load-resources", + "iframe", + "--disable-metrics", + ], + { + env: browserEnvironment(), + cwd: "/tmp", + stdio: "ignore", + }, + ); + child.once("error", () => void stop()); + child.once("exit", () => void stop()); + const deadline = Date.now() + 10000; + let endpoint: string | undefined; + while (!stopped && Date.now() < deadline) { + try { + const response = await fetch(`http://127.0.0.1:${port}/json/version`, { + signal: AbortSignal.timeout(500), + redirect: "error", + }); + const data = (await response.json()) as { + webSocketDebuggerUrl?: string; + }; + const url = new URL(data.webSocketDebuggerUrl ?? ""); + if ( + url.protocol !== "ws:" || + url.hostname !== "127.0.0.1" || + url.port !== String(port) || + url.username || + url.password || + url.search || + url.hash + ) + throw new Error(); + endpoint = url.href; + break; + } catch { + await new Promise((resolve) => setTimeout(resolve, 50)); + } + } + if (!endpoint || stopped) throw new Error("Browser unavailable"); + ws = new WebSocket(endpoint); + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error("CDP timeout")), 3000); + ws!.onopen = () => { + clearTimeout(timeout); + resolve(); + }; + ws!.onerror = () => { + clearTimeout(timeout); + reject(new Error("CDP failed")); + }; + }); + if (stopped) { + ws.close(); + return; + } + ws.onclose = () => void stop(); + ws.onerror = () => void stop(); + ws.onmessage = (event) => { + try { + if (typeof event.data !== "string") throw new Error(); + writeFrame(client!, event.data); + } catch { + void stop(); + } + }; + readFrames(client!, (message) => { + if ( + ws!.readyState !== WebSocket.OPEN || + ws!.bufferedAmount > MAX_CDP_BYTES + ) { + void stop(); + return; + } + ws!.send(message); + }); + } + try { + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(socketPath, resolve); + }); + await chmod(socketPath, 0o600); + } catch (error) { + await stop(); + throw error; + } + return { stop, done }; +} + +if (import.meta.main) { + const worker = await runWorker( + process.argv[2] ?? "/ipc/browser.sock", + "/usr/local/bin/lightpanda", + ); + process.once("SIGTERM", () => void worker.stop()); + process.once("SIGINT", () => void worker.stop()); + await worker.done; +} diff --git a/test/binding.test.ts b/test/binding.test.ts index 048cca7..3720035 100644 --- a/test/binding.test.ts +++ b/test/binding.test.ts @@ -1,30 +1,40 @@ import { describe, expect, test } from "bun:test"; -import { BindingPolicy } from "../src/broker/binding.js"; -import { parseBinding } from "../src/broker/mock-secrets.js"; -import { BINDING_KEYS, type SecretRef } from "../src/broker/types.js"; +import { createSecretRef, DENY_REASONS } from "@turnkey/browser-secrets"; + +import { MockSecretsClient } from "../src/broker/mock-secrets.js"; +import { BINDING_KEYS } from "../src/broker/types.js"; +import { FillDeniedError } from "../src/tools/fill-common.js"; const origin = "https://shop.example"; const fields = JSON.stringify({ number: "input[name=cardNumber]" }); -describe("parseBinding", () => { +/** + * Bindings are parsed by @turnkey/browser-secrets. These cases pin the + * secrets the broker accepted and refused before the move, so a package + * change that loosens or breaks an existing secret fails here. + */ +describe("binding parsing", () => { + const ref = (staticProperties: Record) => + createSecretRef({ secretId: "s1", staticProperties }); + test("no sbm:origin means no binding", () => { - expect(parseBinding({})).toEqual({}); + const parsed = ref({ [BINDING_KEYS.selector]: "input" }); + expect(parsed.binding).toBeUndefined(); + expect(parsed.bindingError).toBeUndefined(); }); test("parses a full binding", () => { expect( - parseBinding({ + ref({ [BINDING_KEYS.origin]: origin, [BINDING_KEYS.urlPattern]: "/checkout*", [BINDING_KEYS.fields]: fields, - }), + }).binding, ).toEqual({ - binding: { - origin, - urlPattern: "/checkout*", - fields: { number: "input[name=cardNumber]" }, - }, + origin, + urlPattern: "/checkout*", + fields: { number: "input[name=cardNumber]" }, }); }); @@ -53,28 +63,35 @@ describe("parseBinding", () => { { [BINDING_KEYS.origin]: origin, [BINDING_KEYS.selector]: " " }, ], ])("rejects a binding whose %s", (_, props) => { - const parsed = parseBinding(props); + const parsed = ref(props); expect(parsed.binding).toBeUndefined(); expect(parsed.bindingError).toBeString(); }); + + test("the mock seed secrets all bind", async () => { + for (const r of await new MockSecretsClient().listRefs()) { + expect(r.bindingError).toBeUndefined(); + expect(r.binding?.origin).toBeString(); + } + }); }); -describe("BindingPolicy", () => { - test("refuses a secret with a malformed binding", async () => { - const ref: SecretRef = { - secretId: "s1", - staticProperties: {}, - ...parseBinding({ - [BINDING_KEYS.origin]: origin, - [BINDING_KEYS.fields]: "not json", - }), - }; - await expect( - new BindingPolicy().assertAllowed( - ref, - { pageUrl: `${origin}/checkout`, elementUid: "e1" }, - { elementMatches: async () => true }, - ), - ).rejects.toThrow("destination binding is invalid"); +describe("FillDeniedError", () => { + const card = { secretId: "s1", name: "demo-card" }; + + test.each([...DENY_REASONS, "destination_changed" as const])( + "%s names the reason and holds no URL", + (reason) => { + const message = new FillDeniedError(reason, card, "e12").message; + expect(message).toStartWith('Refusing to fill "demo-card": '); + expect(message).toEndWith(`(${reason}, element e12)`); + expect(message).not.toMatch(/https?:/); + }, + ); + + test("falls back to the secret ID without a name", () => { + expect( + new FillDeniedError("unbound_secret", { secretId: "s1" }).message, + ).toStartWith('Refusing to fill "s1": '); }); }); diff --git a/test/browsers.test.ts b/test/browsers.test.ts new file mode 100644 index 0000000..c8b75d5 --- /dev/null +++ b/test/browsers.test.ts @@ -0,0 +1,156 @@ +import { expect, test } from "bun:test"; + +import { RedactionRegistry } from "@turnkey/browser-secrets"; + +import { + MemoryPendingFillStore, + scopedFills, +} from "../src/broker/pending-store.js"; +import type { BrowserHost } from "../src/browser/hosts.js"; +import { BrowserSession } from "../src/browser/session.js"; +import { + BrowserHandles, + browserScope, + UnknownBrowserError, + type BrowserContext, + type HandleOwner, +} from "../src/browsers.js"; + +/** Browser handles without a browser: a host that counts stops. */ +function harness(limits = { max: 10, perEndpoint: 10, perPrincipal: 10 }) { + const stopped: string[] = []; + const handles = new BrowserHandles( + { ...limits, perPrincipal: () => limits.perPrincipal }, + 1_000, + ); + const make = (id: string): BrowserContext => { + const host: BrowserHost = { + kind: "local", + start: () => Promise.reject(new Error("no browser in unit tests")), + stop: async () => { + stopped.push(id); + }, + }; + return { + session: new BrowserSession(host), + registry: new RedactionRegistry(), + pendingFills: scopedFills(new MemoryPendingFillStore(), { + owner: id, + principal: "p", + }), + }; + }; + return { handles, make, stopped }; +} + +const alice: HandleOwner = { endpoint: "t1", principal: "alice", client: "c" }; + +test("browser ids are random, not sequential", () => { + const { handles, make } = harness({ + max: 100, + perEndpoint: 100, + perPrincipal: 100, + }); + const ids = Array.from({ length: 50 }, () => handles.open(alice, make)); + expect(new Set(ids).size).toBe(50); + for (const id of ids) expect(id).toMatch(/^br_[A-Za-z0-9_-]{32}$/); +}); + +test("another owner gets the same error as an id that was never minted", async () => { + const { handles, make } = harness(); + const id = handles.open(alice, make); + const others: HandleOwner[] = [ + { ...alice, principal: "bob" }, + { ...alice, endpoint: "t2" }, + { ...alice, client: "other" }, + { ...alice, client: undefined }, + ]; + const message = async (owner: HandleOwner, browserId: string) => + handles + .use(browserId, owner, async () => "ran") + .catch((err: Error) => err.message); + for (const owner of others) { + expect(await message(owner, id)).toBe(new UnknownBrowserError(id).message); + expect(await handles.close(id, owner)).toBe(false); + } + expect(await message(alice, id)).toBe("ran"); +}); + +test("each cap refuses one more handle", () => { + const { handles, make } = harness({ + max: 3, + perEndpoint: 2, + perPrincipal: 1, + }); + handles.open(alice, make); + expect(() => handles.open(alice, make)).toThrow("close_browser"); + handles.open({ ...alice, principal: "bob" }, make); + expect(() => handles.open({ ...alice, principal: "carol" }, make)).toThrow( + "tenant is at its browser limit", + ); + handles.open({ ...alice, endpoint: "t2" }, make); + expect(() => handles.open({ ...alice, endpoint: "t3" }, make)).toThrow( + "Broker is at its browser limit", + ); +}); + +test("the sweep closes idle handles but not one in use", async () => { + const { handles, make, stopped } = harness(); + const idle = handles.open(alice, make); + const busy = handles.open(alice, make); + let release!: () => void; + const running = handles.use( + busy, + alice, + () => new Promise((r) => (release = r)), + ); + + await handles.sweep(Date.now() + 5_000); + expect(handles.has(idle)).toBe(false); + expect(stopped).toContain(idle); + expect(handles.has(busy)).toBe(true); + + release(); + await running; + // Its idle time starts when the call ends. + await handles.sweep(Date.now() + 500); + expect(handles.has(busy)).toBe(true); + await handles.sweep(Date.now() + 5_000); + expect(handles.has(busy)).toBe(false); + expect(handles.size).toBe(0); +}); + +test("a closed handle frees its slot and closes its browser", async () => { + const { handles, make, stopped } = harness({ + max: 1, + perEndpoint: 1, + perPrincipal: 1, + }); + const id = handles.open(alice, make); + expect(await handles.close(id, alice)).toBe(true); + expect(stopped).toEqual([id]); + expect(await handles.close(id, alice)).toBe(false); + expect(handles.open(alice, make)).not.toBe(id); +}); + +test("a scope without its own browser requires a browser_id", async () => { + const { handles, make } = harness(); + const scope = browserScope({ handles, owner: alice, make }); + await expect(scope.run(undefined, true, async () => "ran")).rejects.toThrow( + "open_browser", + ); + // A tool that touches no browser runs in an empty context. + expect( + await scope.run(undefined, false, async (b) => b.pendingFills.list()), + ).toEqual([]); + + const id = scope.open(); + let seen: BrowserContext | undefined; + await scope.run(id, true, async (b) => void (seen = b)); + expect(seen).toBeDefined(); + + const implicit = make("implicit"); + const session = browserScope({ handles, owner: alice, make, implicit }); + await session.run(undefined, true, async (b) => expect(b).toBe(implicit)); + await session.run(id, true, async (b) => expect(b).toBe(seen!)); +}); diff --git a/test/e2e.test.ts b/test/e2e.test.ts index bb62417..36a057d 100644 --- a/test/e2e.test.ts +++ b/test/e2e.test.ts @@ -29,6 +29,8 @@ test("lists the full tool surface, without evaluate_script", async () => { const msg = await client.request("tools/list"); const names = (msg.result?.tools ?? []).map((t) => t.name); expect(names).toEqual([ + "open_browser", + "close_browser", "list_secret_refs", "navigate", "snapshot", @@ -99,7 +101,7 @@ test("refuses to fill into a non-matching destination", async () => { element_uid: password!.uid, }); expect(fill.isError).toBe(true); - expect(fill.text).toContain("does not match bound origin"); + expect(fill.text).toContain("top_level_origin_mismatch"); }); test("fills a JSON card secret into several fields with one call", async () => { @@ -123,7 +125,7 @@ test("fills a JSON card secret into several fields with one call", async () => { element_uid: byName("cardName"), }); expect(unkeyed.isError).toBe(true); - expect(unkeyed.text).toContain("fill each part by key"); + expect(unkeyed.text).toContain("unkeyed_target_for_field_binding"); // A payload key the binding doesn't declare is refused before any export, // and a declared key aimed at the wrong element fails its selector check. @@ -238,6 +240,54 @@ test("redacts a card number the page splits across fields and the URL", async () expect(client.transcript).not.toMatch(/4242(%20|\D)?4242(%20|\D)?4242/); }); +test("a browser_id names a browser of its own, beside the session's", async () => { + const refs = (await client.callTool("list_secret_refs")).body as { + refs: { secretId: string; name?: string }[]; + }; + const demo = refs.refs.find((r) => r.name === "demo-login-password")!; + await client.callTool("navigate", { url: `${FIXTURE_ORIGIN}/checkout` }); + + const opened = await client.callTool("open_browser"); + expect(opened.isError).toBe(false); + const { browser_id } = opened.body as { browser_id: string }; + expect(browser_id).toMatch(/^br_[A-Za-z0-9_-]{32}$/); + + await client.callTool("navigate", { + url: `${FIXTURE_ORIGIN}/login`, + browser_id, + }); + const snap = (await client.callTool("snapshot", { browser_id })).body as { + url: string; + elements: { uid: string; type?: string; value?: string }[]; + }; + expect(snap.url).toContain("/login"); + const password = snap.elements.find((e) => e.type === "password")!; + const fill = await client.callTool("fill_secret", { + secret_id: demo.secretId, + element_uid: password.uid, + browser_id, + }); + expect(fill.body).toMatchObject({ filled: true }); + + // The session's own browser did not move. + const own = (await client.callTool("snapshot")).body as { url: string }; + expect(own.url).toContain("/checkout"); + + const after = (await client.callTool("snapshot", { browser_id })).body as { + elements: { uid: string; value?: string }[]; + }; + expect(after.elements.find((e) => e.uid === password.uid)?.value).toBe( + "[REDACTED:secret-filled-field]", + ); + + expect((await client.callTool("close_browser", { browser_id })).body).toEqual( + { closed: browser_id }, + ); + const gone = await client.callTool("snapshot", { browser_id }); + expect(gone.isError).toBe(true); + expect(gone.text).toContain("Unknown browser_id"); +}); + test("the plaintext never appeared anywhere in server output", () => { expect(client.transcript.length).toBeGreaterThan(0); expect(client.transcript).not.toContain(DEMO_PLAINTEXT); diff --git a/test/get-stream.test.ts b/test/get-stream.test.ts new file mode 100644 index 0000000..08ba6c2 --- /dev/null +++ b/test/get-stream.test.ts @@ -0,0 +1,39 @@ +import { expect, test } from "bun:test"; +import { EventEmitter } from "node:events"; + +import { watchGetStream } from "../src/get-stream.js"; + +/** src/get-stream.ts: when a GET stream is ended or released. */ + +/** A GET whose response the transport does not end, so only the 1 s + * fallback after expiry closes it. */ +function openGet(expiresAt: number | undefined) { + const req = new EventEmitter(); + const res = { + headersSent: true, + statusCode: 200, + writableFinished: false, + destroy: () => req.emit("close"), + }; + let closes = 0; + const transport = { closeStandaloneSSEStream: () => void closes++ }; + watchGetStream(transport, req, res, expiresAt); + return { req, closes: () => closes }; +} + +test("a dropped GET stream is released", () => { + const get = openGet(undefined); + get.req.emit("close"); + expect(get.closes()).toBe(1); +}); + +test("the fallback after an expiry does not release a newer stream", async () => { + const get = openGet(Date.now()); + await Bun.sleep(50); + // The expiry closed the session's stream; a new one may open now. + expect(get.closes()).toBe(1); + // The fallback destroys the old response, which fires its request's + // `close` with the response unfinished. + await Bun.sleep(1_100); + expect(get.closes()).toBe(1); +}); diff --git a/test/http.test.ts b/test/http.test.ts index b45afba..5931b3b 100644 --- a/test/http.test.ts +++ b/test/http.test.ts @@ -13,9 +13,11 @@ import { FIXTURE_ORIGIN, startFixtureServer } from "./fixtures/serve.js"; import { McpStdioClient } from "./mcp-client.js"; /** - * The hosted broker (src/http.ts), black box: EMG-89 step 1 acceptance. - * Same tools as stdio, sessions isolated from each other, and a pending - * fill that survives a broker restart. + * The hosted broker (src/http.ts), black box, in its development + * shared-token mode (SBM_DEV_SHARED_TOKEN): EMG-89 step 1 acceptance. Same + * tools as stdio, sessions isolated from each other, and a pending fill + * that survives a broker restart. Both protocol eras, with browser handles + * (EMG-288). Tenant OAuth is in oauth.test.ts. */ const TOKEN = "test-token"; @@ -23,6 +25,8 @@ const DEMO_PLAINTEXT = "mock-demo-p@ssw0rd-1234"; // mock-secrets.ts seed const CARD_NUMBER = "4242424242424242"; // mock-secrets.ts demo-card seed const stateDir = mkdtempSync(join(tmpdir(), "sbm-state-")); const stateKey = randomBytes(32).toString("hex"); +/** Where the dev endpoint's parked fills live under SBM_STATE_DIR. */ +const fillDir = join(stateDir, "tenants", "_shared-token"); const port = 18_000 + Math.floor(Math.random() * 2_000); const url = `http://127.0.0.1:${port}/mcp`; @@ -33,7 +37,9 @@ let broker: BrokerProcess; type BrokerProcess = { stop: () => Promise }; -async function startBroker(): Promise { +async function startBroker( + env: Record = {}, +): Promise { const proc = Bun.spawn(["bun", "src/http.ts"], { cwd: new URL("..", import.meta.url).pathname, stdout: "ignore", @@ -45,12 +51,14 @@ async function startBroker(): Promise { TURNKEY_ORGANIZATION_ID: "", SBM_HEADLESS: "true", SBM_HTTP_TOKEN: TOKEN, + SBM_DEV_SHARED_TOKEN: "1", SBM_HTTP_PORT: String(port), SBM_STATE_DIR: stateDir, SBM_STATE_KEY: stateKey, SBM_MOCK_CONSENSUS: "demo-login-password,demo-card", SBM_MOCK_CONSENSUS_DELAY_MS: "2000", SBM_ALLOWED_ORIGINS: "https://allowed.example, http://localhost:3000", + ...env, }, }); let log = ""; @@ -75,10 +83,18 @@ type Session = { ) => Promise<{ isError: boolean; body: any; text: string }>; }; -async function connect(): Promise { - const client = new Client({ name: "http-test", version: "0" }); +/** A 2025-era session, or with `modern` a client pinned to 2026-07-28. */ +async function connect( + opts: { modern?: boolean; url?: string } = {}, +): Promise { + const client = new Client( + { name: "http-test", version: "0" }, + opts.modern + ? { versionNegotiation: { mode: { pin: "2026-07-28" } } } + : undefined, + ); await client.connect( - new StreamableHTTPClientTransport(new URL(url), { + new StreamableHTTPClientTransport(new URL(opts.url ?? url), { requestInit: { headers: { authorization: `Bearer ${TOKEN}` } }, fetch: async (input, init) => { const res = await fetch(input, init); @@ -106,9 +122,9 @@ async function connect(): Promise { return { client, call }; } -async function openLogin(s: Session) { - await s.call("navigate", { url: `${FIXTURE_ORIGIN}/login` }); - const snap = (await s.call("snapshot")).body; +async function openLogin(s: Session, browser: Record = {}) { + await s.call("navigate", { url: `${FIXTURE_ORIGIN}/login`, ...browser }); + const snap = (await s.call("snapshot", browser)).body; return snap.elements.find((e: { type?: string }) => e.type === "password") .uid as string; } @@ -130,6 +146,30 @@ afterAll(async () => { rmSync(stateDir, { recursive: true, force: true }); }); +test("the shared token needs the dev flag and a header-safe value", async () => { + for (const [env, message] of [ + [ + { SBM_HTTP_TOKEN: TOKEN, SBM_DEV_SHARED_TOKEN: "" }, + "SBM_DEV_SHARED_TOKEN", + ], + [ + { SBM_HTTP_TOKEN: "p@ss:word!", SBM_DEV_SHARED_TOKEN: "1" }, + "SBM_HTTP_TOKEN may contain only", + ], + ] as const) { + const proc = Bun.spawn(["bun", "src/http.ts"], { + cwd: new URL("..", import.meta.url).pathname, + stdout: "ignore", + stderr: "pipe", + env: { ...process.env, ...env }, + }); + const guard = setTimeout(() => proc.kill(), 10_000); + expect(await proc.exited).not.toBe(0); + clearTimeout(guard); + expect(await new Response(proc.stderr).text()).toContain(message); + } +}); + test("requests without the bearer token are refused", async () => { const res = await fetch(url, { method: "POST", body: "{}" }); expect(res.status).toBe(401); @@ -228,6 +268,7 @@ function modernDiscover(): RequestInit { "content-type": "application/json", accept: "application/json, text/event-stream", "mcp-protocol-version": "2026-07-28", + "mcp-method": "server/discover", }, body: JSON.stringify({ jsonrpc: "2.0", @@ -244,39 +285,153 @@ function modernDiscover(): RequestInit { }; } -test("a 2026-07-28 request gets UnsupportedProtocolVersion", async () => { +test("a raw 2026-07-28 request is served without a session", async () => { const res = await fetch(url, modernDiscover()); - expect(res.status).toBe(400); + expect(res.status).toBe(200); expect(res.headers.get("mcp-session-id")).toBeNull(); const body = (await res.json()) as any; expect(body.id).toBe(7); - expect(body.error.code).toBe(-32022); - expect(body.error.data.requested).toBe("2026-07-28"); - expect(body.error.data.supported).toContain("2025-11-25"); - expect(body.error.data.supported).not.toContain("2026-07-28"); + expect(body.result.supportedVersions).toContain("2026-07-28"); }); -test("a client pinned to 2026-07-28 fails; auto mode falls back", async () => { - const transport = () => - new StreamableHTTPClientTransport(new URL(url), { - requestInit: { headers: { authorization: `Bearer ${TOKEN}` } }, - }); - const pinned = new Client( - { name: "pinned", version: "0" }, - { versionNegotiation: { mode: { pin: "2026-07-28" } } }, - ); - await expect(pinned.connect(transport())).rejects.toThrow(); - +test("a client in auto mode now picks 2026-07-28", async () => { const auto = new Client( { name: "auto", version: "0" }, { versionNegotiation: { mode: "auto" } }, ); - await auto.connect(transport()); - expect(auto.getProtocolEra()).toBe("legacy"); - expect((await auto.listTools()).tools.length).toBeGreaterThan(0); + await auto.connect( + new StreamableHTTPClientTransport(new URL(url), { + requestInit: { headers: { authorization: `Bearer ${TOKEN}` } }, + }), + ); + expect(auto.getProtocolEra()).toBe("modern"); await auto.close(); }); +test("a 2026-07-28 client lists refs, navigates, fills, and resumes a parked fill by browser_id", async () => { + const s = await connect({ modern: true }); + expect(s.client.getProtocolEra()).toBe("modern"); + const tools = (await s.client.listTools()).tools; + const navigateTool = tools.find((t) => t.name === "navigate")!; + expect(Object.keys(navigateTool.inputSchema.properties ?? {})).toContain( + "browser_id", + ); + + // There is no session, so a browser tool must name a browser. + const bare = await s.call("snapshot"); + expect(bare.isError).toBe(true); + expect(bare.text).toContain("open_browser"); + + const secretId = await demoSecretId(s); + const { browser_id } = (await s.call("open_browser")).body; + expect(browser_id).toMatch(/^br_[A-Za-z0-9_-]{32}$/); + const uid = await openLogin(s, { browser_id }); + + const parked = ( + await s.call("fill_secret", { + secret_id: secretId, + element_uid: uid, + browser_id, + }) + ).body; + expect(parked.status).toBe("pending_approval"); + // The fill is parked on the browser, not on the request: another + // browser of the same caller cannot take it while this one is open. + const other = (await s.call("open_browser")).body.browser_id; + const stolen = await s.call("await_fill", { + fill_id: parked.fill_id, + timeout_seconds: 1, + browser_id: other, + }); + expect(stolen.text).toContain("Unknown fill_id"); + await s.call("close_browser", { browser_id: other }); + + const done = await s.call("await_fill", { + fill_id: parked.fill_id, + timeout_seconds: 10, + browser_id, + }); + expect(done.body).toMatchObject({ filled: true, secret_id: secretId }); + const snap = (await s.call("snapshot", { browser_id })).body; + const filled = snap.elements.find((e: { uid: string }) => e.uid === uid); + expect(filled?.value).toBe("[REDACTED:secret-filled-field]"); + + expect((await s.call("close_browser", { browser_id })).body).toEqual({ + closed: browser_id, + }); + const gone = await s.call("snapshot", { browser_id }); + expect(gone.isError).toBe(true); + expect(gone.text).toContain("Unknown browser_id"); + await s.client.close(); +}, 60_000); + +test("a 2025 client with a new session per call keeps one browser_id (the Arcade pattern)", async () => { + // Every call opens a session and never closes it. More calls than the + // endpoint has session slots: idle, browserless sessions make room. + const call = async (name: string, args: Record = {}) => + (await connect()).call(name, args); + + const { browser_id } = (await call("open_browser")).body; + const secretId = (await call("list_secret_refs")).body.refs.find( + (r: { name?: string }) => r.name === "demo-login-password", + ).secretId; + await call("navigate", { url: `${FIXTURE_ORIGIN}/login`, browser_id }); + const snap = (await call("snapshot", { browser_id })).body; + expect(snap.url).toContain("/login"); + const uid = snap.elements.find( + (e: { type?: string }) => e.type === "password", + ).uid; + // A session's own browser is still its own. + expect((await call("snapshot")).body.url).not.toContain("/login"); + + const parked = ( + await call("fill_secret", { + secret_id: secretId, + element_uid: uid, + browser_id, + }) + ).body; + expect(parked.status).toBe("pending_approval"); + for (let i = 0; i < 6; i++) { + expect((await call("snapshot", { browser_id })).body.url).toContain( + "/login", + ); + } + const done = await call("await_fill", { + fill_id: parked.fill_id, + timeout_seconds: 10, + browser_id, + }); + expect(done.body).toMatchObject({ filled: true, secret_id: secretId }); + const after = (await call("snapshot", { browser_id })).body; + const filled = after.elements.find((e: { uid: string }) => e.uid === uid); + expect(filled?.value).toBe("[REDACTED:secret-filled-field]"); + expect((await call("close_browser", { browser_id })).isError).toBe(false); +}, 90_000); + +test("an idle browser handle is closed after SBM_SESSION_IDLE_S", async () => { + const idlePort = port + 1; + const idleUrl = `http://127.0.0.1:${idlePort}/mcp`; + const idle = await startBroker({ + SBM_HTTP_PORT: String(idlePort), + SBM_SESSION_IDLE_S: "1", + SBM_STATE_DIR: "", + }); + try { + const s = await connect({ modern: true, url: idleUrl }); + const { browser_id } = (await s.call("open_browser")).body; + expect((await s.call("open_browser")).body.idle_timeout_seconds).toBe(1); + await s.call("navigate", { url: `${FIXTURE_ORIGIN}/login`, browser_id }); + await new Promise((r) => setTimeout(r, 3_000)); + const gone = await s.call("snapshot", { browser_id }); + expect(gone.isError).toBe(true); + expect(gone.text).toContain("Unknown browser_id"); + await s.client.close(); + } finally { + await idle.stop(); + } +}, 30_000); + test("DELETE ends the session", async () => { const transport = new StreamableHTTPClientTransport(new URL(url), { requestInit: { headers: { authorization: `Bearer ${TOKEN}` } }, @@ -367,9 +522,9 @@ test("a pending fill survives a broker restart", async () => { expect(parked.status).toBe("pending_approval"); // The fill is on disk, encrypted. - const files = readdirSync(stateDir).filter((f) => f.endsWith(".fill")); + const files = readdirSync(fillDir).filter((f) => f.endsWith(".fill")); expect(files.length).toBe(1); - const onDisk = readFileSync(join(stateDir, files[0]!), "utf8"); + const onDisk = readFileSync(join(fillDir, files[0]!), "utf8"); expect(onDisk).not.toContain(parked.fill_id); expect(onDisk).not.toContain("demo-login-password"); @@ -391,7 +546,7 @@ test("a pending fill survives a broker restart", async () => { timeout_seconds: 10, }); expect(done.body).toMatchObject({ filled: true, secret_id: secretId }); - expect(readdirSync(stateDir).filter((f) => f.endsWith(".fill"))).toEqual([]); + expect(readdirSync(fillDir).filter((f) => f.endsWith(".fill"))).toEqual([]); const snap = (await after.call("snapshot")).body; const filled = snap.elements.find((e: { uid: string }) => e.uid === freshUid); @@ -431,7 +586,7 @@ test("await_fill refuses to re-target a card fill at an unkeyed element", async timeout_seconds: 5, }); expect(unkeyed.isError).toBe(true); - expect(unkeyed.text).toContain("fill each part by key"); + expect(unkeyed.text).toContain("unkeyed_target_for_field_binding"); // A refused re-target leaves the parked targets in place. const done = await s.call("await_fill", { diff --git a/test/inflight.test.ts b/test/inflight.test.ts new file mode 100644 index 0000000..2328194 --- /dev/null +++ b/test/inflight.test.ts @@ -0,0 +1,98 @@ +import { expect, test } from "bun:test"; + +import { InFlightIds } from "../src/inflight.js"; + +/** src/inflight.ts: when a session's request ids are free again. */ + +const call = (id: string | number) => ({ + jsonrpc: "2.0" as const, + id, + method: "tools/call", + params: { name: "snapshot" }, +}); +const answer = (id: string | number) => ({ + jsonrpc: "2.0" as const, + id, + result: {}, +}); +const cancel = (requestId: string | number) => ({ + jsonrpc: "2.0" as const, + method: "notifications/cancelled", + params: { requestId }, +}); +const free = (ids: InFlightIds, id: string | number) => { + const r = ids.reserve(call(id)); + if (r.ok) r.done(); + return r.ok; +}; + +test("refuses an id in flight, and one id twice in a batch", () => { + const ids = new InFlightIds(); + // Only requests count: not notifications, responses, or a null id. + const r = ids.reserve([ + call(1), + { jsonrpc: "2.0", method: "notifications/initialized" }, + answer(2), + { jsonrpc: "2.0", id: null, method: "ping" }, + ]); + expect(r.ok).toBe(true); + expect(ids.reserve(call(1))).toEqual({ ok: false, id: 1 }); + expect(free(ids, 2)).toBe(true); + // A batch with one clashing id is refused whole. + expect(ids.reserve([call(3), call(1)])).toEqual({ ok: false, id: 1 }); + expect(free(ids, 3)).toBe(true); + expect(ids.reserve([call(4), call(4)])).toEqual({ ok: false, id: 4 }); + // 1 and "1" are different ids, as they are to the transport. + expect(free(ids, "1")).toBe(true); +}); + +test("an id is free once the server has answered or cancelled it, or never saw it", () => { + // Answered or cancelled after dispatch: free at once, whether or not the + // HTTP response has ended. + for (const settle of [ + (ids: InFlightIds) => ids.outbound(answer(1)), + (ids: InFlightIds) => ids.inbound(cancel(1)), + ]) { + const ids = new InFlightIds(); + const r = ids.reserve(call(1)); + if (!r.ok) throw new Error("reserve failed"); + ids.inbound(call(1)); + expect(free(ids, 1)).toBe(false); + settle(ids); + expect(free(ids, 1)).toBe(true); + } + + // A cancel that arrives before its request does nothing to the server, + // which still answers. + const early = new InFlightIds(); + if (!early.reserve(call(1)).ok) throw new Error("reserve failed"); + early.inbound(cancel(1)); + early.inbound(call(1)); + expect(free(early, 1)).toBe(false); + early.outbound(answer(1)); + expect(free(early, 1)).toBe(true); + + // The transport refused the POST: the server never saw the id. + const refused = new InFlightIds(); + const r = refused.reserve(call(1)); + if (!r.ok) throw new Error("reserve failed"); + expect(free(refused, 1)).toBe(false); + r.done(); + r.done(); + expect(free(refused, 1)).toBe(true); +}); + +test("after a disconnect, the id stays taken until the server answers", () => { + const ids = new InFlightIds(); + const r = ids.reserve(call(1)); + if (!r.ok) throw new Error("reserve failed"); + ids.inbound(call(1)); + r.done(); + // A late answer must not go to a new request with this id. + expect(free(ids, 1)).toBe(false); + // A request the server sends to the client is not an answer. + ids.outbound({ jsonrpc: "2.0", id: 1, method: "elicitation/create" }); + expect(free(ids, 1)).toBe(false); + ids.outbound(answer(1)); + expect(free(ids, 1)).toBe(true); +}); diff --git a/test/lightpanda-integration.test.ts b/test/lightpanda-integration.test.ts new file mode 100644 index 0000000..a8a3c07 --- /dev/null +++ b/test/lightpanda-integration.test.ts @@ -0,0 +1,156 @@ +import { expect, test } from "bun:test"; +import { RedactionRegistry } from "@turnkey/browser-secrets"; +import { LightpandaHost } from "../src/browser/lightpanda.js"; +import { BrowserSession } from "../src/browser/session.js"; +import { captureSnapshot } from "../src/browser/snapshot.js"; +import { observeTargets, topLevelDocument } from "../src/browser/observe.js"; +import { MockSecretsClient } from "../src/broker/mock-secrets.js"; +import { + MemoryPendingFillStore, + scopedFills, +} from "../src/broker/pending-store.js"; +import { BrowserHandles, browserScope } from "../src/browsers.js"; +import { fillSecret } from "../src/tools/fill-secret.js"; +import { awaitFill } from "../src/tools/await-fill.js"; +import type { ToolContext } from "../src/tools/tool.js"; + +const socket = process.env["SBM_TEST_WORKER_SOCKET"]; +test.skipIf(!socket)( + "isolated Lightpanda: live observations, approval, fill, redaction and denied destinations", + async () => { + const session = new BrowserSession(new LightpandaHost(socket!)); + const registry = new RedactionRegistry(); + const secrets = new MockSecretsClient( + [ + { + name: "fixture", + value: "synthetic-worker-test-password", + staticProperties: { + "sbm:origin": "http://127.0.0.1:4173", + "sbm:url-pattern": "/login*", + "sbm:selector": 'input[type="password"]', + }, + }, + ], + { consensusNames: ["fixture"], approvalDelayMs: 100 }, + ); + const pendingFills = scopedFills(new MemoryPendingFillStore(), { + owner: "test", + principal: "test", + }); + const browser = { session, registry, pendingFills }; + const ctx: ToolContext = { + ...browser, + secrets, + backend: "mock", + browsers: browserScope({ + handles: new BrowserHandles( + { max: 1, perEndpoint: 1, perPrincipal: () => 1 }, + 300000, + ), + owner: { endpoint: "test", principal: "test", client: "test" }, + make: () => browser, + implicit: browser, + }), + }; + try { + const page = await session.ensureStarted(); + await page.goto("http://127.0.0.1:4173/login"); + let snapshot = await captureSnapshot(session, registry); + const uid = snapshot.elements.find((e) => e.name === "password")!.uid; + const [ref] = await secrets.listRefs(); + const before = await topLevelDocument(session); + await page.evaluate(() => { + Element.prototype.matches = () => false; + }); + const observation = await observeTargets(session, ref!, { + targets: [{ elementId: uid }], + }); + expect( + observation.targets[0]?.selectorMatches['input[type="password"]'], + ).toBe(true); + const pending = (await fillSecret.handler(ctx, { + secret_id: ref!.secretId, + element_uid: uid, + })) as { status: string; fill_id: string }; + expect(pending.status).toBe("pending_approval"); + const result = await awaitFill.handler(ctx, { + fill_id: pending.fill_id, + timeout_seconds: 1, + }); + expect(result).toMatchObject({ filled: true }); + snapshot = await captureSnapshot(session, registry); + expect(JSON.stringify(snapshot)).not.toContain( + "synthetic-worker-test-password", + ); + expect( + await page.$eval( + 'input[type="password"]', + (el) => (el as HTMLInputElement).value.length, + ), + ).toBe(30); + const other = snapshot.elements.find((e) => e.name === "other")!.uid; + await expect( + fillSecret.handler(ctx, { + secret_id: ref!.secretId, + element_uid: other, + }), + ).rejects.toThrow(); + // Cancellation after an approved export must release it without a write. + const pendingAgain = (await fillSecret.handler(ctx, { + secret_id: ref!.secretId, + element_uid: uid, + })) as { fill_id: string }; + const abort = new AbortController(); + const originalAwait = secrets.awaitExport.bind(secrets); + let released = false; + secrets.awaitExport = async (...args) => { + const exported = await originalAwait(...args); + abort.abort(); + return { + ...exported, + release: () => { + released = true; + exported.release(); + }, + }; + }; + await page.$eval('input[type="password"]', (el) => { + (el as HTMLInputElement).value = ""; + }); + await expect( + awaitFill.handler( + { ...ctx, signal: abort.signal }, + { fill_id: pendingAgain.fill_id, timeout_seconds: 1 }, + ), + ).rejects.toThrow("Request cancelled"); + expect(released).toBe(true); + expect( + await page.$eval( + 'input[type="password"]', + (el) => (el as HTMLInputElement).value.length, + ), + ).toBe(0); + secrets.awaitExport = originalAwait; + const second = new LightpandaHost(socket!); + await expect(second.start()).rejects.toThrow(); + expect(session.isStarted()).toBe(true); + await page.goto("http://127.0.0.1:4173/wrong"); + expect((await topLevelDocument(session)).document.documentId).not.toBe( + before.document.documentId, + ); + snapshot = await captureSnapshot(session, registry); + await expect( + fillSecret.handler(ctx, { + secret_id: ref!.secretId, + element_uid: snapshot.elements[0]!.uid, + }), + ).rejects.toThrow(); + await session.close(); + await expect(session.ensureStarted()).rejects.toThrow("consumed"); + } finally { + await session.close(); + } + }, + 30000, +); diff --git a/test/lightpanda-lifecycle.test.ts b/test/lightpanda-lifecycle.test.ts new file mode 100644 index 0000000..490bebe --- /dev/null +++ b/test/lightpanda-lifecycle.test.ts @@ -0,0 +1,23 @@ +import { expect, test } from "bun:test"; +import { LightpandaHost } from "../src/browser/lightpanda.js"; +import { BrowserSession } from "../src/browser/session.js"; +const socket = process.env["SBM_TEST_LIFECYCLE_SOCKET"]; +test.skipIf(!socket)( + "running worker loss invalidates the session and refuses reconnect", + async () => { + const session = new BrowserSession(new LightpandaHost(socket!)); + try { + await session.ensureStarted(); + expect(session.isStarted()).toBe(true); + console.log("WORKER_ATTACHED"); // Fixed readiness marker for the external crash harness. + const until = Date.now() + 10000; + while (session.isStarted() && Date.now() < until) + await new Promise((resolve) => setTimeout(resolve, 25)); + expect(session.isStarted()).toBe(false); + await expect(session.ensureStarted()).rejects.toThrow("consumed"); + } finally { + await session.close(); + } + }, + 15000, +); diff --git a/test/lightpanda-worker-fixture.ts b/test/lightpanda-worker-fixture.ts new file mode 100644 index 0000000..8635e72 --- /dev/null +++ b/test/lightpanda-worker-fixture.ts @@ -0,0 +1,19 @@ +/** Test-only loopback fixture inside the network:none worker container. */ +import { runWorker } from "../src/worker/lightpanda.js"; +const fixture = Bun.serve({ + hostname: "127.0.0.1", + port: 4173, + fetch: () => + new Response( + '', + { headers: { "content-type": "text/html" } }, + ), +}); +const worker = await runWorker( + "/ipc/browser.sock", + "/usr/local/bin/lightpanda", + Number(process.argv[2] ?? "300000"), +); +process.once("SIGTERM", () => void worker.stop()); +await worker.done; +fixture.stop(true); diff --git a/test/lightpanda.test.ts b/test/lightpanda.test.ts new file mode 100644 index 0000000..81681bd --- /dev/null +++ b/test/lightpanda.test.ts @@ -0,0 +1,218 @@ +import { expect, test } from "bun:test"; +import { mkdtemp, chmod, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createServer, connect } from "node:net"; +import { browserEnvironment, runWorker } from "../src/worker/lightpanda.js"; +import { + WorkerTransport, + readFrames, + writeFrame, + MAX_CDP_BYTES, +} from "../src/browser/lightpanda-transport.js"; +import { LocalChromeHost } from "../src/browser/hosts.js"; +import { findChrome } from "../src/runtime.js"; +import { + LightpandaHost, + verifyObservationIsolation, +} from "../src/browser/lightpanda.js"; + +async function directory() { + const dir = await mkdtemp(join(tmpdir(), "sbm-lp-")); + await chmod(dir, 0o700); + return dir; +} + +test("browser environment excludes credentials, proxy settings and loader hooks", () => { + const saved = process.env["TURNKEY_API_PRIVATE_KEY"]; + process.env["TURNKEY_API_PRIVATE_KEY"] = "synthetic-do-not-forward"; + try { + expect(browserEnvironment()).toEqual({ + PATH: "/usr/local/bin:/usr/bin:/bin", + HOME: "/tmp", + TMPDIR: "/tmp", + LIGHTPANDA_DISABLE_TELEMETRY: "true", + }); + } finally { + if (saved === undefined) delete process.env["TURNKEY_API_PRIVATE_KEY"]; + else process.env["TURNKEY_API_PRIVATE_KEY"] = saved; + } +}); + +test("private transport roundtrips bounded CDP without a TCP endpoint", async () => { + const dir = await directory(); + const path = join(dir, "cdp.sock"); + const server = createServer((socket) => + readFrames(socket, (message) => writeFrame(socket, message)), + ); + await new Promise((resolve) => server.listen(path, resolve)); + await chmod(path, 0o600); + try { + const transport = await WorkerTransport.open(path); + const received = new Promise((resolve) => { + transport.onmessage = resolve; + }); + transport.send(JSON.stringify({ id: 1, method: "Browser.getVersion" })); + expect(JSON.parse(await received).id).toBe(1); + expect(() => transport.send("x".repeat(MAX_CDP_BYTES + 1))).toThrow( + "unavailable", + ); + transport.close(); + } finally { + server.close(); + await rm(dir, { recursive: true, force: true }); + } +}); + +test("wrong destination and world-readable socket directories fail closed", async () => { + const dir = await directory(); + try { + await expect(WorkerTransport.open("ws://127.0.0.1:9222")).rejects.toThrow(); + await chmod(dir, 0o755); + await expect(WorkerTransport.open(join(dir, "cdp.sock"))).rejects.toThrow( + "0700", + ); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("idle worker expires without launching browser and cannot be attached", async () => { + const dir = await directory(); + const path = join(dir, "cdp.sock"); + try { + const worker = await runWorker(path, "/not/an/executable", 1000); + await worker.done; + await expect(WorkerTransport.open(path)).rejects.toThrow(); + await worker.stop(); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("worker startup crash consumes lease; no cross-session attach or reconnect", async () => { + const dir = await directory(); + const path = join(dir, "cdp.sock"); + try { + const worker = await runWorker(path, "/not/an/executable", 1000); + const socket = connect(path); + socket.on("error", () => {}); + await worker.done; + await expect(WorkerTransport.open(path)).rejects.toThrow(); + socket.destroy(); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("failed host attachment is never retried against another session", async () => { + const dir = await directory(); + try { + const host = new LightpandaHost(join(dir, "absent.sock")); + await expect(host.start()).rejects.toThrow("attachment failed"); + await expect(host.start()).rejects.toThrow("consumed"); + await host.stop(); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("cancelling attachment closes transport and never returns a live browser", async () => { + const dir = await directory(); + const path = join(dir, "cdp.sock"); + let connected!: () => void; + const accepted = new Promise((resolve) => { + connected = resolve; + }); + const server = createServer((socket) => { + socket.on("data", () => connected()); + socket.on("error", () => {}); + }); + await new Promise((resolve) => server.listen(path, resolve)); + await chmod(path, 0o600); + const host = new LightpandaHost(path); + try { + const start = host.start(); + const rejected = start.then( + () => "unexpected success", + (error: Error) => error.message, + ); + await accepted; + await host.stop(); + expect(await rejected).toContain("attachment failed"); + await expect(host.start()).rejects.toThrow("consumed"); + } finally { + await host.stop(); + server.close(); + await rm(dir, { recursive: true, force: true }); + } +}, 5000); + +test("malformed peer frames close the channel without passing content", async () => { + const dir = await directory(); + const path = join(dir, "cdp.sock"); + const server = createServer((socket) => socket.write("not JSON\n")); + await new Promise((resolve) => server.listen(path, resolve)); + await chmod(path, 0o600); + try { + const transport = await WorkerTransport.open(path); + let messages = 0; + transport.onmessage = () => { + ++messages; + }; + await new Promise((resolve) => { + transport.onclose = resolve; + }); + expect(messages).toBe(0); + } finally { + server.close(); + await rm(dir, { recursive: true, force: true }); + } +}); + +test("observation gate passes on Chrome with real isolated worlds and fresh document IDs", async () => { + const host = new LocalChromeHost({ executablePath: findChrome() }); + try { + const browser = await host.start(); + const page = await browser.newPage(); + await verifyObservationIsolation(page); + expect(page.url()).toBe("about:blank"); + } finally { + await host.stop(); + } +}); + +test("cancellation after focus prevents the plaintext CDP write and releases it", async () => { + const abort = new AbortController(); + const commands: string[] = []; + let released = false; + const session = { + resolveElement: () => ({ + backendNodeId: 1, + handle: { evaluate: async () => 0 }, + }), + cdpSession: () => ({ + send: async (method: string) => { + commands.push(method); + if (method === "DOM.focus") abort.abort(); + }, + }), + } as unknown as import("../src/browser/session.js").BrowserSession; + const { injectSecret } = await import("../src/browser/inject.js"); + await expect( + injectSecret( + session, + "field", + { + ref: { secretId: "fixture", staticProperties: {} }, + value: "synthetic", + release: () => { + released = true; + }, + }, + abort.signal, + ), + ).rejects.toThrow("Request cancelled"); + expect(commands).not.toContain("Input.insertText"); + expect(released).toBe(true); +}); diff --git a/test/mock-auth-server.ts b/test/mock-auth-server.ts new file mode 100644 index 0000000..92ec76a --- /dev/null +++ b/test/mock-auth-server.ts @@ -0,0 +1,111 @@ +/** + * Test-only authorization server: publishes a JWKS over HTTP and mints JWT + * access tokens signed with its one key. Not an OAuth implementation; + * it has no endpoints but the JWKS, because the broker only ever reads the + * key set. + */ +import { randomUUID } from "node:crypto"; + +import { + exportJWK, + generateKeyPair, + SignJWT, + type CryptoKey, + type JWK, +} from "jose"; + +type Key = { kid: string; privateKey: CryptoKey; publicJwk: JWK }; + +export type MintOptions = { + sub?: string; + aud?: string | string[]; + scope?: string; + /** Okta-style scopes claim. */ + scp?: string | string[]; + /** Seconds from now; negative for an expired token. */ + expiresIn?: number; + /** Seconds from now. */ + notBefore?: number; + /** `iat`, in seconds from now (default 0). */ + issuedAt?: number; + omitIat?: boolean; + /** More claims, such as `nonce` or `azp`. */ + claims?: Record; + /** Override `iss` (for a token claiming another issuer). */ + iss?: string; + omitExp?: boolean; + omitSub?: boolean; + /** The `typ` header (default `at+jwt`); null leaves it out. */ + typ?: string | null; +}; + +export class MockAuthServer { + readonly issuer: string; + readonly jwksUri: string; + /** JWKS fetches served, for cache assertions. */ + fetches = 0; + /** While true the JWKS endpoint answers 503. */ + down = false; + private constructor( + private readonly server: ReturnType, + private readonly key: Key, + ) { + this.issuer = `http://127.0.0.1:${server.port}`; + this.jwksUri = `${this.issuer}/jwks.json`; + } + + static async start(): Promise { + let self: MockAuthServer | undefined; + const server = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + fetch: (req) => { + if (new URL(req.url).pathname !== "/jwks.json") + return new Response("not found", { status: 404 }); + self!.fetches++; + if (self!.down) return new Response("down", { status: 503 }); + return Response.json({ keys: [self!.key.publicJwk] }); + }, + }); + const { privateKey, publicKey } = await generateKeyPair("ES256"); + const kid = randomUUID(); + const publicJwk = { ...(await exportJWK(publicKey)), kid, alg: "ES256" }; + self = new MockAuthServer(server, { kid, privateKey, publicJwk }); + return self; + } + + /** Sign with a key that was never published. */ + async mintWithUnpublishedKey(opts: MintOptions): Promise { + const { privateKey } = await generateKeyPair("ES256"); + return this.sign({ kid: "unpublished", privateKey, publicJwk: {} }, opts); + } + + mint(opts: MintOptions = {}): Promise { + return this.sign(this.key, opts); + } + + private sign(key: Key, opts: MintOptions): Promise { + const now = Math.floor(Date.now() / 1000); + const claims: Record = { ...opts.claims }; + if (opts.scope !== undefined) claims["scope"] = opts.scope; + if (opts.scp !== undefined) claims["scp"] = opts.scp; + const jwt = new SignJWT(claims) + .setProtectedHeader({ + alg: "ES256", + kid: key.kid, + ...(opts.typ === null ? {} : { typ: opts.typ ?? "at+jwt" }), + }) + .setIssuer(opts.iss ?? this.issuer) + .setJti(randomUUID()); + if (!opts.omitIat) jwt.setIssuedAt(now + (opts.issuedAt ?? 0)); + if (opts.aud !== undefined) jwt.setAudience(opts.aud); + if (!opts.omitSub) jwt.setSubject(opts.sub ?? "user-1"); + if (!opts.omitExp) jwt.setExpirationTime(now + (opts.expiresIn ?? 300)); + if (opts.notBefore !== undefined) jwt.setNotBefore(now + opts.notBefore); + return jwt.sign(key.privateKey); + } + + stop(): void { + this.server.stop(true); + } +} diff --git a/test/oauth.test.ts b/test/oauth.test.ts new file mode 100644 index 0000000..fb5080e --- /dev/null +++ b/test/oauth.test.ts @@ -0,0 +1,935 @@ +import { afterAll, beforeAll, expect, test } from "bun:test"; +import { randomBytes } from "node:crypto"; +import { + existsSync, + mkdtempSync, + readdirSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { + Client, + StreamableHTTPClientTransport, +} from "@modelcontextprotocol/client"; + +import { FIXTURE_ORIGIN, startFixtureServer } from "./fixtures/serve.js"; +import { MockAuthServer } from "./mock-auth-server.js"; + +/** + * The hosted broker as an OAuth resource server, black box: EMG-89 step 2 + * acceptance. + * + * - alpha trusts authorization server A and holds the default mock + * secrets, with demo-login-password behind consensus. + * - beta trusts B and A, and holds one secret of its own. Trusting A means + * a token A minted for alpha fails at beta on audience alone. + * - gamma trusts B and sets an `audience` override. + * - delta trusts A, for tests that need sessions of their own. Listing its + * secrets takes LIST_DELAY_MS. + * - epsilon trusts A and sets allowedClients. + * - zeta, eta, and theta trust A, for the browser-handle tests. + */ + +const DEMO_PLAINTEXT = "mock-demo-p@ssw0rd-1234"; // mock-secrets.ts seed +const BETA_PLAINTEXT = "beta-only-plaintext-0000"; +const ALL = "sbm:browse sbm:fill sbm:refs"; +const GAMMA_AUDIENCE = "api://sbm-gamma"; +const ALLOWED_ORIGIN = "https://allowed.example"; +const LIST_DELAY_MS = 1_000; + +const work = mkdtempSync(join(tmpdir(), "sbm-oauth-")); +const stateDir = join(work, "state"); +const stateKey = randomBytes(32).toString("hex"); +const port = 20_000 + Math.floor(Math.random() * 2_000); +const base = `http://127.0.0.1:${port}`; +const mcpUrl = (tenant: string) => `${base}/t/${tenant}/mcp`; +const prmUrl = (tenant: string) => + `${base}/.well-known/oauth-protected-resource/t/${tenant}/mcp`; + +let asA: MockAuthServer; +let asB: MockAuthServer; +let fixture: { stop: () => void }; +let broker: { stop: () => Promise }; +const tenantsFile = join(work, "tenants.json"); +/** Every response body the server sent, for leak assertions. */ +let transcript = ""; + +async function startBroker() { + const proc = Bun.spawn(["bun", "src/http.ts"], { + cwd: new URL("..", import.meta.url).pathname, + stdout: "ignore", + stderr: "pipe", + env: { + ...process.env, + TURNKEY_API_PUBLIC_KEY: "", + TURNKEY_API_PRIVATE_KEY: "", + TURNKEY_ORGANIZATION_ID: "", + SBM_HTTP_TOKEN: "", + // Every tenant here uses the mock backend, which needs the dev flag. + SBM_DEV_SHARED_TOKEN: "1", + SBM_HEADLESS: "true", + SBM_TENANTS: tenantsFile, + SBM_PUBLIC_URL: base, + SBM_HTTP_PORT: String(port), + SBM_STATE_DIR: stateDir, + SBM_STATE_KEY: stateKey, + // Eight tenants get five slots each, and a principal the default four. + SBM_MAX_SESSIONS: "40", + SBM_ALLOWED_ORIGINS: ALLOWED_ORIGIN, + SBM_MOCK_CONSENSUS: "demo-login-password", + SBM_MOCK_CONSENSUS_DELAY_MS: "2000", + }, + }); + let log = ""; + for await (const chunk of proc.stderr) { + log += new TextDecoder().decode(chunk); + if (log.includes("listening on")) break; + } + if (!log.includes("listening on")) throw new Error(`broker: ${log}`); + return { + stop: async () => { + proc.kill(); + await proc.exited; + }, + }; +} + +const token = ( + as: MockAuthServer, + tenant: string, + opts: { sub?: string; scope?: string; claims?: Record } = {}, +) => as.mint({ aud: mcpUrl(tenant), scope: ALL, ...opts }); + +type Session = { + client: Client; + call: ( + name: string, + args?: Record, + ) => Promise<{ isError: boolean; body: any; text: string }>; +}; + +/** A 2025-era session, or with `modern` a client pinned to 2026-07-28. */ +async function connect( + tenant: string, + bearer: string, + opts: { modern?: boolean } = {}, +): Promise { + const client = new Client( + { name: "oauth-test", version: "0" }, + opts.modern + ? { versionNegotiation: { mode: { pin: "2026-07-28" } } } + : undefined, + ); + await client.connect( + new StreamableHTTPClientTransport(new URL(mcpUrl(tenant)), { + requestInit: { headers: { authorization: `Bearer ${bearer}` } }, + fetch: async (input, init) => { + const res = await fetch(input, init); + void res + .clone() + .text() + .then((t) => (transcript += t)) + .catch(() => {}); + return res; + }, + }), + ); + const call: Session["call"] = async (name, args = {}) => { + const result = (await client.callTool({ name, arguments: args })) as { + isError?: boolean; + content: { text: string }[]; + }; + const text = result.content[0]?.text ?? ""; + let body: unknown = text; + try { + body = JSON.parse(text); + } catch {} + return { isError: result.isError === true, body, text }; + }; + return { client, call }; +} + +function mcpHeaders( + bearer: string | undefined, + sessionId: string | undefined, +): Record { + return { + "mcp-protocol-version": "2025-06-18", + ...(bearer ? { authorization: `Bearer ${bearer}` } : {}), + ...(sessionId ? { "mcp-session-id": sessionId } : {}), + }; +} + +/** One raw JSON-RPC POST, for status codes and headers the SDK hides. A + * message gets `jsonrpc` and `id: 1` unless it sets its own; an array is + * sent as given, as a batch. */ +async function rpc( + tenant: string, + bearer: string | undefined, + message: object | object[], + sessionId?: string, + headers: Record = {}, +): Promise { + const res = await fetch(mcpUrl(tenant), { + method: "POST", + headers: { + "content-type": "application/json", + accept: "application/json, text/event-stream", + ...mcpHeaders(bearer, sessionId), + ...headers, + }, + body: JSON.stringify( + Array.isArray(message) ? message : { jsonrpc: "2.0", id: 1, ...message }, + ), + }); + transcript += await res.clone().text(); + return res; +} + +const initialize = { + method: "initialize", + params: { + protocolVersion: "2025-06-18", + capabilities: {}, + clientInfo: { name: "raw", version: "0" }, + }, +}; +const toolCall = (name: string, args: object = {}, id = 1) => ({ + id, + method: "tools/call", + params: { name, arguments: args }, +}); +const fillCall = toolCall("fill_secret", { secret_id: "x", element_uid: "y" }); + +/** Open a session over raw HTTP; returns its id. */ +async function rawSession(tenant: string, bearer: string): Promise { + const res = await rpc(tenant, bearer, initialize); + expect(res.status).toBe(200); + const id = res.headers.get("mcp-session-id")!; + const initialized = { jsonrpc: "2.0", method: "notifications/initialized" }; + await rpc(tenant, bearer, [initialized], id); + return id; +} + +async function openLogin(s: Session) { + await s.call("navigate", { url: `${FIXTURE_ORIGIN}/login` }); + const snap = (await s.call("snapshot")).body; + return snap.elements.find((e: { type?: string }) => e.type === "password") + .uid as string; +} + +const refNames = async (s: Session) => + ((await s.call("list_secret_refs")).body.refs as { name: string }[]).map( + (r) => r.name, + ); + +beforeAll(async () => { + asA = await MockAuthServer.start(); + asB = await MockAuthServer.start(); + const a = { issuer: asA.issuer, jwksUri: asA.jwksUri }; + const b = { issuer: asB.issuer, jwksUri: asB.jwksUri }; + writeFileSync( + tenantsFile, + JSON.stringify({ + tenants: [ + { id: "alpha", backend: "mock", issuers: [a] }, + { + id: "beta", + backend: "mock", + issuers: [b, a], + authorizationServers: [asB.issuer], + mockSecrets: [ + { + name: "beta-login-password", + value: BETA_PLAINTEXT, + staticProperties: { + "sbm:origin": FIXTURE_ORIGIN, + "sbm:url-pattern": "/login*", + "sbm:selector": "input[type=password]", + }, + }, + ], + }, + { + id: "gamma", + backend: "mock", + issuers: [b], + audience: GAMMA_AUDIENCE, + }, + { + id: "delta", + backend: "mock", + issuers: [a], + listDelayMs: LIST_DELAY_MS, + }, + { + id: "epsilon", + backend: "mock", + issuers: [a], + allowedClients: ["agent-app"], + }, + { id: "zeta", backend: "mock", issuers: [a] }, + { id: "eta", backend: "mock", issuers: [a] }, + { id: "theta", backend: "mock", issuers: [a] }, + ], + }), + ); + fixture = startFixtureServer(); + broker = await startBroker(); +}); + +afterAll(async () => { + await broker.stop(); + fixture.stop(); + asA.stop(); + asB.stop(); + rmSync(work, { recursive: true, force: true }); +}); + +test("a mock tenant stops the broker at startup without the dev flag", async () => { + const proc = Bun.spawn(["bun", "src/http.ts"], { + cwd: new URL("..", import.meta.url).pathname, + stdout: "ignore", + stderr: "pipe", + env: { + ...process.env, + SBM_HTTP_TOKEN: "", + SBM_DEV_SHARED_TOKEN: "", + SBM_TENANTS: tenantsFile, + SBM_PUBLIC_URL: base, + SBM_HTTP_PORT: String(port + 1), + }, + }); + const guard = setTimeout(() => proc.kill(), 10_000); + expect(await proc.exited).not.toBe(0); + clearTimeout(guard); + expect(await new Response(proc.stderr).text()).toContain( + "Tenant alpha uses the mock secrets backend", + ); +}); + +test("each tenant publishes its Protected Resource Metadata", async () => { + // Public: any origin may read it. + const alpha = await fetch(prmUrl("alpha"), { + headers: { origin: "https://evil.example" }, + }); + expect(alpha.status).toBe(200); + expect(alpha.headers.get("access-control-allow-origin")).toBe("*"); + expect(await alpha.json()).toEqual({ + resource: mcpUrl("alpha"), + authorization_servers: [asA.issuer], + scopes_supported: ["sbm:browse", "sbm:fill", "sbm:refs"], + bearer_methods_supported: ["header"], + resource_name: "secure-browser-mcp (alpha)", + }); + const beta = (await (await fetch(prmUrl("beta"))).json()) as { + authorization_servers: string[]; + }; + expect(beta.authorization_servers).toEqual([asB.issuer]); + + expect((await fetch(prmUrl("nobody"))).status).toBe(404); + expect((await rpc("nobody", "x", initialize)).status).toBe(404); +}); + +test("missing, malformed, and invalid credentials are refused with a challenge", async () => { + const good = await token(asA, "alpha"); + const cases: [string | undefined, number, string | undefined][] = [ + [undefined, 401, undefined], + // Another scheme is no Bearer credentials at all. + ["Basic dXNlcjpwYXNz", 401, undefined], + ["Bearer", 400, "invalid_request"], + ["Bearer ", 400, "invalid_request"], + [`Bearer ${good} extra`, 400, "invalid_request"], + ["Bearer p@ss:word!", 400, "invalid_request"], + ["Bearer garbage", 401, "invalid_token"], + // B is a real issuer, but alpha does not trust it. + [`Bearer ${await token(asB, "alpha")}`, 401, "invalid_token"], + ]; + for (const [authorization, status, error] of cases) { + const res = await rpc( + "alpha", + undefined, + initialize, + undefined, + authorization ? { authorization } : {}, + ); + expect(res.status).toBe(status); + const challenge = res.headers.get("www-authenticate")!; + expect(challenge).toStartWith("Bearer "); + if (error) expect(challenge).toContain(`error="${error}"`); + else expect(challenge).not.toContain("error="); + expect(challenge).toContain(`scope="${ALL}"`); + expect(challenge).toContain(`resource_metadata="${prmUrl("alpha")}"`); + } +}); + +test("a token for tenant alpha is refused at tenant beta", async () => { + // beta trusts A, so only the audience tells these apart. + const res = await rpc("beta", await token(asA, "alpha"), initialize); + expect(res.status).toBe(401); + expect(res.headers.get("www-authenticate")).toContain( + `resource_metadata="${prmUrl("beta")}"`, + ); + expect((await rpc("beta", await token(asA, "beta"), initialize)).status).toBe( + 200, + ); +}); + +test("a tenant's audience override replaces the resource URI", async () => { + const ok = await asB.mint({ aud: GAMMA_AUDIENCE, scope: ALL }); + expect((await rpc("gamma", ok, initialize)).status).toBe(200); + // The resource URI itself is no longer accepted, nor an extra audience. + for (const aud of [mcpUrl("gamma"), [GAMMA_AUDIENCE, mcpUrl("gamma")]]) { + const bad = await asB.mint({ aud, scope: ALL }); + expect((await rpc("gamma", bad, initialize)).status).toBe(401); + } + // With an override, typ: at+jwt is required by default: an ID token for + // a client whose id is the override, without nonce, at_hash, or c_hash, + // differs from an access token only by typ. + for (const typ of ["JWT", null]) { + const idShaped = await asB.mint({ aud: GAMMA_AUDIENCE, scope: ALL, typ }); + expect((await rpc("gamma", idShaped, initialize)).status).toBe(401); + } +}); + +test("tenant endpoints refuse browser origins that are not allowed", async () => { + // Refused before auth: no challenge, even without a token. + const evil = await rpc("alpha", undefined, initialize, undefined, { + origin: "https://evil.example", + }); + expect(evil.status).toBe(403); + expect(evil.headers.get("www-authenticate")).toBeNull(); + expect(await evil.text()).toContain("Origin not allowed"); + + // An allowed origin goes on to the token check. + const allowed = await rpc("alpha", undefined, initialize, undefined, { + origin: ALLOWED_ORIGIN, + }); + expect(allowed.status).toBe(401); +}); + +test("a token without any sbm scope cannot open a session", async () => { + const res = await rpc( + "alpha", + await token(asA, "alpha", { scope: "openid profile" }), + initialize, + ); + expect(res.status).toBe(403); + const challenge = res.headers.get("www-authenticate")!; + expect(challenge).toContain('error="insufficient_scope"'); + expect(challenge).toContain('scope="sbm:browse"'); + expect(challenge).toContain(`resource_metadata="${prmUrl("alpha")}"`); + expect(res.headers.get("mcp-session-id")).toBeNull(); +}); + +test("a tool call without its scope gets 403 insufficient_scope", async () => { + const browseOnly = await token(asA, "alpha", { scope: "sbm:browse" }); + const sessionId = await rawSession("alpha", browseOnly); + + const fill = await rpc("alpha", browseOnly, fillCall, sessionId); + expect(fill.status).toBe(403); + const challenge = fill.headers.get("www-authenticate")!; + expect(challenge).toContain('error="insufficient_scope"'); + expect(challenge).toContain('scope="sbm:fill"'); + expect(challenge).toContain(`resource_metadata="${prmUrl("alpha")}"`); + + // A batch names every missing scope in one challenge. + const batch = await rpc( + "alpha", + browseOnly, + [ + { jsonrpc: "2.0", ...fillCall, id: 2 }, + { jsonrpc: "2.0", ...toolCall("list_secret_refs"), id: 3 }, + ], + sessionId, + ); + expect(batch.status).toBe(403); + expect(batch.headers.get("www-authenticate")).toContain( + 'scope="sbm:fill sbm:refs"', + ); +}); + +test("a session answers only to the tenant, subject, and client that opened it", async () => { + const carol = ( + tenant: string, + claims: Record, + sub = "carol", + ) => token(asA, tenant, { sub, claims }); + const sessionId = await rawSession( + "delta", + await carol("delta", { azp: "app-1" }), + ); + const list = { method: "tools/list" }; + const cases: [string, string, number][] = [ + // The same client, by azp or client_id, is the same caller. + ["delta", await carol("delta", { azp: "app-1" }), 200], + ["delta", await carol("delta", { client_id: "app-1" }), 200], + ["delta", await carol("delta", { azp: "app-1" }, "bob"), 404], + ["alpha", await carol("alpha", { azp: "app-1" }), 404], + ["delta", await carol("delta", { azp: "app-2" }), 404], + ["delta", await carol("delta", {}), 404], + ]; + for (const [tenant, bearer, status] of cases) { + expect((await rpc(tenant, bearer, list, sessionId)).status).toBe(status); + } +}); + +test("tokens of one subject with different clients cannot share a session", async () => { + const frank = (claims: Record) => + token(asA, "delta", { sub: "frank", claims }); + const list = { method: "tools/list" }; + const appA = await rawSession("delta", await frank({ azp: "app-a" })); + expect( + (await rpc("delta", await frank({ azp: "app-b" }), list, appA)).status, + ).toBe(404); + const appB = await rawSession("delta", await frank({ azp: "app-b" })); + expect( + (await rpc("delta", await frank({ azp: "app-a" }), list, appB)).status, + ).toBe(404); +}); + +test("a session opened without a client claim is bound to the tenant and subject only", async () => { + const gina = (claims: Record, sub = "gina") => + token(asA, "delta", { sub, claims }); + const sessionId = await rawSession("delta", await gina({})); + const list = { method: "tools/list" }; + const cases: [string, number][] = [ + // Any token of the subject without a client claim: the broker cannot + // tell those clients apart (docs/HOSTED.md, "Sessions"). + [await gina({}), 200], + [await gina({ azp: "" }), 200], + // A token that names a client, or another subject, cannot. + [await gina({ azp: "app-1" }), 404], + [await gina({ client_id: "app-1" }), 404], + [await gina({}, "hank"), 404], + ]; + for (const [bearer, status] of cases) { + expect((await rpc("delta", bearer, list, sessionId)).status).toBe(status); + } +}); + +test("a tenant with allowedClients refuses tokens of other clients", async () => { + const withClient = async (claims: Record) => + (await rpc("epsilon", await token(asA, "epsilon", { claims }), initialize)) + .status; + expect(await withClient({ azp: "agent-app" })).toBe(200); + expect(await withClient({ azp: "rogue-app" })).toBe(401); + expect(await withClient({})).toBe(401); +}); + +test("tenants see only their own secrets and fills, across a restart", async () => { + const alpha = await connect( + "alpha", + await token(asA, "alpha", { sub: "alice" }), + ); + const beta = await connect("beta", await token(asB, "beta", { sub: "eve" })); + expect(await refNames(beta)).toEqual(["beta-login-password"]); + + const refs = (await alpha.call("list_secret_refs")).body.refs; + const secretId = refs.find( + (r: { name: string }) => r.name === "demo-login-password", + ).secretId; + const parked = ( + await alpha.call("fill_secret", { + secret_id: secretId, + element_uid: await openLogin(alpha), + }) + ).body; + expect(parked.status).toBe("pending_approval"); + + // Parked under alpha's directory only. + const fills = (t: string) => { + const dir = join(stateDir, "tenants", t); + return existsSync(dir) + ? readdirSync(dir).filter((f) => f.endsWith(".fill")) + : []; + }; + expect(fills("alpha").length).toBe(1); + expect(fills("beta")).toEqual([]); + + const steal = async (s: Session) => { + const r = await s.call("await_fill", { + fill_id: parked.fill_id, + element_uid: "e1", + timeout_seconds: 1, + }); + expect(r.isError).toBe(true); + expect(r.text).toContain("Unknown fill_id"); + }; + await steal(beta); + + await alpha.client.close().catch(() => {}); + await beta.client.close().catch(() => {}); + await broker.stop(); + broker = await startBroker(); + + // After the restart every fill is an orphan. Neither beta nor another + // subject of alpha can claim alice's. + const betaAfter = await connect("beta", await token(asB, "beta")); + await steal(betaAfter); + const bob = await connect("alpha", await token(asA, "alpha", { sub: "bob" })); + await steal(bob); + + // The subject that parked it can claim it from a new session. + const aliceAfter = await connect( + "alpha", + await token(asA, "alpha", { sub: "alice" }), + ); + const done = await aliceAfter.call("await_fill", { + fill_id: parked.fill_id, + element_uid: await openLogin(aliceAfter), + timeout_seconds: 10, + }); + expect(done.body).toMatchObject({ filled: true, secret_id: secretId }); + expect(fills("alpha")).toEqual([]); + + for (const s of [betaAfter, bob, aliceAfter]) await s.client.close(); +}, 90_000); + +test("a request id in flight cannot be reused in the session", async () => { + // Two tokens of one subject share a session: one may list secret refs, + // the other may only browse. + const refsToken = await token(asA, "delta", { sub: "dana" }); + const browseOnly = await token(asA, "delta", { + sub: "dana", + scope: "sbm:browse", + }); + const sessionId = await rawSession("delta", refsToken); + const listRefs = toolCall("list_secret_refs", {}, 7); + const navigate = (url: string) => toolCall("navigate", { url }, 7); + // A page slower than list_secret_refs, so the browse-only call would + // still be open when the refs result is sent. + const slowPage = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + fetch: async () => { + await Bun.sleep(LIST_DELAY_MS * 2); + return new Response("slow", { + headers: { "content-type": "text/html" }, + }); + }, + }); + + // If the broker forwarded the reused id, the transport would send the + // refs result on the browse-only call's stream. + const slow = rpc("delta", refsToken, listRefs, sessionId); + await Bun.sleep(LIST_DELAY_MS / 5); + const dup = await rpc( + "delta", + browseOnly, + navigate(`http://127.0.0.1:${slowPage.port}/`), + sessionId, + ).finally(() => slowPage.stop(true)); + const dupText = await dup.text(); + expect(dup.status).toBe(409); + expect(dupText).not.toContain("demo-login-password"); + expect(JSON.parse(dupText)).toMatchObject({ + error: { code: -32600 }, + id: null, + }); + + const first = await slow; + expect(first.status).toBe(200); + expect(await first.text()).toContain("demo-login-password"); + + // The id is free once the call is done. + const again = await rpc( + "delta", + browseOnly, + navigate(`${FIXTURE_ORIGIN}/login`), + sessionId, + ); + expect(again.status).toBe(200); + // Each request still gets the scopes of its own token. + expect((await rpc("delta", browseOnly, listRefs, sessionId)).status).toBe( + 403, + ); +}, 30_000); + +test("a GET stream ends when its token expires", async () => { + const long = await token(asA, "delta", { sub: "erin" }); + const sessionId = await rawSession("delta", long); + const get = (bearer: string) => + fetch(mcpUrl("delta"), { + headers: { + accept: "text/event-stream", + ...mcpHeaders(bearer, sessionId), + }, + }); + + const short = await asA.mint({ + aud: mcpUrl("delta"), + sub: "erin", + scope: ALL, + expiresIn: 2, + }); + const started = Date.now(); + const stream = await get(short); + expect(stream.status).toBe(200); + // Read until the broker ends the stream; the test timeout catches a + // stream that never ends. + const reader = stream.body!.getReader(); + while (!(await reader.read()).done); + const elapsed = Date.now() - started; + expect(elapsed).toBeGreaterThan(1_000); + expect(elapsed).toBeLessThan(5_000); + + // The session's one GET stream is free again, for a fresh token. + const again = await get(long); + expect(again.status).toBe(200); + await again.body?.cancel(); +}, 15_000); + +test("a GET stream opened just after an expiry stays open", async () => { + const long = await token(asA, "delta", { sub: "erin2" }); + const sessionId = await rawSession("delta", long); + const get = (bearer: string) => + fetch(mcpUrl("delta"), { + headers: { + accept: "text/event-stream", + ...mcpHeaders(bearer, sessionId), + }, + }); + const short = await asA.mint({ + aud: mcpUrl("delta"), + sub: "erin2", + scope: ALL, + expiresIn: 2, + }); + const expiring = await get(short); + expect(expiring.status).toBe(200); + const old = expiring.body!.getReader(); + while (!(await old.read()).done); + + // Opened inside the expired stream's 1 s fallback, which must not end it. + const again = await get(long); + expect(again.status).toBe(200); + const reader = again.body!.getReader(); + const deadline = Date.now() + 2_000; + let ended = false; + while (!ended && Date.now() < deadline) { + const next = await Promise.race([ + reader.read(), + Bun.sleep(deadline - Date.now()).then(() => undefined), + ]); + if (next === undefined) break; + ended = next.done; + } + expect(ended).toBe(false); + // Still the session's one GET stream. + expect((await get(long)).status).toBe(409); + await reader.cancel(); +}, 15_000); + +test("a request id is free again after its client disconnects, once the server has answered", async () => { + const bearer = await token(asA, "delta", { sub: "quitter" }); + const sessionId = await rawSession("delta", bearer); + const listRefs = toolCall("list_secret_refs", {}, 11); + const dropper = new AbortController(); + const dropped = await fetch(mcpUrl("delta"), { + method: "POST", + signal: dropper.signal, + headers: { + "content-type": "application/json", + accept: "application/json, text/event-stream", + ...mcpHeaders(bearer, sessionId), + }, + body: JSON.stringify({ jsonrpc: "2.0", ...listRefs }), + }); + expect(dropped.status).toBe(200); + dropper.abort(); + + // Still in flight: its answer would go to a new request with this id. + expect((await rpc("delta", bearer, listRefs, sessionId)).status).toBe(409); + await Bun.sleep(LIST_DELAY_MS * 2); + // Answered, though nobody received it: the id is free. + expect((await rpc("delta", bearer, listRefs, sessionId)).status).toBe(200); +}, 15_000); + +test("a GET stream the client drops is released, and its expiry with it", async () => { + const long = await token(asA, "delta", { sub: "flaky" }); + const sessionId = await rawSession("delta", long); + const get = (bearer: string, signal?: AbortSignal) => + fetch(mcpUrl("delta"), { + ...(signal ? { signal } : {}), + headers: { + accept: "text/event-stream", + ...mcpHeaders(bearer, sessionId), + }, + }); + const short = await asA.mint({ + aud: mcpUrl("delta"), + sub: "flaky", + scope: ALL, + expiresIn: 2, + }); + const dropper = new AbortController(); + const dropped = await get(short, dropper.signal); + expect(dropped.status).toBe(200); + dropper.abort(); + + // The session's one GET stream is free again, and the dropped stream's + // expiry does not end the new one. + const again = await get(long); + expect(again.status).toBe(200); + const reader = again.body!.getReader(); + const deadline = Date.now() + 3_500; + let ended = false; + while (!ended && Date.now() < deadline) { + const next = await Promise.race([ + reader.read(), + Bun.sleep(deadline - Date.now()).then(() => undefined), + ]); + if (next === undefined) break; + ended = next.done; + } + expect(ended).toBe(false); + await reader.cancel(); +}, 15_000); + +test("one principal or tenant cannot take every session slot", async () => { + /** Open sessions that each hold a browser until refused; returns how + * many opened, and the refusal. */ + const fill = async (bearer: string) => { + let opened = 0; + let res: Response; + while ((res = await rpc("beta", bearer, initialize)).status === 200) { + opened++; + expect(opened).toBeLessThanOrEqual(5); + const id = res.headers.get("mcp-session-id")!; + const navigate = toolCall("navigate", { url: `${FIXTURE_ORIGIN}/login` }); + expect((await rpc("beta", bearer, navigate, id)).status).toBe(200); + } + expect(res.status).toBe(503); + return { opened, refusal: await res.text() }; + }; + + // beta gets 5 slots, and one principal 4 of them. An earlier test may + // still hold a beta session. + const greedy = await fill(await token(asB, "beta", { sub: "greedy" })); + expect(greedy.opened).toBe(4); + expect(greedy.refusal).toContain("caller is at its session limit"); + // A principal is (iss, sub): the same sub from another issuer is another. + const next = await fill(await token(asA, "beta", { sub: "greedy" })); + expect(next.opened).toBeLessThanOrEqual(1); + expect(next.refusal).toContain("tenant is at its session limit"); + + expect( + (await rpc("alpha", await token(asA, "alpha"), initialize)).status, + ).toBe(200); +}, 60_000); + +test("at its session limit, a caller's idle sessions without a browser make room", async () => { + const bearer = await token(asA, "zeta", { sub: "per-call" }); + const ids: string[] = []; + for (let i = 0; i < 6; i++) ids.push(await rawSession("zeta", bearer)); + // Four live at most: the two oldest were closed for the newest two. + const list = { method: "tools/list" }; + const status = async (id: string) => + (await rpc("zeta", bearer, list, id)).status; + expect(await status(ids[0]!)).toBe(404); + expect(await status(ids[1]!)).toBe(404); + for (const id of ids.slice(2)) expect(await status(id)).toBe(200); + // Another caller's sessions are never closed to make room. + const other = await token(asA, "zeta", { sub: "neighbor" }); + const theirs = await rawSession("zeta", other); + for (let i = 0; i < 4; i++) await rawSession("zeta", bearer); + expect((await rpc("zeta", other, list, theirs)).status).toBe(200); +}); + +test("a browser_id answers only to the tenant, subject, and client that opened it, in both eras", async () => { + const ivy = (tenant: string, claims: Record, sub = "ivy") => + token(asA, tenant, { sub, claims }); + const owner = await connect("eta", await ivy("eta", { azp: "app-1" }), { + modern: true, + }); + const { browser_id } = (await owner.call("open_browser")).body; + await owner.call("navigate", { + url: `${FIXTURE_ORIGIN}/login`, + browser_id, + }); + + // To anyone else the id looks exactly like one that was never minted. + const never = `br_${randomBytes(24).toString("base64url")}`; + const notFound = (id: string) => + `Unknown browser_id: ${id}. It was closed, timed out, or belongs to ` + + "another caller. Call open_browser for a new one."; + const strangers: [string, string][] = [ + ["eta", await ivy("eta", { azp: "app-1" }, "mallory")], + ["eta", await ivy("eta", { azp: "app-2" })], + ["eta", await ivy("eta", {})], + ["theta", await ivy("theta", { azp: "app-1" })], + ]; + for (const [tenant, bearer] of strangers) { + for (const modern of [true, false]) { + const s = await connect(tenant, bearer, { modern }); + for (const id of [browser_id, never]) { + const snap = await s.call("snapshot", { browser_id: id }); + expect(snap.isError).toBe(true); + expect(snap.text).toBe(notFound(id)); + const close = await s.call("close_browser", { browser_id: id }); + expect(close.text).toBe(notFound(id)); + } + await s.client.close(); + } + } + + // The owner, from a 2025 session this time, still has its browser. + const again = await connect("eta", await ivy("eta", { azp: "app-1" })); + const snap = (await again.call("snapshot", { browser_id })).body; + expect(snap.url).toContain("/login"); + expect((await again.call("close_browser", { browser_id })).isError).toBe( + false, + ); + await again.client.close(); + await owner.client.close(); +}, 60_000); + +test("one principal cannot open more browser handles than its limit", async () => { + const s = await connect( + "delta", + await token(asA, "delta", { sub: "hoard" }), + { + modern: true, + }, + ); + const ids: string[] = []; + for (let i = 0; i < 4; i++) { + const opened = await s.call("open_browser"); + expect(opened.isError).toBe(false); + ids.push(opened.body.browser_id); + } + expect(new Set(ids).size).toBe(4); + const refused = await s.call("open_browser"); + expect(refused.isError).toBe(true); + expect(refused.text).toContain("close_browser"); + // A refusal for one caller is not one for the next. + const other = await connect( + "delta", + await token(asA, "delta", { sub: "frugal" }), + { modern: true }, + ); + expect((await other.call("open_browser")).isError).toBe(false); + + await s.call("close_browser", { browser_id: ids[0] }); + expect((await s.call("open_browser")).isError).toBe(false); + await s.client.close(); + await other.client.close(); +}); + +test("a 2026-07-28 request needs the tool's scope too", async () => { + const browseOnly = await token(asA, "alpha", { scope: "sbm:browse" }); + const s = await connect("alpha", browseOnly, { modern: true }); + await expect(s.call("list_secret_refs")).rejects.toThrow(); + expect((await s.call("open_browser")).isError).toBe(false); + await s.client.close(); +}); + +test("the plaintext never appeared in any HTTP response", () => { + expect(transcript.length).toBeGreaterThan(0); + expect(transcript).not.toContain(DEMO_PLAINTEXT); + expect(transcript).not.toContain(BETA_PLAINTEXT); +}); diff --git a/test/observe.test.ts b/test/observe.test.ts new file mode 100644 index 0000000..ef611d7 --- /dev/null +++ b/test/observe.test.ts @@ -0,0 +1,317 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; + +import { + authorize, + createSecretRef, + type FillRequest, + type SecretRef, +} from "@turnkey/browser-secrets"; +import fixtureFile from "@turnkey/browser-secrets/fixtures/conformance.json" with { type: "json" }; +import type { ElementHandle, Page } from "puppeteer-core"; + +import { LocalChromeHost } from "../src/browser/hosts.js"; +import { observeTargets, topLevelDocument } from "../src/browser/observe.js"; +import { BrowserSession } from "../src/browser/session.js"; +import { findChrome } from "../src/runtime.js"; +import { FIXTURE_ORIGIN, startFixtureServer } from "./fixtures/serve.js"; + +/** + * The observation builder against a real browser and the fixture pages. + * `authorize` trusts the observation, so these tests check that each fact + * comes from the live page and that page script cannot change it. + */ + +let fixture: { stop: () => void }; +let session: BrowserSession; +let page: Page; + +beforeAll(async () => { + fixture = startFixtureServer(); + session = new BrowserSession( + new LocalChromeHost({ executablePath: findChrome(), headless: true }), + ); + page = await session.ensureStarted(); +}); + +afterAll(async () => { + await session.close(); + fixture.stop(); +}); + +/** Registers elements with the session the way a snapshot does. */ +async function register( + handles: Record>, +): Promise { + session.setElements( + await Promise.all( + Object.entries(handles).map(async ([uid, handle]) => ({ + uid, + backendNodeId: await handle.backendNodeId(), + handle, + })), + ), + ); +} + +async function byName(name: string): Promise> { + const handle = await page.$(`[name=${name}]`); + if (!handle) throw new Error(`fixture has no element named ${name}`); + return handle; +} + +const passwordRef = createSecretRef({ + secretId: "s1", + staticProperties: { + "sbm:origin": FIXTURE_ORIGIN, + "sbm:url-pattern": "/login*", + "sbm:selector": "input[type=password]", + }, +}); + +describe("observeTargets", () => { + test("reads frame, document and selector facts from the live page", async () => { + await page.goto(`${FIXTURE_ORIGIN}/login#top`); + await register({ + pw: await byName("password"), + user: await byName("username"), + }); + const request = { targets: [{ elementId: "pw" }, { elementId: "user" }] }; + const observation = await observeTargets(session, passwordRef, request); + const top = await topLevelDocument(session); + + expect(observation.browserSessionId).toBe(session.sessionId()); + expect(observation.topLevelUrl).toBe(`${FIXTURE_ORIGIN}/login#top`); + expect(observation.targets).toEqual([ + { + elementId: "pw", + frames: [ + { + frameId: top.document.frameId, + documentId: top.document.documentId, + url: `${FIXTURE_ORIGIN}/login#top`, + origin: FIXTURE_ORIGIN, + }, + ], + shadowRoot: "none", + selectorMatches: { "input[type=password]": true }, + }, + expect.objectContaining({ + elementId: "user", + selectorMatches: { "input[type=password]": false }, + }), + ]); + expect(authorize(passwordRef, request, observation)).toMatchObject({ + allowed: false, + reason: "selector_mismatch", + targetIndex: 1, + }); + }); + + test("a new document gets a new document identity", async () => { + await page.goto(`${FIXTURE_ORIGIN}/login`); + const first = await topLevelDocument(session); + await page.goto(`${FIXTURE_ORIGIN}/login`); + const second = await topLevelDocument(session); + expect(second.document.frameId).toBe(first.document.frameId); + expect(second.document.documentId).not.toBe(first.document.documentId); + }); + + test("page script cannot fake a selector match", async () => { + await page.goto(`${FIXTURE_ORIGIN}/login`); + await page.evaluate(() => { + Element.prototype.matches = () => true; + Element.prototype.getRootNode = () => document; + }); + await register({ user: await byName("username") }); + const observation = await observeTargets(session, passwordRef, { + targets: [{ elementId: "user" }], + }); + expect(observation.targets[0]?.selectorMatches).toEqual({ + "input[type=password]": false, + }); + }); + + test("a selector the browser rejects records false", async () => { + await page.goto(`${FIXTURE_ORIGIN}/login`); + await register({ pw: await byName("password") }); + const ref = createSecretRef({ + secretId: "s2", + staticProperties: { + "sbm:origin": FIXTURE_ORIGIN, + "sbm:selector": "input[", + }, + }); + const observation = await observeTargets(session, ref, { + targets: [{ elementId: "pw" }], + }); + expect(observation.targets[0]?.selectorMatches).toEqual({ + "input[": false, + }); + }); + + test("reports open and closed shadow roots", async () => { + await page.goto(`${FIXTURE_ORIGIN}/login`); + const [open, closed] = await Promise.all( + (["open", "closed"] as const).map((mode) => + page.evaluateHandle((m) => { + const host = document.createElement("div"); + document.body.append(host); + const root = host.attachShadow({ mode: m }); + root.innerHTML = ''; + return root.querySelector("input")!; + }, mode), + ), + ); + await register({ + open: open as ElementHandle, + closed: closed as ElementHandle, + }); + const request = { + targets: [{ elementId: "open" }, { elementId: "closed" }], + }; + const observation = await observeTargets(session, passwordRef, request); + expect(observation.targets.map((t) => t.shadowRoot)).toEqual([ + "open", + "closed", + ]); + expect(observation.targets[0]?.selectorMatches).toEqual({ + "input[type=password]": true, + }); + expect(authorize(passwordRef, request, observation)).toMatchObject({ + allowed: false, + reason: "unsupported_shadow_root", + targetIndex: 1, + }); + }); + + test("leaves out detached and unknown elements", async () => { + await page.goto(`${FIXTURE_ORIGIN}/login`); + const pw = await byName("password"); + await register({ pw }); + await pw.evaluate((el) => el.remove()); + const request = { + targets: [{ elementId: "pw" }, { elementId: "never-snapshotted" }], + }; + const observation = await observeTargets(session, passwordRef, request); + expect(observation.targets).toEqual([]); + expect(authorize(passwordRef, request, observation)).toMatchObject({ + allowed: false, + reason: "target_not_observed", + }); + }); + + test("observes an element named under several keys once", async () => { + await page.goto(`${FIXTURE_ORIGIN}/checkout`); + await register({ n: await byName("cardNumber") }); + const ref = createSecretRef({ + secretId: "card", + staticProperties: { + "sbm:origin": FIXTURE_ORIGIN, + "sbm:fields": JSON.stringify({ + number: "input[name=cardNumber]", + cvc: "input[name=cardCvc]", + }), + }, + }); + const observation = await observeTargets(session, ref, { + targets: [ + { key: "number", elementId: "n" }, + { key: "cvc", elementId: "n" }, + ], + }); + expect(observation.targets).toHaveLength(1); + expect(observation.targets[0]?.selectorMatches).toEqual({ + "input[name=cardNumber]": true, + "input[name=cardCvc]": false, + }); + }); +}); + +/** + * The browser-secrets conformance fixture, run against observations this + * builder makes from the live fixture pages. The fixture's own + * observations are hand-written; here only the secret, the request and the + * expected decision come from the fixture. A fixture element ID names the + * fixture field with that `name`. + * + * Cases on other origins (shop.example, the iframe pages on port 5001) need + * pages this repo does not serve, or iframe support the snapshot does not + * have yet (EMG-66). They are listed so a new case is not skipped silently. + */ +describe("conformance fixture against live observations", () => { + type Case = { + kind: string; + name: string; + secret: { secretId: string; staticProperties: Record }; + request: FillRequest; + observation: { topLevelUrl: string }; + expect: { allowed: boolean; reason?: string }; + }; + const cases = (fixtureFile as unknown as { cases: Case[] }).cases.filter( + (c) => c.kind === "authorize", + ); + + const LIVE = [ + "fills every card part with one request", + "refuses a request with no targets", + "refuses a mix of keyed and unkeyed targets", + "refuses a secret without a binding", + "refuses an unkeyed fill of a field-bound card", + "refuses a payload key the binding does not declare", + "refuses a page outside the bound URL pattern", + "refuses an element that does not match the bound selector", + ]; + const NOT_LIVE = [ + "allows nested frames whose every ancestor is bound", + "refuses a request with an empty element ID", + "refuses the same key and element twice", + "refuses a secret with a malformed binding", + "refuses a malformed observation", + "refuses a look-alike host for an origin-only secret", + "refuses a detached target", + "refuses a frame ancestry that does not start at the top-level URL", + "refuses a srcdoc frame", + "refuses a sandboxed frame with an opaque origin", + "refuses a same-origin iframe without sbm:frame-origin", + "refuses a frame whose origin is not the bound frame origin", + "refuses nested frames with an unbound intermediate origin", + "refuses a frame-bound secret in the top-level document", + "refuses an element in a closed shadow root", + "refuses a target without selector evidence", + ]; + + test("every authorize case is either run live or listed as not live", () => { + expect(cases.map((c) => c.name).sort()).toEqual( + [...LIVE, ...NOT_LIVE].sort(), + ); + }); + + /** Fixture element IDs that are not field names on the fixture pages. */ + const ALIASES: Record = { + // The URL-pattern case targets a password field on /checkout, which + // has none; any field shows the URL check, which runs first. + password: "cardName", + }; + + for (const c of cases.filter((c) => LIVE.includes(c.name))) { + test(c.name, async () => { + await page.goto(c.observation.topLevelUrl); + const handles: Record> = {}; + for (const { elementId } of c.request.targets) { + const name = (await page.$(`[name=${elementId}]`)) + ? elementId + : ALIASES[elementId]; + if (name) handles[elementId] = await byName(name); + } + await register(handles); + const ref: SecretRef = createSecretRef(c.secret); + const observation = await observeTargets(session, ref, c.request); + const decision = authorize(ref, c.request, observation); + const outcome = (allowed: boolean, reason?: string): string => + allowed ? "allowed" : `denied:${reason}`; + expect( + outcome(decision.allowed, decision.allowed ? "" : decision.reason), + ).toBe(outcome(c.expect.allowed, c.expect.reason)); + }); + } +}); diff --git a/test/onboarding.test.ts b/test/onboarding.test.ts index a89b6f1..77d4629 100644 --- a/test/onboarding.test.ts +++ b/test/onboarding.test.ts @@ -1,15 +1,17 @@ import { expect, test } from "bun:test"; + +import { RedactionRegistry } from "@turnkey/browser-secrets"; + import { parseImport, runImport } from "../scripts/import-secret.js"; import { listSecretRefs } from "../src/tools/list-secret-refs.js"; import { MockSecretsClient } from "../src/broker/mock-secrets.js"; -import { BindingPolicy } from "../src/broker/binding.js"; import { MemoryPendingFillStore, scopedFills, } from "../src/broker/pending-store.js"; import { LocalChromeHost } from "../src/browser/hosts.js"; import { BrowserSession } from "../src/browser/session.js"; -import { RedactionRegistry } from "../src/redaction/registry.js"; +import { BrowserHandles, browserScope } from "../src/browsers.js"; const secret = "canary-never-print-this"; const env = { @@ -140,12 +142,24 @@ for (const backend of ["mock", "turnkey"] as const) { { backend, secrets, + browsers: browserScope({ + handles: new BrowserHandles( + { max: 1, perEndpoint: 1, perPrincipal: () => 1 }, + 60_000, + ), + owner: { endpoint: "test", principal: "test", client: undefined }, + make: () => { + throw new Error("unused"); + }, + }), session: new BrowserSession( new LocalChromeHost({ executablePath: "/unused" }), ), registry: new RedactionRegistry(), - binding: new BindingPolicy(), - pendingFills: scopedFills(new MemoryPendingFillStore(), "test"), + pendingFills: scopedFills(new MemoryPendingFillStore(), { + owner: "test", + principal: "test", + }), }, {}, ); diff --git a/test/pending-store.test.ts b/test/pending-store.test.ts new file mode 100644 index 0000000..858040d --- /dev/null +++ b/test/pending-store.test.ts @@ -0,0 +1,133 @@ +import { afterAll, expect, test } from "bun:test"; +import { randomBytes } from "node:crypto"; +import { cpSync, mkdtempSync, readdirSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { + FilePendingFillStore, + MemoryPendingFillStore, + scopedFills, + tenantStateKey, + type PendingFill, + type StoredFill, +} from "../src/broker/pending-store.js"; + +const dirs: string[] = []; +const tempDir = () => { + const d = mkdtempSync(join(tmpdir(), "sbm-store-")); + dirs.push(d); + return d; +}; +afterAll(() => + dirs.forEach((d) => rmSync(d, { recursive: true, force: true })), +); + +const fill = (fillId: string): PendingFill => ({ + fillId, + pending: { + ref: { secretId: "s1", name: "n", staticProperties: {} }, + activityId: "act", + fingerprint: "fp", + material: "{}", + }, + targets: [{ elementUid: "e1" }], + pageUrl: "https://example.com/", + createdAt: Date.now(), +}); + +test("an orphaned fill can be claimed only by the same principal", async () => { + const store = new MemoryPendingFillStore(); + const gone = () => false; + const alice1 = scopedFills(store, { owner: "s1", principal: "alice" }); + await alice1.put(fill("f1")); + + const bob = scopedFills(store, { + owner: "s2", + principal: "bob", + isLive: gone, + }); + expect(await bob.get("f1")).toBeUndefined(); + await bob.delete("f1"); + + const alice2 = scopedFills(store, { + owner: "s3", + principal: "alice", + isLive: gone, + }); + expect((await alice2.get("f1"))?.fillId).toBe("f1"); + expect(alice2.list().map((f) => f.fillId)).toEqual(["f1"]); + expect(alice1.list()).toEqual([]); +}); + +test("two sessions of one principal cannot both claim an orphan", async () => { + const store = new FilePendingFillStore(tempDir(), randomBytes(32)); + await scopedFills(store, { owner: "gone", principal: "alice" }).put( + fill("f1"), + ); + const isLive = (owner: string) => owner !== "gone"; + const claims = await Promise.all( + ["s2", "s3"].map((owner) => + scopedFills(store, { owner, principal: "alice", isLive }).get("f1"), + ), + ); + expect(claims.filter((c) => c !== undefined).length).toBe(1); +}); + +test("a claim whose write fails leaves the orphan with its owner", async () => { + class FailingStore extends MemoryPendingFillStore { + failing = false; + override async put(f: StoredFill): Promise { + if (this.failing) throw new Error("disk full"); + await super.put(f); + } + } + const store = new FailingStore(); + await scopedFills(store, { owner: "gone", principal: "alice" }).put( + fill("f1"), + ); + const isLive = (owner: string) => owner !== "gone"; + store.failing = true; + await expect( + scopedFills(store, { owner: "s2", principal: "alice", isLive }).get("f1"), + ).rejects.toThrow("disk full"); + expect(store.list("gone").map((f) => f.fillId)).toEqual(["f1"]); + expect(store.list("s2")).toEqual([]); + + // Still an orphan, so another session of the principal can claim it. + store.failing = false; + const s3 = scopedFills(store, { owner: "s3", principal: "alice", isLive }); + expect((await s3.get("f1"))?.fillId).toBe("f1"); +}); + +test("tenant keys are independent, so one tenant's files do not load under another's key", async () => { + const master = randomBytes(32); + const keyA = tenantStateKey(master, "a"); + const keyB = tenantStateKey(master, "b"); + expect(keyA.equals(keyB)).toBe(false); + expect(keyA.equals(tenantStateKey(master, "a"))).toBe(true); + + const dirA = tempDir(); + const a = new FilePendingFillStore(dirA, keyA); + await scopedFills(a, { owner: "s1", principal: "p" }).put(fill("f1")); + expect(readdirSync(dirA).length).toBe(1); + + // Copying A's file into B's directory gives B nothing. + const dirB = tempDir(); + cpSync(dirA, dirB, { recursive: true }); + const b = new FilePendingFillStore(dirB, keyB); + const bView = scopedFills(b, { + owner: "s2", + principal: "p", + isLive: () => false, + }); + expect(await bView.get("f1")).toBeUndefined(); + + const reloaded = new FilePendingFillStore(dirA, keyA); + const aView = scopedFills(reloaded, { + owner: "s3", + principal: "p", + isLive: () => false, + }); + expect((await aView.get("f1"))?.fillId).toBe("f1"); +}); diff --git a/test/redaction.test.ts b/test/redaction.test.ts index d9b23b1..c20bf7a 100644 --- a/test/redaction.test.ts +++ b/test/redaction.test.ts @@ -1,18 +1,33 @@ import { expect, test } from "bun:test"; -import { RedactionRegistry } from "../src/redaction/registry.js"; +import { RedactionRegistry } from "@turnkey/browser-secrets"; + +/** + * The broker's redaction guarantees, run against the @turnkey/browser-secrets + * registry that now provides them. These cases came with the broker's own + * registry; keeping them here fails a package change that weakens them. + */ const registry = () => { const r = new RedactionRegistry(); - r.trackValue("4242424242424242", "card"); - r.trackValue("123", "cvc"); + r.registerValue("4242424242424242", "card"); + r.registerValue("123", "cvc"); return r; }; test("scrubs exact matches", () => { - expect(registry().scrubText("n=4242424242424242&c=123")).toBe( - "n=[REDACTED:card]&c=[REDACTED:cvc]", - ); + expect(registry().scrubText("n=4242424242424242")).toBe("n=[REDACTED:card]"); +}); + +test("does not text-scan values shorter than four characters", () => { + // The broker's own registry scanned these exactly. browser-secrets skips + // them: they match unrelated text and are cheap to confirm by guessing. + // A filled CVC field is still redacted by its registered target. + const r = new RedactionRegistry(); + expect(r.registerValues([{ value: "123", secretId: "cvc" }])).toEqual([ + "cvc", + ]); + expect(r.scrubText("c=123")).toBe("c=123"); }); test("scrubs separator-formatted copies of long values", () => { @@ -57,8 +72,8 @@ test("scrubs URL-encoded and other-script copies", () => { test("returns match spans for callers that map them back", () => { const text = "4242 4242 | 4242 4242"; - expect(registry().matches(text)).toEqual([ - { start: 0, end: text.length, secretId: "card" }, + expect(registry().findMatches(text)).toEqual([ + { start: 0, end: text.length, secretIds: ["card"] }, ]); }); @@ -69,7 +84,7 @@ test("does not stitch digits across wide gaps", () => { test("strips punctuation from long non-digit values", () => { const r = new RedactionRegistry(); - r.trackValue("sk_live_abc123def456", "key"); + r.registerValue("sk_live_abc123def456", "key"); expect(r.scrubText("key: SK-LIVE:ABC123,DEF456 ok")).toBe( "key: [REDACTED:key] ok", ); @@ -77,7 +92,7 @@ test("strips punctuation from long non-digit values", () => { test("folds case for long values", () => { const r = new RedactionRegistry(); - r.trackValue("gb82west12345698765432", "iban"); + r.registerValue("gb82west12345698765432", "iban"); expect(r.scrubText("IBAN GB82 WEST 1234 5698 7654 32 ok")).toBe( "IBAN [REDACTED:iban] ok", ); @@ -91,8 +106,22 @@ test("leaves partial copies alone", () => { expect(registry().scrubText("card ending 4242")).toBe("card ending 4242"); }); -test("findSecret reports formatted copies", () => { +test("findSecretIds reports formatted copies", () => { const r = registry(); - expect(r.findSecret("4242 4242 4242 4242")).toBe("card"); - expect(r.findSecret("nothing here")).toBeUndefined(); + expect(r.findSecretIds("4242 4242 4242 4242")).toEqual(["card"]); + expect(r.findSecretIds("nothing here")).toEqual([]); +}); + +test("target tags are document-scoped", () => { + const r = new RedactionRegistry(); + const target = { + browserSessionId: "b", + tabId: "t", + frameId: "f", + documentId: "d1", + elementId: "e1", + }; + r.registerTarget(target, "card"); + expect(r.isRegisteredTarget(target)).toBe(true); + expect(r.isRegisteredTarget({ ...target, documentId: "d2" })).toBe(false); }); diff --git a/test/tenants.test.ts b/test/tenants.test.ts new file mode 100644 index 0000000..4489274 --- /dev/null +++ b/test/tenants.test.ts @@ -0,0 +1,137 @@ +import { expect, test } from "bun:test"; + +import { + checkAudiences, + metadataUri, + parsePublicUrl, + parseTenants, + resolveApiKey, + resourceUri, + tenantAudience, + tenantIssuers, +} from "../src/auth/tenants.js"; + +const issuers = [ + { + issuer: "https://login.acme.com", + jwksUri: "https://login.acme.com/jwks.json", + }, +]; + +test("rejects missing keys, inline keys, bad ids, duplicates, and insecure issuers", () => { + const base = { organizationId: "o", apiKey: { path: "/k.json" }, issuers }; + expect(parseTenants({ tenants: [{ ...base, id: "a" }] })[0]?.backend).toBe( + "turnkey", + ); + const bad: [unknown[], RegExp][] = [ + [[{ id: "a", issuers, apiKey: { path: "x" } }], /organizationId/], + [[{ id: "a", issuers, organizationId: "o" }], /apiKey/], + [[{ ...base, id: "a", apiKey: { privateKey: "secret" } }], /apiKey/], + [[{ ...base, id: "../x" }], /id/], + [ + [ + { ...base, id: "a" }, + { ...base, id: "a" }, + ], + /Duplicate/, + ], + [ + [ + { + ...base, + id: "a", + issuers: [{ issuer: "http://x.com", jwksUri: "x" }], + }, + ], + /https/, + ], + [[], /tenants/], + ]; + for (const [tenants, message] of bad) { + expect(() => parseTenants({ tenants })).toThrow(message); + } +}); + +test("resolves an API key from the environment, never from the file", () => { + const [t] = parseTenants({ + tenants: [ + { + id: "acme", + organizationId: "o", + apiKey: { publicKeyEnv: "T_PUB", privateKeyEnv: "T_PRIV" }, + issuers, + }, + ], + }); + if (t?.backend !== "turnkey") throw new Error("expected turnkey"); + expect(() => resolveApiKey(t)).toThrow(/T_PUB/); + process.env["T_PUB"] = "pub"; + process.env["T_PRIV"] = "priv"; + expect(resolveApiKey(t)).toEqual({ + apiPublicKey: "pub", + apiPrivateKey: "priv", + }); + delete process.env["T_PUB"]; + delete process.env["T_PRIV"]; +}); + +test("resource and metadata URIs follow RFC 8707 and RFC 9728", () => { + const base = parsePublicUrl("https://sbm.example.com/"); + expect(resourceUri(base, "acme")).toBe("https://sbm.example.com/t/acme/mcp"); + expect(metadataUri(base, "acme")).toBe( + "https://sbm.example.com/.well-known/oauth-protected-resource/t/acme/mcp", + ); + expect(() => parsePublicUrl("http://sbm.example.com")).toThrow(); + expect(() => parsePublicUrl("https://sbm.example.com/prefix")).toThrow(); + expect(parsePublicUrl("http://127.0.0.1:8080")).toBe("http://127.0.0.1:8080"); +}); + +test("a tenant's audience defaults to its resource URI and must be unique", () => { + const base = "https://sbm.example.com"; + const tenants = parseTenants({ + tenants: [ + { id: "acme", backend: "mock", issuers }, + { id: "globex", backend: "mock", issuers, audience: "api://globex" }, + ], + }); + expect(tenantAudience(base, tenants[0]!)).toBe(resourceUri(base, "acme")); + expect(tenantAudience(base, tenants[1]!)).toBe("api://globex"); + expect(() => checkAudiences(base, tenants)).not.toThrow(); + + // An override that collides with another tenant would let one tenant's + // tokens work at the other. + const clash = parseTenants({ + tenants: [ + { id: "acme", backend: "mock", issuers }, + { + id: "globex", + backend: "mock", + issuers, + audience: resourceUri(base, "acme"), + }, + ], + }); + expect(() => checkAudiences(base, clash)).toThrow(/same token audience/); +}); + +test("requires typ: at+jwt by default when the audience is overridden", () => { + const publicUrl = "https://sbm.example.com"; + const requireTyp = (t: Record) => + tenantIssuers( + publicUrl, + parseTenants({ tenants: [{ id: "acme", backend: "mock", ...t }] })[0]!, + ).map((i) => i.requireTyp); + expect(requireTyp({ issuers })).toEqual([false]); + // An override equal to the resource URI is no override. + expect( + requireTyp({ issuers, audience: resourceUri(publicUrl, "acme") }), + ).toEqual([false]); + expect(requireTyp({ issuers, audience: "api://acme" })).toEqual([true]); + // An explicit setting wins, for servers such as Entra ID. + expect( + requireTyp({ + issuers: [{ ...issuers[0], requireTyp: false }], + audience: "api://acme", + }), + ).toEqual([false]); +}); diff --git a/test/tokens.test.ts b/test/tokens.test.ts new file mode 100644 index 0000000..672d6a1 --- /dev/null +++ b/test/tokens.test.ts @@ -0,0 +1,155 @@ +import { afterAll, beforeAll, expect, test } from "bun:test"; + +import { + InvalidTokenError, + JwksCache, + MAX_TOKEN_LIFETIME_S, + verifyAccessToken, +} from "../src/auth/tokens.js"; +import { MockAuthServer, type MintOptions } from "./mock-auth-server.js"; + +/** src/auth/tokens.ts against a live JWKS. */ + +const AUDIENCE = "https://sbm.example.com/t/acme/mcp"; +const DAY = MAX_TOKEN_LIFETIME_S; +let as: MockAuthServer; +let other: MockAuthServer; +const jwks = new JwksCache(); + +beforeAll(async () => { + as = await MockAuthServer.start(); + other = await MockAuthServer.start(); +}); + +afterAll(() => { + as.stop(); + other.stop(); +}); + +type Expect = Parameters[1]; +const expectFor = (more: Partial = {}): Expect => ({ + audience: AUDIENCE, + issuers: [{ issuer: as.issuer, jwksUri: as.jwksUri }], + ...more, +}); +const mint = (opts: MintOptions = {}) => as.mint({ aud: AUDIENCE, ...opts }); +const verify = (token: string, want = expectFor(), cache = jwks) => + verifyAccessToken(token, want, cache); + +async function rejects(token: string, want = expectFor(), cache = jwks) { + const err = await verify(token, want, cache).catch((e) => e); + expect(err).toBeInstanceOf(InvalidTokenError); +} + +test("accepts a valid token and reports its principal, client, and scopes", async () => { + const p = await verify( + await mint({ sub: "alice", scope: "sbm:browse", scp: ["sbm:fill"] }), + ); + expect(p.issuer).toBe(as.issuer); + expect(p.subject).toBe("alice"); + expect(p.client).toBeUndefined(); + // scope and scp (Okta) are merged. + expect([...p.scopes].sort()).toEqual(["sbm:browse", "sbm:fill"]); + expect([...(await verify(await mint({ scp: "sbm:refs" }))).scopes]).toEqual([ + "sbm:refs", + ]); + + // Small clock skew is tolerated. + await verify(await mint({ expiresIn: -5, notBefore: 5 })); + // Up to a day of lifetime is fine, with or without iat. + await verify(await mint({ expiresIn: DAY })); + await verify(await mint({ expiresIn: DAY, omitIat: true })); +}); + +test("rejects tokens that fail a claim check", async () => { + const bad: [string, MintOptions][] = [ + ["expired", { expiresIn: -120 }], + ["not yet valid", { notBefore: 120 }], + ["no exp", { omitExp: true }], + ["no sub", { omitSub: true }], + ["another tenant", { aud: "https://sbm.example.com/t/other/mcp" }], + ["near-miss aud", { aud: `${AUDIENCE}/` }], + ["extra aud", { aud: [AUDIENCE, "https://api.other.com"] }], + ["over a day left", { expiresIn: DAY + 120 }], + ["over a day, no iat", { expiresIn: DAY + 120, omitIat: true }], + // Issued two days ago for three days: a day left, but too long a life. + ["lifetime > a day", { issuedAt: -2 * DAY, expiresIn: DAY - 60 }], + ]; + // ID tokens, whatever their typ. + for (const claims of [ + { nonce: "n-1" }, + { at_hash: "abc" }, + { c_hash: "abc" }, + ]) { + for (const typ of ["JWT", "at+jwt", null]) { + bad.push([`ID token ${typ}`, { claims, typ }]); + } + } + for (const [, opts] of bad) await rejects(await mint(opts)); + await rejects(await as.mint({})); // no aud +}); + +test("rejects untrusted issuers, unknown keys, and weak algorithms", async () => { + await rejects(await other.mint({ aud: AUDIENCE })); + // Claims the trusted issuer, but signed with a key its JWKS lacks. + await rejects(await other.mint({ aud: AUDIENCE, iss: as.issuer })); + await rejects(await as.mintWithUnpublishedKey({ aud: AUDIENCE })); + + const b64 = (o: object) => + Buffer.from(JSON.stringify(o)).toString("base64url"); + const claims = b64({ + iss: as.issuer, + aud: AUDIENCE, + sub: "alice", + exp: Math.floor(Date.now() / 1000) + 300, + }); + await rejects(`${b64({ alg: "none" })}.${claims}.`); + await rejects(`${b64({ alg: "HS256" })}.${claims}.c2ln`); + await rejects("not-a-jwt"); +}); + +test("checks typ: at+jwt only for an issuer with requireTyp", async () => { + const strict = expectFor({ + issuers: [{ issuer: as.issuer, jwksUri: as.jwksUri, requireTyp: true }], + }); + for (const typ of ["JWT", null]) { + await verify(await mint({ typ })); + await rejects(await mint({ typ }), strict); + } + for (const typ of ["at+jwt", "application/at+JWT"]) { + await verify(await mint({ typ }), strict); + } +}); + +test("with allowedClients, accepts only those clients, by azp else client_id", async () => { + const only = expectFor({ allowedClients: ["agent-app"] }); + for (const claims of [{ azp: "agent-app" }, { client_id: "agent-app" }]) { + expect((await verify(await mint({ claims }), only)).client).toBe( + "agent-app", + ); + } + for (const claims of [ + {}, + { azp: "rogue-app" }, + { client_id: "rogue-app" }, + // azp names the client when both are present. + { azp: "rogue-app", client_id: "agent-app" }, + ]) { + await rejects(await mint({ claims }), only); + } +}); + +test("caches the JWKS, and fails closed when it cannot be fetched", async () => { + const cache = new JwksCache(); + const before = as.fetches; + await verify(await mint(), expectFor(), cache); + await verify(await mint(), expectFor(), cache); + expect(as.fetches - before).toBe(1); + + as.down = true; + try { + await rejects(await mint(), expectFor(), new JwksCache()); + } finally { + as.down = false; + } +});