diff --git a/README.md b/README.md
index 3fab183..6522a8c 100644
--- a/README.md
+++ b/README.md
@@ -25,6 +25,7 @@ This plugin includes the following skills (see `skills/` for details):
| [company-research](skills/company-research/SKILL.md) | Discover target companies matching your ICP using the Browserbase Search API, deep-research each one, and score fit into a research report and CSV |
| [event-prospecting](skills/event-prospecting/SKILL.md) | Extract speakers from a conference page, filter their companies against your ICP, and deep-research the best-fit people into a person-first prospecting report |
| [competitor-analysis](skills/competitor-analysis/SKILL.md) | Auto-discover a company's competitors via the Browserbase Search API, deep-research each across marketing, signal, benchmark, and strategic-diff lanes, and compile a browsable HTML report with an overview, per-competitor deep dives, a feature/pricing matrix, and a mentions feed |
+| [fetch-event-receipts](skills/fetch-event-receipts/SKILL.md) | Retrieve a final DoorDash receipt through a persistent Browserbase Context, match it exactly to a Ramp transaction, and attach it through a standalone Ramp Agent Identity |
## Installation
diff --git a/skills/fetch-event-receipts/LICENSE.txt b/skills/fetch-event-receipts/LICENSE.txt
new file mode 100644
index 0000000..f2f4397
--- /dev/null
+++ b/skills/fetch-event-receipts/LICENSE.txt
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 Browserbase, Inc.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/skills/fetch-event-receipts/SKILL.md b/skills/fetch-event-receipts/SKILL.md
new file mode 100644
index 0000000..039e855
--- /dev/null
+++ b/skills/fetch-event-receipts/SKILL.md
@@ -0,0 +1,62 @@
+---
+name: fetch-event-receipts
+description: "Fetch a DoorDash event receipt in an authenticated Browserbase session and optionally attach it to its Ramp card transaction. Use for the Ramp Agent Identity + Browserbase receipt demo, DoorDash receipt retrieval, or a missing DoorDash receipt. Do not use for ordering food, reimbursements, other merchants, or invoices."
+license: MIT
+compatibility: "Requires browse CLI 0.9.5+, Ramp CLI 0.2.24+, Bash, Node.js 20+, jq, Poppler, Tesseract, and authenticated Browserbase and Ramp accounts."
+allowed-tools: Bash Read
+---
+
+# Fetch event receipts
+
+Use the bundled deterministic runner. Do not reconstruct the workflow as
+individual commands, invent state files, inspect CLI schemas, or add receipt
+content validators. The runner owns one Browserbase session, the DoorDash PDF
+print, the optional Ramp upload, and cleanup in one foreground process.
+
+## Interpret the request
+
+- Normalize a yearless month/day such as `8/18` to the current calendar year.
+- Normalize `~$50`, `about $50`, or `$50-ish` to a 5000-cent hint with a
+ 500-cent tolerance.
+- Retrieval is the default. Add `--attach` only when the user explicitly asks
+ to attach or upload the receipt to Ramp.
+- The private demo registry resolves the approximate request to one configured
+ Ramp transaction and DoorDash order. The runner stops if it is not unique.
+
+## Run
+
+Resolve the absolute directory containing this `SKILL.md` as `skill_root`, then
+run exactly one foreground command:
+
+```bash
+"$skill_root/scripts/run-demo.sh" \
+ --date YYYY-MM-DD \
+ --amount-hint-minor INTEGER_CENTS \
+ --amount-tolerance-minor INTEGER_CENTS \
+ [--attach]
+```
+
+For `8/18, the ~$50 order` in 2026, use:
+
+```text
+--date 2026-08-18 --amount-hint-minor 5000 --amount-tolerance-minor 500
+```
+
+Run it once. Do not background it, split it into commands, retry it, or perform
+extra preflight, schema, or help calls. Relay only its five short milestone
+updates as they appear. The runner deliberately does not inspect PDF text;
+successful DoorDash printing plus Ramp's upload response is the demo contract.
+
+If the runner says Ramp already has a receipt, ask the operator to remove it
+before recording. Do not bypass that duplicate guard. If another stage stops,
+report the single sanitized error and do not improvise a workaround.
+
+## Return
+
+After a successful run, the runner opens the matched Ramp transaction in the
+operator's default browser. It does not open Ramp after a stopped or failed run.
+
+Return the final status plus the clickable `ramp_transaction_url` and
+`browserbase_session_url`. For retrieval-only, also return `receipt_pdf`. Never
+return the Browserbase CDP URL, cookies, credentials, receipt base64, private
+registry contents, or raw CLI responses.
diff --git a/skills/fetch-event-receipts/agents/openai.yaml b/skills/fetch-event-receipts/agents/openai.yaml
new file mode 100644
index 0000000..9d5ab1d
--- /dev/null
+++ b/skills/fetch-event-receipts/agents/openai.yaml
@@ -0,0 +1,4 @@
+interface:
+ display_name: "Fetch Event Receipts"
+ short_description: "Match DoorDash receipts to Ramp transactions"
+ default_prompt: "Use $fetch-event-receipts to run the deterministic DoorDash-to-Ramp receipt workflow. Relay its five milestone updates, and attach only when I explicitly ask."
diff --git a/skills/fetch-event-receipts/assets/live-view.html b/skills/fetch-event-receipts/assets/live-view.html
new file mode 100644
index 0000000..3f00243
--- /dev/null
+++ b/skills/fetch-event-receipts/assets/live-view.html
@@ -0,0 +1,244 @@
+
+
+
+
+
+
+ Browserbase receipt agent
+
+
+
+
+
+
+
+
+
+
+ Live browser
+ DoorDash → Ramp
+
+
+
Starting live browser.
+
+
+
+
+
+
+
+
+
+
+
diff --git a/skills/fetch-event-receipts/assets/live-view.js b/skills/fetch-event-receipts/assets/live-view.js
new file mode 100644
index 0000000..b9af030
--- /dev/null
+++ b/skills/fetch-event-receipts/assets/live-view.js
@@ -0,0 +1,40 @@
+"use strict";
+
+const overallStatus = document.querySelector("#overallStatus");
+const browserPlaceholder = document.querySelector("#browserPlaceholder");
+const liveBrowser = document.querySelector("#liveBrowser");
+let visibleRevision = 0;
+
+function setLiveStatus(status, ready, revision) {
+ const messages = {
+ waiting: "Waiting for the Browserbase session.",
+ connecting: "Connecting to Browserbase.",
+ ready: "Browserbase session live.",
+ unavailable: "Live session unavailable.",
+ };
+ overallStatus.textContent = messages[status] || messages.waiting;
+ browserPlaceholder.textContent = messages[status] || messages.waiting;
+ browserPlaceholder.hidden = ready;
+
+ if (ready && Number.isInteger(revision) && revision > 0 && revision !== visibleRevision) {
+ visibleRevision = revision;
+ liveBrowser.src = `/live?revision=${revision}`;
+ } else if (!ready && visibleRevision !== 0) {
+ visibleRevision = 0;
+ liveBrowser.src = "about:blank";
+ }
+}
+
+async function refresh() {
+ try {
+ const response = await fetch("/state", { cache: "no-store", credentials: "same-origin" });
+ if (!response.ok) throw new Error("state unavailable");
+ const state = await response.json();
+ setLiveStatus(state.live_status, state.live_ready === true, state.live_revision);
+ } catch {
+ overallStatus.textContent = "Local viewer unavailable.";
+ }
+}
+
+void refresh();
+setInterval(() => void refresh(), 1000);
diff --git a/skills/fetch-event-receipts/evals/evals.json b/skills/fetch-event-receipts/evals/evals.json
new file mode 100644
index 0000000..468fbc0
--- /dev/null
+++ b/skills/fetch-event-receipts/evals/evals.json
@@ -0,0 +1,50 @@
+{
+ "skill_name": "fetch-event-receipts",
+ "evals": [
+ {
+ "id": 1,
+ "prompt": "Use fetch-event-receipts for DoorDash on 8/18, the ~$50 order.",
+ "expected_output": "The agent normalizes the request and invokes the bundled runner once without attaching.",
+ "assertions": [
+ "The date is normalized to the current calendar year",
+ "Approximate $50 becomes a 5000-cent hint with a 500-cent tolerance",
+ "The foreground runner executes exactly once without --attach",
+ "Only five runner milestones and final links are relayed"
+ ]
+ },
+ {
+ "id": 2,
+ "prompt": "Use fetch-event-receipts to attach the DoorDash receipt from 8/18, the ~$50 order, to Ramp.",
+ "expected_output": "The agent invokes the runner once with --attach and reports the verified nested Ramp upload result.",
+ "assertions": [
+ "The runner owns Browserbase, PDF printing, Ramp upload, and cleanup",
+ "A non-empty receipt_uuids array stops the write",
+ "Success requires data[0].attached_to_transaction true and a receipt UUID",
+ "The matched Ramp transaction opens only after successful completion",
+ "No schema probing or automatic retry occurs"
+ ]
+ },
+ {
+ "id": 3,
+ "prompt": "Attach the DoorDash receipt, but the Ramp transaction already has a receipt.",
+ "expected_output": "The run stops before Browserbase and asks the operator to remove the receipt.",
+ "assertions": [
+ "The duplicate check uses the exact transaction receipt_uuids array",
+ "No Browserbase session or upload is created",
+ "The agent does not bypass the guard"
+ ]
+ },
+ {
+ "id": 4,
+ "prompt": "Run the approved receipt demo and show the Browserbase live view.",
+ "expected_output": "The branded viewer opens while one recorded Browserbase session executes the workflow.",
+ "assertions": [
+ "The signed debugger URL stays server-side",
+ "One Browserbase session is used for navigation and PDF printing",
+ "The agent relays five milestones instead of narrating commands",
+ "The matched Ramp transaction opens after the run succeeds",
+ "Cleanup releases only resources created by the runner"
+ ]
+ }
+ ]
+}
diff --git a/skills/fetch-event-receipts/references/context-setup.md b/skills/fetch-event-receipts/references/context-setup.md
new file mode 100644
index 0000000..8455849
--- /dev/null
+++ b/skills/fetch-event-receipts/references/context-setup.md
@@ -0,0 +1,164 @@
+# Set up the `catering-agent` Browserbase context
+
+Read this only when the named context is missing, stale, or logged out.
+
+## Important distinction
+
+The Browse CLI name `catering-agent` is a local alias for an opaque Browserbase
+context UUID. The alias is stored on the machine running the CLI; it is not a
+server-side display name.
+
+Current Browse CLI releases support naming directly:
+
+```bash
+browse cloud contexts create --name catering-agent
+```
+
+If someone already created the context and shared its UUID privately, save the
+local alias without creating another context:
+
+```bash
+browse cloud contexts add catering-agent
+```
+
+Do not put the UUID in source code, a public issue, a PR, or a recording.
+
+## Option A: seed logins in a Browserbase session
+
+Create one persistent session and attach the Browse driver. Use the same lock as
+normal runs so setup cannot overlap a receipt fetch. Run this entire option in
+one long-lived Bash process; do not split its trap and commands across shell
+calls:
+
+```bash
+context_lock_dir="${TMPDIR:-/tmp}/fetch-event-receipts-catering-agent.lock"
+mkdir "$context_lock_dir" 2>/dev/null || {
+ printf '%s\n' 'catering-agent is already in use or needs stale-lock review' >&2
+ exit 1
+}
+setup_session_id=''
+setup_driver_started=false
+setup_cleanup_complete=false
+
+cleanup_context_setup() {
+ setup_status=$?
+ trap - EXIT HUP INT TERM
+ if [[ "$setup_cleanup_complete" != true ]]; then
+ if [[ "$setup_driver_started" == true ]]; then
+ browse stop --session catering-context-setup >/dev/null 2>&1 || true
+ fi
+ if [[ -n "$setup_session_id" ]]; then
+ browse cloud sessions update "$setup_session_id" \
+ --status REQUEST_RELEASE >/dev/null 2>&1 || true
+ setup_remote_status=''
+ for attempt in {1..30}; do
+ setup_remote_status="$(
+ browse cloud sessions get "$setup_session_id" 2>/dev/null \
+ | sed -n '/^{/,$p' \
+ | jq -r '.status // empty'
+ )"
+ [[ "$setup_remote_status" == COMPLETED ]] && break
+ sleep 2
+ done
+ if [[ "$setup_remote_status" != COMPLETED ]]; then
+ printf '%s\n' 'context_setup_cleanup_unconfirmed' >&2
+ setup_status=1
+ fi
+ fi
+ if [[ -z "$setup_session_id" || "${setup_remote_status:-}" == COMPLETED ]]; then
+ rmdir "$context_lock_dir" 2>/dev/null || setup_status=1
+ fi
+ fi
+ unset setup_connect_url setup_json setup_output
+ exit "$setup_status"
+}
+trap cleanup_context_setup EXIT HUP INT TERM
+
+if setup_output="$(browse cloud sessions create \
+ --context-id catering-agent \
+ --persist \
+ --timeout 900 \
+ --no-record-session \
+ --no-log-session 2>&1)"; then
+ setup_create_status=0
+else
+ setup_create_status=$?
+fi
+setup_json="$(printf '%s\n' "$setup_output" | sed -n '/^{/,$p')"
+setup_session_id="$(jq -r '
+ .id
+ | select(type == "string")
+ | select(test("^[0-9a-fA-F-]{36}$"))
+' <<<"$setup_json" 2>/dev/null || true)"
+if ((setup_create_status != 0)); then
+ printf '%s\n' 'Browserbase context-setup session creation failed.' >&2
+ exit "$setup_create_status"
+fi
+jq -e '
+ (.id | type == "string" and test("^[0-9a-fA-F-]{36}$")) and
+ (.connectUrl | type == "string" and test("^wss?://"))
+' <<<"$setup_json" >/dev/null || exit 1
+
+setup_session_id="$(jq -r '.id' <<<"$setup_json")"
+setup_connect_url="$(jq -r '.connectUrl' <<<"$setup_json")"
+
+browse open https://www.doordash.com \
+ --cdp "$setup_connect_url" \
+ --session catering-context-setup
+setup_driver_started=true
+```
+
+Use `browse cloud sessions debug "$setup_session_id"` to obtain the live-view
+URL and open it only after confirming it begins with `https://`. The user, not
+the agent, completes passwords, SSO, CAPTCHA, and multifactor authentication in
+that live view.
+
+Verify DoorDash by navigating to its order/receipt page and confirming
+authenticated account content is visible. Do not record account names,
+addresses, or order details as setup evidence.
+
+When the DoorDash login is verified, exit the long-lived shell normally. Its
+registered trap stops the local driver, requests remote release, polls for
+`COMPLETED`, and removes the lock only after that terminal state:
+
+```bash
+exit 0
+```
+
+Only `COMPLETED` proves the remote session released and context persistence
+finished. Disable recordings during login setup so a replay cannot capture
+credentials or one-time authentication screens. If cleanup cannot be confirmed,
+the trap keeps the lock for stale-lock review.
+
+## Option B: seed from local Chrome with `$cookie-sync`
+
+Use `$cookie-sync` when the user is already logged into DoorDash in a debuggable
+local Chrome. Sync only this domain:
+
+```text
+doordash.com
+```
+
+For a new sync, save the returned Browserbase context UUID under the requested
+name:
+
+```bash
+browse cloud contexts add catering-agent
+```
+
+To refresh an existing context, resolve `catering-agent` privately and pass the
+real UUID to cookie-sync's `--context` option. Do not echo the UUID into public
+logs or artifacts.
+
+## Operating rule for the shared context
+
+Keep sessions sequential, use a consistent proxy geography if one is
+introduced, and expect DoorDash to expire its login state even though the
+Browserbase context itself persists.
+
+Skill runs enforce an atomic local lock so only one session can use the shared
+context at a time.
+
+Ramp is not stored in this context. Follow
+[ramp-identity-setup.md](ramp-identity-setup.md) separately and keep the
+standalone Ramp agent's CLI state isolated from personal Ramp authentication.
diff --git a/skills/fetch-event-receipts/references/ramp-identity-setup.md b/skills/fetch-event-receipts/references/ramp-identity-setup.md
new file mode 100644
index 0000000..3ec580d
--- /dev/null
+++ b/skills/fetch-event-receipts/references/ramp-identity-setup.md
@@ -0,0 +1,123 @@
+# Set up the standalone Ramp receipt identity
+
+Read this before the first live demo or whenever the isolated agent login has
+expired.
+
+## Availability and ownership
+
+Ramp currently describes standalone agents as limited early access. If an admin
+cannot see **Company > Agents**, stop and request enablement
+through or `agents@ramp.com`. Never include a Client
+secret or token in that request.
+
+A standalone agent belongs to the business, receives permissions explicitly
+assigned by an admin, has an accountable human owner, and is attributed as the
+actor in supported Ramp activity. This is different from ordinary `ramp auth
+login`, which acts on behalf of the human who completed browser OAuth.
+
+## One-time admin setup
+
+Require a Ramp admin. In **Roles & Permissions**, create:
+
+```text
+Role: Receipt Cleanup Agent Role
+Permission: Review and edit transactions
+```
+
+Ramp includes **View transactions and reimbursements** as the visibility
+dependency. Do not add card controls, bill permissions, approval-policy access,
+payment access, or unrelated administrative permissions.
+
+In **Company > Agents**, create:
+
+```text
+Agent:
+Job: Match final DoorDash catering receipts to exact card transactions and attach them.
+Role: Receipt Cleanup Agent Role
+Boundary: Cannot spend, approve, pay, edit policy, or operate outside confirmed receipt cleanup.
+```
+
+Save the Client ID and Client secret in approved secure storage. The Client ID
+is not secret; the Client secret must never enter chat, shell history, source
+code, a plaintext file, or recorded terminal output.
+
+Official onboarding instructions:
+
+
+## CLI capability check
+
+The standalone flow requires Ramp CLI 0.2.24 or newer; that is the release whose
+auth and receipt schemas this skill validated:
+
+```bash
+ramp --version
+ramp auth login --help
+ramp agent list --help
+```
+
+The login help must expose `--client-id`; the agent resource may be conditional
+on preview enablement. If the CLI is stale, explain that updating changes the
+local installation and ask before running `ramp update`. Do not broaden Ramp
+permissions to work around a missing command.
+
+Help text is not the final compatibility proof. Before a live upload, the skill's
+receipt helper must complete its no-write `--dry_run` and show the expected
+`/developer/v1/agent-tools/upload-receipt-file` endpoint, intended transaction
+UUID, MIME type, and redacted base64 field.
+
+Before login, an admin must open **Company > Agents**, select the intended
+receipt agent, and confirm its display name, active status, accountable owner,
+`Receipt Cleanup Agent Role`, and non-secret Client ID against the approved
+provisioning record. Preserve that verified display name as the expected actor
+for the run. A standalone credential cannot assume it may list the business's
+agents; treat `ramp agent list` as optional rather than an identity-proof
+prerequisite. Directory naming and scopes are not identity proof. Do not use the
+admin's human session for the receipt run.
+
+For a recorded demo, capture this setup surface separately if it contains no
+unapproved private data. It proves the identity and permissions, not that the
+identity performed a later receipt upload.
+
+## Isolated runtime login
+
+Every command for the standalone identity uses its own config directory:
+
+```bash
+ramp_agent_config_home="$HOME/.config/ramp-agents/catering-receipt-agent"
+```
+
+That directory is a stable local alias, not the Ramp activity display name.
+After a demo upload, the attributed Ramp activity actor must match the
+admin-verified agent (Ramp may render it as ` (Agent)`).
+
+Open a private local terminal prompt where the user can enter the Client secret
+without echo. The authentication call is:
+
+```bash
+RAMP_CLIENT_SECRET="$secret" \
+XDG_CONFIG_HOME="$ramp_agent_config_home" \
+ramp --env production auth login \
+ --client-id "$RAMP_CLIENT_ID" \
+ --scope transactions:read \
+ --scope receipts:write
+```
+
+Do not ask the user to paste the secret into chat or a visible command. Hold it
+only for the login process, then unset it. Verify the isolated identity without
+touching the operator's personal CLI state:
+
+```bash
+XDG_CONFIG_HOME="$ramp_agent_config_home" \
+ramp --env production auth status
+```
+
+Then run one small read-only transaction query under the same prefix. A
+successful auth status alone proves neither the expected identity nor permission
+correctness. For the demo, perform a fresh client-credential login with the
+exact Client ID verified above instead of relying only on cached config state.
+If the principal cannot be tied back to that login, stop before reads or writes.
+
+Standalone-agent access tokens do not refresh automatically. When an unattended
+or long-running runtime receives an auth-expiry error, repeat the client-
+credential login through the secure secret mechanism; do not fall back to the
+operator's personal Ramp session.
diff --git a/skills/fetch-event-receipts/scripts/live-view.mjs b/skills/fetch-event-receipts/scripts/live-view.mjs
new file mode 100755
index 0000000..809d271
--- /dev/null
+++ b/skills/fetch-event-receipts/scripts/live-view.mjs
@@ -0,0 +1,371 @@
+#!/usr/bin/env node
+
+import { execFile } from "node:child_process";
+import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
+import { constants as fsConstants } from "node:fs";
+import {
+ access,
+ chmod,
+ lstat,
+ readFile,
+ rename,
+ rm,
+ writeFile,
+} from "node:fs/promises";
+import { createServer } from "node:http";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import { promisify } from "node:util";
+
+const execFileAsync = promisify(execFile);
+const SCRIPT_PATH = fileURLToPath(import.meta.url);
+const SKILL_DIR = path.dirname(path.dirname(SCRIPT_PATH));
+const HTML_PATH = path.join(SKILL_DIR, "assets", "live-view.html");
+const JS_PATH = path.join(SKILL_DIR, "assets", "live-view.js");
+const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
+const MAX_SESSION_FILE_BYTES = 128;
+
+function parseArgs(argv) {
+ const values = {};
+ for (let index = 0; index < argv.length; index += 1) {
+ const argument = argv[index];
+ if (!["--session-id-file", "--ready-file", "--port"].includes(argument)) {
+ throw new Error("Unknown argument.");
+ }
+ const value = argv[index + 1];
+ if (!value || value.startsWith("--")) throw new Error("Argument value is required.");
+ values[argument.slice(2)] = value;
+ index += 1;
+ }
+ return values;
+}
+
+function required(value, label) {
+ if (typeof value !== "string" || value.length === 0) throw new Error(`${label} is required.`);
+ return value;
+}
+
+async function validateParent(filePath, writable = false) {
+ if (!path.isAbsolute(filePath) || /[\r\n]/u.test(filePath)) {
+ throw new Error("File paths must be absolute and contain no control characters.");
+ }
+ const parent = path.dirname(filePath);
+ const parentInfo = await lstat(parent);
+ if (!parentInfo.isDirectory() || parentInfo.isSymbolicLink()) {
+ throw new Error("File parent must be a directory, not a symlink.");
+ }
+ await access(parent, writable ? fsConstants.W_OK : fsConstants.R_OK);
+}
+
+async function readPrivateFile(filePath, maxBytes) {
+ let info;
+ try {
+ info = await lstat(filePath);
+ } catch (error) {
+ if (error?.code === "ENOENT") return null;
+ throw error;
+ }
+ if (!info.isFile() || info.isSymbolicLink() || info.nlink !== 1) return null;
+ if (typeof process.getuid === "function" && info.uid !== process.getuid()) return null;
+ if ((info.mode & 0o077) !== 0 || info.size > maxBytes) return null;
+ return readFile(filePath, "utf8");
+}
+
+function extractJsonObject(output) {
+ for (let start = output.indexOf("{"); start >= 0; start = output.indexOf("{", start + 1)) {
+ let depth = 0;
+ let inString = false;
+ let escaped = false;
+ for (let index = start; index < output.length; index += 1) {
+ const character = output[index];
+ if (inString) {
+ if (escaped) escaped = false;
+ else if (character === "\\") escaped = true;
+ else if (character === '"') inString = false;
+ continue;
+ }
+ if (character === '"') inString = true;
+ else if (character === "{") depth += 1;
+ else if (character === "}") {
+ depth -= 1;
+ if (depth === 0) {
+ try {
+ return JSON.parse(output.slice(start, index + 1));
+ } catch {
+ break;
+ }
+ }
+ }
+ }
+ }
+ return null;
+}
+
+function allowedLiveUrl(value) {
+ if (typeof value !== "string" || value.length === 0 || value.length > 8192) return null;
+ try {
+ const candidate = new URL(value);
+ const allowedHost = candidate.hostname === "browserbase.com" || candidate.hostname.endsWith(".browserbase.com");
+ if (candidate.protocol !== "https:" || !allowedHost || candidate.username || candidate.password) return null;
+ return candidate.href;
+ } catch {
+ return null;
+ }
+}
+
+function selectLiveUrl(payload) {
+ const pages = Array.isArray(payload?.pages) ? payload.pages : [];
+ const page = pages.find((candidate) => candidate?.url && candidate.url !== "about:blank") || pages[0];
+ return allowedLiveUrl(page?.debuggerFullscreenUrl || payload?.debuggerFullscreenUrl);
+}
+
+async function resolveLiveUrl(sessionId, signal) {
+ const browseBinary = process.env.BROWSE_BIN || "browse";
+ try {
+ const { stdout } = await execFileAsync(
+ browseBinary,
+ ["cloud", "sessions", "debug", sessionId],
+ {
+ env: { ...process.env, BROWSE_DISABLE_UPDATE_CHECK: "1", NO_COLOR: "1" },
+ maxBuffer: 1024 * 1024,
+ signal,
+ timeout: 10_000,
+ windowsHide: true,
+ },
+ );
+ return selectLiveUrl(extractJsonObject(stdout));
+ } catch {
+ return null;
+ }
+}
+
+async function atomicReadyWrite(readyPath, value) {
+ const temporary = `${readyPath}.${process.pid}.tmp`;
+ const contents = `${JSON.stringify(value)}\n`;
+ await writeFile(temporary, contents, { encoding: "utf8", flag: "wx", mode: 0o600 });
+ await chmod(temporary, 0o600);
+ await rename(temporary, readyPath);
+ await chmod(readyPath, 0o600);
+}
+
+function baseHeaders(contentType, csp) {
+ return {
+ "cache-control": "no-store, max-age=0",
+ "content-type": contentType,
+ "content-security-policy": csp,
+ "permissions-policy": "camera=(), microphone=(), geolocation=(), payment=(), usb=()",
+ "referrer-policy": "no-referrer",
+ "x-content-type-options": "nosniff",
+ "x-frame-options": "DENY",
+ };
+}
+
+function tokenMatches(candidate, expected) {
+ if (typeof candidate !== "string" || candidate.length !== expected.length) return false;
+ return timingSafeEqual(Buffer.from(candidate), Buffer.from(expected));
+}
+
+async function main() {
+ const args = parseArgs(process.argv.slice(2));
+ const sessionIdPath = required(args["session-id-file"], "session-id-file");
+ const readyPath = required(args["ready-file"], "ready-file");
+ const port = args.port === undefined ? 0 : Number(args.port);
+ if (!Number.isInteger(port) || port < 0 || port > 65535) throw new Error("port must be an integer from 0 through 65535.");
+
+ await validateParent(sessionIdPath);
+ await validateParent(readyPath, true);
+ try {
+ const readyInfo = await lstat(readyPath);
+ if (!readyInfo.isFile() || readyInfo.isSymbolicLink() || readyInfo.nlink !== 1) {
+ throw new Error("ready-file must be a regular file, not a symlink.");
+ }
+ if (typeof process.getuid === "function" && readyInfo.uid !== process.getuid()) {
+ throw new Error("ready-file must be owned by the current user.");
+ }
+ } catch (error) {
+ if (error?.code !== "ENOENT") throw error;
+ }
+
+ const [html, clientScript] = await Promise.all([
+ readFile(HTML_PATH, "utf8"),
+ readFile(JS_PATH, "utf8"),
+ ]);
+ const styleSource = html.match(/