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 + + + + +
+
+

Ramp agent identity · Browserbase

+

Receipt fetching agent

+
+
+ Waiting for the Browserbase session. +
+
+ +
+
+
+
+ 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(/